diff --git a/Cargo.lock b/Cargo.lock index 62fe5d83d..e5e1c842c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6642,6 +6642,7 @@ dependencies = [ "pallet-balances", "parity-scale-codec", "polkadot-sdk-frame", + "qp-high-security", "scale-info", ] @@ -6813,6 +6814,7 @@ dependencies = [ "pallet-root-testing", "pallet-timestamp", "parity-scale-codec", + "qp-high-security", "scale-info", "sp-core", "sp-io", @@ -8077,6 +8079,9 @@ dependencies = [ [[package]] name = "qp-high-security" version = "0.1.0" +dependencies = [ + "parity-scale-codec", +] [[package]] name = "qp-plonky2" diff --git a/pallets/multisig/src/lib.rs b/pallets/multisig/src/lib.rs index 62c0a0c1d..fb419c16b 100644 --- a/pallets/multisig/src/lib.rs +++ b/pallets/multisig/src/lib.rs @@ -621,7 +621,8 @@ pub mod pallet { // ===== PHASE 4: High-security whitelist check (if applicable) ===== // (additional read: HighSecurityAccounts) let is_high_security = T::HighSecurity::is_high_security(&multisig_address); - if is_high_security && !T::HighSecurity::is_whitelisted(&decoded_call) { + // Use the shared `is_call_allowed` policy so `propose` and `execute` stay consistent. + if !T::HighSecurity::is_call_allowed(&multisig_address, &decoded_call) { // Don't refund after decode - same reasoning as above. return Self::err_burn_full(Error::::CallNotAllowedForHighSecurityMultisig); } @@ -1130,9 +1131,7 @@ pub mod pallet { // or the whitelist may have been updated via runtime upgrade. // This prevents bypassing HS restrictions by proposing before enabling HS. // After decode + get_dispatch_info, don't refund - burn the full reserved weight. - if T::HighSecurity::is_high_security(&multisig_address) && - !T::HighSecurity::is_whitelisted(&call) - { + if !T::HighSecurity::is_call_allowed(&multisig_address, &call) { return Self::err_burn_full(Error::::CallNotAllowedForHighSecurityMultisig); } diff --git a/pallets/multisig/src/mock.rs b/pallets/multisig/src/mock.rs index d9f8b72d1..fe2066eea 100644 --- a/pallets/multisig/src/mock.rs +++ b/pallets/multisig/src/mock.rs @@ -332,6 +332,7 @@ impl pallet_recovery::Config for Test { type MaxFriends = MaxFriends; type RecoveryDeposit = RecoveryDeposit; type BlockNumberProvider = System; + type HighSecurity = (); } impl pallet_preimage::Config for Test { @@ -372,6 +373,7 @@ impl pallet_utility::Config for Test { type RuntimeCall = RuntimeCall; type PalletsOrigin = OriginCaller; type WeightInfo = (); + type HighSecurity = (); } pub fn new_test_ext() -> sp_io::TestExternalities { diff --git a/pallets/recovery/Cargo.toml b/pallets/recovery/Cargo.toml index a8022da49..4ba548afe 100644 --- a/pallets/recovery/Cargo.toml +++ b/pallets/recovery/Cargo.toml @@ -18,6 +18,7 @@ targets = ["x86_64-unknown-linux-gnu"] [dependencies] codec = { features = ["derive"], workspace = true } frame = { workspace = true, features = ["runtime"] } +qp-high-security = { path = "../../primitives/high-security", default-features = false } scale-info = { features = ["derive"], workspace = true } [dev-dependencies] @@ -31,6 +32,7 @@ runtime-benchmarks = [ std = [ "codec/std", "frame/std", + "qp-high-security/std", "scale-info/std", ] try-runtime = [ diff --git a/pallets/recovery/src/lib.rs b/pallets/recovery/src/lib.rs index 0de0ea1b8..cd4c1463b 100644 --- a/pallets/recovery/src/lib.rs +++ b/pallets/recovery/src/lib.rs @@ -158,6 +158,7 @@ use frame::{ prelude::*, traits::{Currency, ReservableCurrency}, }; +use qp_high_security::HighSecurityInspector; pub use pallet::*; pub use weights::WeightInfo; @@ -248,6 +249,12 @@ pub mod pallet { + GetDispatchInfo + From>; + /// Enforces high-security whitelist restrictions at the recovered (effective) origin. + type HighSecurity: qp_high_security::HighSecurityInspector< + Self::AccountId, + ::RuntimeCall, + >; + /// Query the current block number. /// /// Must return monotonically increasing values when called from consecutive blocks. @@ -343,6 +350,8 @@ pub mod pallet { pub enum Error { /// User is not allowed to make a call on behalf of this account NotAllowed, + /// Call is not allowed for a high-security account + CallNotAllowedForHighSecurity, /// Threshold must be greater than zero ZeroThreshold, /// Friends list must be greater than zero and threshold @@ -434,6 +443,10 @@ pub mod pallet { // Check `who` is allowed to make a call on behalf of `account` let target = Self::proxy(&who).ok_or(Error::::NotAllowed)?; ensure!(target == account, Error::::NotAllowed); + ensure!( + T::HighSecurity::is_call_allowed(&account, &call), + Error::::CallNotAllowedForHighSecurity + ); call.dispatch(frame_system::RawOrigin::Signed(account).into()) .map(|_| ()) .map_err(|e| e.error) diff --git a/pallets/recovery/src/mock.rs b/pallets/recovery/src/mock.rs index 03e62574f..905095794 100644 --- a/pallets/recovery/src/mock.rs +++ b/pallets/recovery/src/mock.rs @@ -68,11 +68,25 @@ impl Config for Test { type FriendDepositFactor = FriendDepositFactor; type MaxFriends = MaxFriends; type RecoveryDeposit = RecoveryDeposit; + type HighSecurity = qp_high_security::testing::TestHighSecurity; +} + +/// High-security accounts in tests may only dispatch `System::remark`. +pub struct HighSecurityWhitelist; +impl qp_high_security::testing::Whitelist for HighSecurityWhitelist { + fn contains(call: &RuntimeCall) -> bool { + matches!(call, RuntimeCall::System(frame_system::Call::remark { .. })) + } } pub type BalancesCall = pallet_balances::Call; pub type RecoveryCall = super::Call; +/// A whitelisted (`System::remark`) call, for high-security test cases. +pub fn remark_call() -> RuntimeCall { + RuntimeCall::System(frame_system::Call::remark { remark: vec![] }) +} + pub fn new_test_ext() -> sp_io::TestExternalities { let mut t = frame_system::GenesisConfig::::default().build_storage().unwrap(); pallet_balances::GenesisConfig:: { diff --git a/pallets/recovery/src/tests.rs b/pallets/recovery/src/tests.rs index f1a13bf53..078dcb869 100644 --- a/pallets/recovery/src/tests.rs +++ b/pallets/recovery/src/tests.rs @@ -786,3 +786,52 @@ fn poke_deposit_handles_insufficient_balance() { assert_eq!(Balances::reserved_balance(5), 13); }); } + +#[test] +fn as_recovered_blocks_high_security_non_whitelisted_call() { + new_test_ext().execute_with(|| { + qp_high_security::testing::reset(); + // Account 1 can act for the (high-security) lost account 5. + assert_ok!(Recovery::set_recovered(RuntimeOrigin::root(), 5, 1)); + qp_high_security::testing::set_high_security(&5u64); + + // A non-whitelisted call as the high-security account is rejected before dispatch. + let call = Box::new(RuntimeCall::Balances(BalancesCall::transfer_allow_death { + dest: 1, + value: 100, + })); + assert_noop!( + Recovery::as_recovered(RuntimeOrigin::signed(1), 5, call), + Error::::CallNotAllowedForHighSecurity + ); + // The lost account's funds are untouched. + assert_eq!(Balances::free_balance(5), 100); + }); +} + +#[test] +fn as_recovered_allows_high_security_whitelisted_call() { + new_test_ext().execute_with(|| { + qp_high_security::testing::reset(); + assert_ok!(Recovery::set_recovered(RuntimeOrigin::root(), 5, 1)); + qp_high_security::testing::set_high_security(&5u64); + + // A whitelisted call as the high-security account is allowed through. + assert_ok!(Recovery::as_recovered(RuntimeOrigin::signed(1), 5, Box::new(remark_call()))); + }); +} + +#[test] +fn as_recovered_non_high_security_is_unaffected() { + new_test_ext().execute_with(|| { + qp_high_security::testing::reset(); + // Lost account 5 is not high-security, so arbitrary calls remain allowed. + assert_ok!(Recovery::set_recovered(RuntimeOrigin::root(), 5, 1)); + let call = Box::new(RuntimeCall::Balances(BalancesCall::transfer_allow_death { + dest: 1, + value: 100, + })); + assert_ok!(Recovery::as_recovered(RuntimeOrigin::signed(1), 5, call)); + assert_eq!(Balances::free_balance(1), 200); + }); +} diff --git a/pallets/reversible-transfers/src/tests/mock.rs b/pallets/reversible-transfers/src/tests/mock.rs index 9e11291f2..bd2f4e5b2 100644 --- a/pallets/reversible-transfers/src/tests/mock.rs +++ b/pallets/reversible-transfers/src/tests/mock.rs @@ -323,6 +323,7 @@ impl pallet_recovery::Config for Test { type MaxFriends = MaxFriends; type RecoveryDeposit = RecoveryDeposit; type BlockNumberProvider = System; + type HighSecurity = (); } impl pallet_preimage::Config for Test { @@ -364,6 +365,7 @@ impl pallet_utility::Config for Test { type RuntimeCall = RuntimeCall; type PalletsOrigin = OriginCaller; type WeightInfo = (); + type HighSecurity = (); } // Build genesis storage according to the mock runtime. diff --git a/pallets/utility/Cargo.toml b/pallets/utility/Cargo.toml index 68d5a8f8a..7be28a145 100644 --- a/pallets/utility/Cargo.toml +++ b/pallets/utility/Cargo.toml @@ -20,6 +20,7 @@ codec = { workspace = true } frame-benchmarking = { optional = true, workspace = true } frame-support.workspace = true frame-system.workspace = true +qp-high-security = { path = "../../primitives/high-security", default-features = false } scale-info = { features = ["derive"], workspace = true } sp-core.workspace = true sp-io.workspace = true @@ -44,6 +45,7 @@ std = [ "frame-benchmarking?/std", "frame-support/std", "frame-system/std", + "qp-high-security/std", "scale-info/std", "sp-core/std", "sp-io/std", diff --git a/pallets/utility/src/lib.rs b/pallets/utility/src/lib.rs index db1f3b713..6152de577 100644 --- a/pallets/utility/src/lib.rs +++ b/pallets/utility/src/lib.rs @@ -68,6 +68,7 @@ use frame_support::{ }, traits::{IsSubType, OriginTrait, UnfilteredDispatchable}, }; +use qp_high_security::HighSecurityInspector; use sp_core::TypeId; use sp_io::hashing::blake2_256; use sp_runtime::traits::{BadOrigin, Dispatchable, TrailingZeroInput}; @@ -107,6 +108,12 @@ pub mod pallet { /// Weight information for extrinsics in this pallet. type WeightInfo: WeightInfo; + + /// Enforces high-security whitelist restrictions at the derivative (effective) origin. + type HighSecurity: qp_high_security::HighSecurityInspector< + Self::AccountId, + ::RuntimeCall, + >; } #[pallet::event] @@ -168,6 +175,8 @@ pub mod pallet { pub enum Error { /// Too many calls batched. TooManyCalls, + /// Call is not allowed for a high-security account. + CallNotAllowedForHighSecurity, } #[pallet::call] @@ -270,6 +279,10 @@ pub mod pallet { let mut origin = origin; let who = ensure_signed(origin.clone())?; let pseudonym = derivative_account_id(who, index); + ensure!( + T::HighSecurity::is_call_allowed(&pseudonym, &call), + Error::::CallNotAllowedForHighSecurity + ); origin.set_caller_from(frame_system::RawOrigin::Signed(pseudonym)); let info = call.get_dispatch_info(); let result = call.dispatch(origin); diff --git a/pallets/utility/src/tests.rs b/pallets/utility/src/tests.rs index d4b7b1bc5..a13379472 100644 --- a/pallets/utility/src/tests.rs +++ b/pallets/utility/src/tests.rs @@ -224,6 +224,15 @@ impl Config for Test { type RuntimeCall = RuntimeCall; type PalletsOrigin = OriginCaller; type WeightInfo = (); + type HighSecurity = qp_high_security::testing::TestHighSecurity; +} + +/// High-security accounts in tests may only dispatch `System::remark`. +pub struct HighSecurityWhitelist; +impl qp_high_security::testing::Whitelist for HighSecurityWhitelist { + fn contains(call: &RuntimeCall) -> bool { + matches!(call, RuntimeCall::System(frame_system::Call::remark { .. })) + } } type ExampleCall = example::Call; @@ -1090,3 +1099,44 @@ fn if_else_with_nested_if_else_works() { System::assert_last_event(utility::Event::IfElseMainSuccess.into()); }); } + +fn remark_call() -> RuntimeCall { + RuntimeCall::System(frame_system::Call::remark { remark: vec![] }) +} + +#[test] +fn as_derivative_blocks_high_security_non_whitelisted_call() { + new_test_ext().execute_with(|| { + qp_high_security::testing::reset(); + // Make account 6's index-0 derivative a high-security account. + let derivative = crate::derivative_account_id(6u64, 0); + qp_high_security::testing::set_high_security(&derivative); + + // A non-whitelisted inner call is rejected at the derivative origin before dispatch. + assert_err_ignore_postinfo!( + Utility::as_derivative(RuntimeOrigin::signed(6), 0, Box::new(call_transfer(2, 1))), + crate::Error::::CallNotAllowedForHighSecurity + ); + }); +} + +#[test] +fn as_derivative_allows_high_security_whitelisted_call() { + new_test_ext().execute_with(|| { + qp_high_security::testing::reset(); + let derivative = crate::derivative_account_id(6u64, 0); + qp_high_security::testing::set_high_security(&derivative); + + // A whitelisted inner call as the high-security derivative is allowed through. + assert_ok!(Utility::as_derivative(RuntimeOrigin::signed(6), 0, Box::new(remark_call()))); + }); +} + +#[test] +fn as_derivative_non_high_security_derivative_is_unaffected() { + new_test_ext().execute_with(|| { + qp_high_security::testing::reset(); + // Account 6's index-1 derivative is not high-security, so the check is a no-op. + assert_ok!(Utility::as_derivative(RuntimeOrigin::signed(6), 1, Box::new(remark_call()))); + }); +} diff --git a/primitives/high-security/Cargo.toml b/primitives/high-security/Cargo.toml index 0721636e7..f7e387959 100644 --- a/primitives/high-security/Cargo.toml +++ b/primitives/high-security/Cargo.toml @@ -9,6 +9,10 @@ publish = false repository.workspace = true version = "0.1.0" +[dependencies] +codec = { workspace = true, default-features = false, optional = true } + [features] default = ["std"] -std = [] +# `codec` is only needed by the `std`-only `testing` inspector. +std = ["codec/std", "dep:codec"] diff --git a/primitives/high-security/src/lib.rs b/primitives/high-security/src/lib.rs index 56a89ed4a..d4103aecf 100644 --- a/primitives/high-security/src/lib.rs +++ b/primitives/high-security/src/lib.rs @@ -122,6 +122,15 @@ pub trait HighSecurityInspector { /// `Some(guardian_account)` if the account has a guardian, `None` otherwise fn guardian(who: &AccountId) -> Option; + /// Whether `call` may be dispatched with `who` as the effective signed origin. + /// + /// Non-High-Security accounts may dispatch anything; High-Security accounts are + /// restricted to whitelisted calls. Origin-rewriting wrappers (multisig execution, + /// `as_recovered`, `as_derivative`) must consult this before dispatching as `who`. + fn is_call_allowed(who: &AccountId, call: &RuntimeCall) -> bool { + !Self::is_high_security(who) || Self::is_whitelisted(call) + } + // NOTE: No benchmarking-specific methods in the trait! // Production API should not be polluted by test/benchmark requirements. // Use pallet-specific helpers instead (e.g., @@ -154,3 +163,55 @@ impl HighSecurityInspector for ( None } } + +/// Reusable [`HighSecurityInspector`] test double shared by pallet test suites. +/// +/// Accounts registered via [`testing::set_high_security`] are treated as high-security; the +/// permitted calls for those accounts are described by the [`testing::Whitelist`] type parameter. +#[cfg(feature = "std")] +pub mod testing { + use super::HighSecurityInspector; + use codec::Encode; + use core::marker::PhantomData; + use std::cell::RefCell; + + thread_local! { + static HIGH_SECURITY: RefCell>> = const { RefCell::new(Vec::new()) }; + } + + /// Mark `who` as a high-security account for the current test thread. + pub fn set_high_security(who: &A) { + let who = who.encode(); + HIGH_SECURITY.with(|hs| hs.borrow_mut().push(who)); + } + + /// Forget every account registered via [`set_high_security`]. + pub fn reset() { + HIGH_SECURITY.with(|hs| hs.borrow_mut().clear()); + } + + /// Compile-time predicate for the calls a high-security account may dispatch. + pub trait Whitelist { + fn contains(call: &RuntimeCall) -> bool; + } + + /// Configurable inspector: registered accounts are high-security and restricted to `W`. + pub struct TestHighSecurity(PhantomData); + + impl> + HighSecurityInspector for TestHighSecurity + { + fn is_high_security(who: &AccountId) -> bool { + let who = who.encode(); + HIGH_SECURITY.with(|hs| hs.borrow().contains(&who)) + } + + fn is_whitelisted(call: &RuntimeCall) -> bool { + W::contains(call) + } + + fn guardian(_who: &AccountId) -> Option { + None + } + } +} diff --git a/runtime/src/configs/mod.rs b/runtime/src/configs/mod.rs index de78cc2ab..1c921ea9e 100644 --- a/runtime/src/configs/mod.rs +++ b/runtime/src/configs/mod.rs @@ -463,6 +463,7 @@ impl pallet_utility::Config for Runtime { type RuntimeEvent = RuntimeEvent; type PalletsOrigin = OriginCaller; type WeightInfo = pallet_utility::weights::SubstrateWeight; + type HighSecurity = HighSecurityConfig; } parameter_types! { @@ -486,6 +487,7 @@ impl pallet_recovery::Config for Runtime { type MaxFriends = MaxFriends; type RecoveryDeposit = RecoveryDeposit; type BlockNumberProvider = System; + type HighSecurity = HighSecurityConfig; } parameter_types! { diff --git a/runtime/src/transaction_extensions.rs b/runtime/src/transaction_extensions.rs index 74b63df84..26b8f9c23 100644 --- a/runtime/src/transaction_extensions.rs +++ b/runtime/src/transaction_extensions.rs @@ -43,6 +43,7 @@ impl const IDENTIFIER: &'static str = "ReversibleTransactionExtension"; fn weight(&self, _call: &RuntimeCall) -> Weight { + // One `is_high_security` storage read for the flat whitelist check. T::DbWeight::get().reads(1) } @@ -70,14 +71,11 @@ impl let who = ensure_signed(origin.clone()) .map_err(|_| TransactionValidityError::Invalid(InvalidTransaction::BadSigner))?; - // Check if account is high-security using the same inspector as multisig - if crate::configs::HighSecurityConfig::is_high_security(&who) { - // Use the same whitelist check as multisig - if crate::configs::HighSecurityConfig::is_whitelisted(call) { - return Ok((ValidTransaction::default(), (), origin)); - } else { - return Err(TransactionValidityError::Invalid(InvalidTransaction::Custom(1))); - } + // Enforce the high-security whitelist on the top-level signer. Origin-rewriting wrappers + // (`as_derivative`/`as_recovered`) re-check the whitelist at the effective origin inside + // their own pallets at dispatch time, so this flat check needs no call traversal. + if !crate::configs::HighSecurityConfig::is_call_allowed(&who, call) { + return Err(TransactionValidityError::Invalid(InvalidTransaction::Custom(1))); } Ok((ValidTransaction::default(), (), origin)) @@ -312,7 +310,10 @@ impl TransactionEx #[cfg(test)] mod tests { use super::*; - use frame_support::{assert_ok, pallet_prelude::TransactionValidityError, traits::Currency}; + use frame_support::{ + assert_err_ignore_postinfo, assert_noop, assert_ok, + pallet_prelude::TransactionValidityError, traits::Currency, + }; use sp_runtime::{traits::TxBaseImplication, AccountId32}; fn alice() -> AccountId { AccountId32::from([1; 32]) @@ -441,32 +442,37 @@ mod tests { }); } - fn check_call(call: RuntimeCall) -> Result<(), TransactionValidityError> { - // Test the reversible transaction extension - let ext = ReversibleTransactionExtension::::new(); + // Run the reversible transaction extension's `validate` for `call` signed by `signer`. + fn validate_with( + signer: AccountId, + call: &RuntimeCall, + ) -> Result<(), TransactionValidityError> { + ReversibleTransactionExtension::::new() + .validate( + RuntimeOrigin::signed(signer), + call, + &Default::default(), + 0, + (), + &TxBaseImplication::<()>(()), + frame_support::pallet_prelude::TransactionSource::External, + ) + .map(|_| ()) + } + fn check_call(call: RuntimeCall) -> Result<(), TransactionValidityError> { // Verify Charlie is high-security assert!(ReversibleTransfers::is_high_security(&charlie()).is_some()); let origin = RuntimeOrigin::signed(charlie()); // Test the prepare method - ext.clone().prepare((), &origin, &call, &Default::default(), 0).unwrap(); - - assert_eq!((), ()); + ReversibleTransactionExtension::::new() + .prepare((), &origin, &call, &Default::default(), 0) + .unwrap(); // Test the validate method - let result = ext.validate( - origin, - &call, - &Default::default(), - 0, - (), - &TxBaseImplication::<()>(()), - frame_support::pallet_prelude::TransactionSource::External, - ); - - result.map(|_| ()) + validate_with(charlie(), &call) } #[test] @@ -558,6 +564,111 @@ mod tests { }); } + // ========================================================================= + // Origin-rewriting wrappers must not bypass high-security restrictions. + // `as_recovered` / `as_derivative` re-check the whitelist at the effective + // (rewritten) origin inside their own pallets, so a non-whitelisted call + // cannot be dispatched as a high-security account, including under `batch`. + // ========================================================================= + + fn boxed(call: RuntimeCall) -> alloc::boxed::Box { + alloc::boxed::Box::new(call) + } + + fn non_whitelisted_transfer() -> RuntimeCall { + RuntimeCall::Balances(pallet_balances::Call::transfer_allow_death { + dest: MultiAddress::Id(bob()), + value: 10 * EXISTENTIAL_DEPOSIT, + }) + } + + fn whitelisted_schedule() -> RuntimeCall { + RuntimeCall::ReversibleTransfers(pallet_reversible_transfers::Call::schedule_transfer { + dest: MultiAddress::Id(bob()), + amount: 10 * EXISTENTIAL_DEPOSIT, + }) + } + + #[test] + fn as_recovered_high_security_call_is_blocked() { + new_test_ext().execute_with(|| { + // bob is charlie's recovery proxy; charlie is high-security (from genesis). + pallet_recovery::Proxy::::insert(bob(), charlie()); + + // A non-whitelisted call dispatched as the high-security account is rejected. + assert_noop!( + Recovery::as_recovered( + RuntimeOrigin::signed(bob()), + MultiAddress::Id(charlie()), + boxed(non_whitelisted_transfer()), + ), + pallet_recovery::Error::::CallNotAllowedForHighSecurity + ); + + // A whitelisted call is allowed through as the high-security account. + assert_ok!(Recovery::as_recovered( + RuntimeOrigin::signed(bob()), + MultiAddress::Id(charlie()), + boxed(whitelisted_schedule()), + )); + }); + } + + #[test] + fn as_derivative_high_security_call_is_blocked() { + new_test_ext().execute_with(|| { + // Make alice's index-0 derivative a high-security account. + let derivative = pallet_utility::derivative_account_id(alice(), 0u16); + Balances::make_free_balance_be(&derivative, EXISTENTIAL_DEPOSIT * 100); + assert_ok!(ReversibleTransfers::set_high_security( + RuntimeOrigin::signed(derivative.clone()), + qp_scheduler::BlockNumberOrTimestamp::BlockNumber(10), + bob(), + )); + + // A non-whitelisted call dispatched as the high-security derivative is rejected. + assert_err_ignore_postinfo!( + Utility::as_derivative( + RuntimeOrigin::signed(alice()), + 0, + boxed(non_whitelisted_transfer()), + ), + pallet_utility::Error::::CallNotAllowedForHighSecurity + ); + + // A whitelisted call as the derivative is allowed. + assert_ok!(Utility::as_derivative( + RuntimeOrigin::signed(alice()), + 0, + boxed(whitelisted_schedule()), + )); + + // A different, non-high-security derivative is unaffected. + assert_ok!(Utility::as_derivative( + RuntimeOrigin::signed(alice()), + 1, + boxed(RuntimeCall::System(frame_system::Call::remark { remark: vec![1] })), + )); + }); + } + + #[test] + fn batch_wrapped_high_security_call_is_blocked() { + new_test_ext().execute_with(|| { + // Wrapping the origin-rewriter in a batch does not bypass the check: `batch_all` + // re-dispatches `as_recovered`, whose own check rejects the non-whitelisted call. + pallet_recovery::Proxy::::insert(bob(), charlie()); + let inner = RuntimeCall::Recovery(pallet_recovery::Call::as_recovered { + account: MultiAddress::Id(charlie()), + call: boxed(non_whitelisted_transfer()), + }); + assert_err_ignore_postinfo!( + Utility::batch_all(RuntimeOrigin::signed(bob()), vec![inner]), + pallet_recovery::Error::::CallNotAllowedForHighSecurity + ); + }); + } + // ========================================================================= // Tests for event-based WormholeProofRecorderExtension // =========================================================================