diff --git a/operator/Cargo.lock b/operator/Cargo.lock index 1447cbcb..d402defa 100644 --- a/operator/Cargo.lock +++ b/operator/Cargo.lock @@ -2892,6 +2892,7 @@ dependencies = [ "pallet-proxy", "pallet-randomness", "pallet-referenda", + "pallet-safe-mode", "pallet-scheduler", "pallet-session", "pallet-storage-providers", @@ -2901,6 +2902,7 @@ dependencies = [ "pallet-transaction-payment", "pallet-transaction-payment-rpc-runtime-api", "pallet-treasury", + "pallet-tx-pause", "pallet-utility", "pallet-whitelist", "parity-scale-codec", @@ -3081,7 +3083,9 @@ dependencies = [ "pallet-evm", "pallet-evm-precompile-proxy", "pallet-migrations", + "pallet-safe-mode", "pallet-treasury", + "pallet-tx-pause", "parity-scale-codec", "polkadot-primitives", "polkadot-runtime-common", @@ -3166,6 +3170,7 @@ dependencies = [ "pallet-proxy", "pallet-randomness", "pallet-referenda", + "pallet-safe-mode", "pallet-scheduler", "pallet-session", "pallet-storage-providers", @@ -3175,6 +3180,7 @@ dependencies = [ "pallet-transaction-payment", "pallet-transaction-payment-rpc-runtime-api", "pallet-treasury", + "pallet-tx-pause", "pallet-utility", "pallet-whitelist", "parity-scale-codec", @@ -3306,6 +3312,7 @@ dependencies = [ "pallet-proxy", "pallet-randomness", "pallet-referenda", + "pallet-safe-mode", "pallet-scheduler", "pallet-session", "pallet-storage-providers", @@ -3315,6 +3322,7 @@ dependencies = [ "pallet-transaction-payment", "pallet-transaction-payment-rpc-runtime-api", "pallet-treasury", + "pallet-tx-pause", "pallet-utility", "pallet-whitelist", "parity-scale-codec", @@ -9589,6 +9597,24 @@ dependencies = [ "sp-runtime", ] +[[package]] +name = "pallet-safe-mode" +version = "20.0.0" +source = "git+https://github.com/paritytech/polkadot-sdk.git?tag=polkadot-stable2412-6#bbc435c7667d3283ba280a8fec44676357392753" +dependencies = [ + "docify", + "frame-benchmarking", + "frame-support", + "frame-system", + "pallet-balances", + "pallet-proxy", + "pallet-utility", + "parity-scale-codec", + "scale-info", + "sp-arithmetic", + "sp-runtime", +] + [[package]] name = "pallet-scheduler" version = "40.2.0" @@ -9789,6 +9815,23 @@ dependencies = [ "sp-runtime", ] +[[package]] +name = "pallet-tx-pause" +version = "20.0.0" +source = "git+https://github.com/paritytech/polkadot-sdk.git?tag=polkadot-stable2412-6#bbc435c7667d3283ba280a8fec44676357392753" +dependencies = [ + "docify", + "frame-benchmarking", + "frame-support", + "frame-system", + "pallet-balances", + "pallet-proxy", + "pallet-utility", + "parity-scale-codec", + "scale-info", + "sp-runtime", +] + [[package]] name = "pallet-utility" version = "39.1.0" diff --git a/operator/Cargo.toml b/operator/Cargo.toml index 83412fba..48f0c731 100644 --- a/operator/Cargo.toml +++ b/operator/Cargo.toml @@ -121,6 +121,8 @@ pallet-multisig = { git = "https://github.com/paritytech/polkadot-sdk", tag = "p pallet-offences = { git = "https://github.com/paritytech/polkadot-sdk", tag = "polkadot-stable2412-6", default-features = false } pallet-parameters = { git = "https://github.com/paritytech/polkadot-sdk", tag = "polkadot-stable2412-6", default-features = false } pallet-preimage = { git = "https://github.com/paritytech/polkadot-sdk", tag = "polkadot-stable2412-6", default-features = false } +pallet-safe-mode = { git = "https://github.com/paritytech/polkadot-sdk", tag = "polkadot-stable2412-6", default-features = false } +pallet-tx-pause = { git = "https://github.com/paritytech/polkadot-sdk", tag = "polkadot-stable2412-6", default-features = false } pallet-collator-selection = { git = "https://github.com/paritytech/polkadot-sdk.git", tag = "polkadot-stable2412-6", default-features = false } pallet-collective = { git = "https://github.com/paritytech/polkadot-sdk", tag = "polkadot-stable2412-6", default-features = false } pallet-conviction-voting = { git = "https://github.com/paritytech/polkadot-sdk", tag = "polkadot-stable2412-6", default-features = false } diff --git a/operator/runtime/common/Cargo.toml b/operator/runtime/common/Cargo.toml index 777b04d6..baeceea0 100644 --- a/operator/runtime/common/Cargo.toml +++ b/operator/runtime/common/Cargo.toml @@ -14,6 +14,8 @@ pallet-balances = { workspace = true } pallet-evm = { workspace = true } pallet-evm-precompile-proxy = { workspace = true } pallet-migrations = { workspace = true } +pallet-safe-mode = { workspace = true } +pallet-tx-pause = { workspace = true } pallet-treasury = { workspace = true } polkadot-primitives = { workspace = true } polkadot-runtime-common = { workspace = true } @@ -34,6 +36,8 @@ std = [ "pallet-evm/std", "pallet-evm-precompile-proxy/std", "pallet-migrations/std", + "pallet-safe-mode/std", + "pallet-tx-pause/std", "pallet-treasury/std", "polkadot-primitives/std", "polkadot-runtime-common/std", @@ -48,6 +52,8 @@ std = [ runtime-benchmarks = [ "frame-support/runtime-benchmarks", "pallet-migrations/runtime-benchmarks", + "pallet-safe-mode/runtime-benchmarks", + "pallet-tx-pause/runtime-benchmarks", "polkadot-primitives/runtime-benchmarks", "polkadot-runtime-common/runtime-benchmarks", "sp-runtime/runtime-benchmarks", diff --git a/operator/runtime/common/src/lib.rs b/operator/runtime/common/src/lib.rs index b823e419..92c20e98 100644 --- a/operator/runtime/common/src/lib.rs +++ b/operator/runtime/common/src/lib.rs @@ -24,6 +24,8 @@ pub mod deal_with_fees; pub mod impl_on_charge_evm_transaction; pub mod migrations; pub use migrations::*; +pub mod safe_mode; +pub use safe_mode::*; use fp_account::EthereumSignature; pub use sp_runtime::OpaqueExtrinsic as UncheckedExtrinsic; diff --git a/operator/runtime/common/src/safe_mode.rs b/operator/runtime/common/src/safe_mode.rs new file mode 100644 index 00000000..8c5c88bd --- /dev/null +++ b/operator/runtime/common/src/safe_mode.rs @@ -0,0 +1,73 @@ +// Copyright 2019-2025 DataHaven Inc. +// This file is part of DataHaven. + +// Moonbeam is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// DataHaven is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Moonbeam. If not, see . + +//! Safe Mode and Tx Pause shared types, constants, and utilities + +use crate::time::DAYS; +use crate::Balance; +use frame_support::{parameter_types, traits::Contains}; +use pallet_tx_pause::RuntimeCallNameOf; +use polkadot_primitives::BlockNumber; +use sp_std::marker::PhantomData; + +// Safe Mode Constants +parameter_types! { + /// Default duration for safe mode activation (1 day) + pub const SafeModeDuration: BlockNumber = DAYS; + pub const SafeModeEnterDeposit: Option = None; + /// Safe mode extend deposit - None disables permissionless extend + pub const SafeModeExtendDeposit: Option = None; + /// Release delay - None disables permissionless release + pub const ReleaseDelayNone: Option = None; +} + +/// Calls that cannot be paused by the tx-pause pallet. +pub struct TxPauseWhitelistedCalls(PhantomData); +/// Whitelist `Balances::transfer_keep_alive`, all others are pauseable. +impl Contains> for TxPauseWhitelistedCalls +where + R: pallet_tx_pause::Config, +{ + fn contains(full_name: &RuntimeCallNameOf) -> bool { + match (full_name.0.as_slice(), full_name.1.as_slice()) { + // sudo calls + (b"Sudo", _) => true, + // SafeMode calls + (b"SafeMode", _) => true, + _ => false, + } + } +} + +/// Combined Call Filter that applies Normal, SafeMode, and TxPause filters +/// This filter is generic over the runtime call type and identical across all runtimes +pub struct RuntimeCallFilter( + PhantomData<(Call, NormalFilter, SafeModeFilter, TxPauseFilter)>, +); + +impl Contains + for RuntimeCallFilter +where + NormalFilter: Contains, + SafeModeFilter: Contains, + TxPauseFilter: Contains, +{ + fn contains(call: &Call) -> bool { + NormalFilter::contains(call) + && SafeModeFilter::contains(call) + && TxPauseFilter::contains(call) + } +} diff --git a/operator/runtime/mainnet/Cargo.toml b/operator/runtime/mainnet/Cargo.toml index 8e402059..6c877e14 100644 --- a/operator/runtime/mainnet/Cargo.toml +++ b/operator/runtime/mainnet/Cargo.toml @@ -64,6 +64,8 @@ pallet-outbound-commitment-store = { workspace = true } pallet-datahaven-native-transfer = { workspace = true } pallet-parameters = { workspace = true } pallet-preimage = { workspace = true } +pallet-safe-mode = { workspace = true } +pallet-tx-pause = { workspace = true } pallet-proxy = { workspace = true } pallet-referenda = { workspace = true } pallet-scheduler = { workspace = true } @@ -211,6 +213,8 @@ std = [ "pallet-offences/std", "pallet-parameters/std", "pallet-preimage/std", + "pallet-safe-mode/std", + "pallet-tx-pause/std", "pallet-referenda/std", "pallet-proxy/std", "pallet-scheduler/std", @@ -310,6 +314,8 @@ runtime-benchmarks = [ "pallet-offences/runtime-benchmarks", "pallet-parameters/runtime-benchmarks", "pallet-preimage/runtime-benchmarks", + "pallet-safe-mode/runtime-benchmarks", + "pallet-tx-pause/runtime-benchmarks", "pallet-referenda/runtime-benchmarks", "pallet-proxy/runtime-benchmarks", "pallet-scheduler/runtime-benchmarks", @@ -358,6 +364,8 @@ try-runtime = [ "pallet-offences/try-runtime", "pallet-parameters/try-runtime", "pallet-preimage/try-runtime", + "pallet-safe-mode/try-runtime", + "pallet-tx-pause/try-runtime", "pallet-referenda/try-runtime", "pallet-proxy/try-runtime", "pallet-scheduler/try-runtime", diff --git a/operator/runtime/mainnet/src/benchmarks.rs b/operator/runtime/mainnet/src/benchmarks.rs index c0683a0f..3677711d 100644 --- a/operator/runtime/mainnet/src/benchmarks.rs +++ b/operator/runtime/mainnet/src/benchmarks.rs @@ -53,6 +53,8 @@ frame_benchmarking::define_benchmarks!( [pallet_transaction_payment, TransactionPayment] [pallet_parameters, Parameters] [pallet_message_queue, MessageQueue] + [pallet_safe_mode, SafeMode] + [pallet_tx_pause, TxPause] // EVM pallets [pallet_evm, Evm] diff --git a/operator/runtime/mainnet/src/configs/mod.rs b/operator/runtime/mainnet/src/configs/mod.rs index 11edcc24..8b2f8e63 100644 --- a/operator/runtime/mainnet/src/configs/mod.rs +++ b/operator/runtime/mainnet/src/configs/mod.rs @@ -35,8 +35,8 @@ use super::{ ExternalValidatorsRewards, Hash, Historical, ImOnline, MessageQueue, MultiBlockMigrations, Nonce, Offences, OriginCaller, OutboundCommitmentStore, PalletInfo, Preimage, Referenda, Runtime, RuntimeCall, RuntimeEvent, RuntimeFreezeReason, RuntimeHoldReason, RuntimeOrigin, - RuntimeTask, Scheduler, Session, SessionKeys, Signature, System, Timestamp, Treasury, - BLOCK_HASH_COUNT, EXTRINSIC_BASE_WEIGHT, MAXIMUM_BLOCK_WEIGHT, NORMAL_BLOCK_WEIGHT, + RuntimeTask, SafeMode, Scheduler, Session, SessionKeys, Signature, System, Timestamp, Treasury, + TxPause, BLOCK_HASH_COUNT, EXTRINSIC_BASE_WEIGHT, MAXIMUM_BLOCK_WEIGHT, NORMAL_BLOCK_WEIGHT, NORMAL_DISPATCH_RATIO, SLOT_DURATION, VERSION, }; use codec::{Decode, Encode, MaxEncodedLen}; @@ -91,6 +91,10 @@ use datahaven_runtime_common::{ FailedMigrationHandler as DefaultFailedMigrationHandler, MigrationCursorMaxLen, MigrationIdentifierMaxLen, MigrationStatusHandler, }, + safe_mode::{ + ReleaseDelayNone, RuntimeCallFilter, SafeModeDuration, SafeModeEnterDeposit, + SafeModeExtendDeposit, TxPauseWhitelistedCalls, + }, time::{EpochDurationInBlocks, DAYS, MILLISECS_PER_BLOCK}, }; use dhp_bridge::{EigenLayerMessageProcessor, NativeTokenTransferMessageProcessor}; @@ -233,6 +237,36 @@ impl Contains for NormalCallFilter { } } +/// Calls that can bypass the safe-mode pallet. +/// These calls are essential for emergency governance and system maintenance. +pub struct SafeModeWhitelistedCalls; +impl Contains for SafeModeWhitelistedCalls { + fn contains(call: &RuntimeCall) -> bool { + match call { + // Core system calls + RuntimeCall::System(_) => true, + // Safe mode management + RuntimeCall::SafeMode(_) => true, + // Transaction pause management + RuntimeCall::TxPause(_) => true, + // Emergency admin access (testnet/dev only) + RuntimeCall::Sudo(_) => true, + // Governance infrastructure - critical for emergency responses + RuntimeCall::Whitelist(_) => true, + RuntimeCall::Preimage(_) => true, + RuntimeCall::Scheduler(_) => true, + RuntimeCall::ConvictionVoting(_) => true, + RuntimeCall::Referenda(_) => true, + RuntimeCall::TechnicalCommittee(_) => true, + RuntimeCall::TreasuryCouncil(_) => true, + _ => false, + } + } +} + +pub type MainnetRuntimeCallFilter = + RuntimeCallFilter; + /// The default types are being injected by [`derive_impl`](`frame_support::derive_impl`) from /// [`SoloChainDefaultConfig`](`struct@frame_system::config_preludes::SolochainDefaultConfig`), /// but overridden as needed. @@ -265,8 +299,8 @@ impl frame_system::Config for Runtime { type MaxConsumers = frame_support::traits::ConstU32<16>; type SystemWeightInfo = mainnet_weights::frame_system::WeightInfo; type MultiBlockMigrator = MultiBlockMigrations; - /// Use the NormalCallFilter to restrict certain runtime calls - type BaseCallFilter = NormalCallFilter; + /// Use the combined call filter to apply Normal, SafeMode, and TxPause restrictions + type BaseCallFilter = MainnetRuntimeCallFilter; } // 1 in 4 blocks (on average, not counting collisions) will be primary babe blocks. @@ -1480,6 +1514,38 @@ impl pallet_datahaven_native_transfer::Config for Runtime { type WeightInfo = mainnet_weights::pallet_datahaven_native_transfer::WeightInfo; } +//╔══════════════════════════════════════════════════════════════════════════════════════════════════════════════════╗ +//║ SAFE MODE & TX PAUSE PALLETS ║ +//╚══════════════════════════════════════════════════════════════════════════════════════════════════════════════════╝ + +impl pallet_safe_mode::Config for Runtime { + type RuntimeEvent = RuntimeEvent; + type Currency = Balances; + type RuntimeHoldReason = RuntimeHoldReason; + type WhitelistedCalls = SafeModeWhitelistedCalls; + type EnterDuration = SafeModeDuration; + type ExtendDuration = SafeModeDuration; + type EnterDepositAmount = SafeModeEnterDeposit; + type ExtendDepositAmount = SafeModeExtendDeposit; + type ForceEnterOrigin = EnsureRootWithSuccess; + type ForceExtendOrigin = EnsureRootWithSuccess; + type ForceExitOrigin = EnsureRoot; + type ForceDepositOrigin = EnsureRoot; + type ReleaseDelay = ReleaseDelayNone; + type Notify = (); + type WeightInfo = mainnet_weights::pallet_safe_mode::WeightInfo; +} + +impl pallet_tx_pause::Config for Runtime { + type RuntimeEvent = RuntimeEvent; + type RuntimeCall = RuntimeCall; + type PauseOrigin = EnsureRoot; + type UnpauseOrigin = EnsureRoot; + type WhitelistedCalls = TxPauseWhitelistedCalls; + type MaxNameLen = ConstU32<256>; + type WeightInfo = mainnet_weights::pallet_tx_pause::WeightInfo; +} + #[cfg(test)] mod tests { use super::*; diff --git a/operator/runtime/mainnet/src/lib.rs b/operator/runtime/mainnet/src/lib.rs index 954238e5..726c9ae0 100644 --- a/operator/runtime/mainnet/src/lib.rs +++ b/operator/runtime/mainnet/src/lib.rs @@ -378,6 +378,12 @@ mod runtime { #[runtime::pallet_index(39)] pub type MultiBlockMigrations = pallet_migrations; + + #[runtime::pallet_index(103)] + pub type SafeMode = pallet_safe_mode; + + #[runtime::pallet_index(104)] + pub type TxPause = pallet_tx_pause; // ╚═════════════════ Polkadot SDK Utility Pallets ══════════════════╝ // ╔═════════════════════════ Governance Pallets ════════════════════╗ diff --git a/operator/runtime/mainnet/src/weights/mod.rs b/operator/runtime/mainnet/src/weights/mod.rs index d314d39c..9428a40f 100644 --- a/operator/runtime/mainnet/src/weights/mod.rs +++ b/operator/runtime/mainnet/src/weights/mod.rs @@ -41,11 +41,13 @@ pub mod pallet_multisig; pub mod pallet_parameters; pub mod pallet_preimage; pub mod pallet_proxy; +pub mod pallet_safe_mode; pub mod pallet_scheduler; pub mod pallet_sudo; pub mod pallet_timestamp; pub mod pallet_transaction_payment; pub mod pallet_treasury; +pub mod pallet_tx_pause; pub mod pallet_utility; // Governance pallets diff --git a/operator/runtime/mainnet/src/weights/pallet_safe_mode.rs b/operator/runtime/mainnet/src/weights/pallet_safe_mode.rs new file mode 100644 index 00000000..e7a54199 --- /dev/null +++ b/operator/runtime/mainnet/src/weights/pallet_safe_mode.rs @@ -0,0 +1,7 @@ +// Placeholder weight mapping for `pallet-safe-mode` until we record chain-specific benchmarks. +// +// We reuse the upstream Substrate weight assumptions which are conservative enough for +// bootstrapping. Once DataHaven-specific safe-mode migrations are added we should regenerate +// weights in this module via the runtime benchmarking CLI. + +pub type WeightInfo = pallet_safe_mode::weights::SubstrateWeight; diff --git a/operator/runtime/mainnet/src/weights/pallet_tx_pause.rs b/operator/runtime/mainnet/src/weights/pallet_tx_pause.rs new file mode 100644 index 00000000..28676e27 --- /dev/null +++ b/operator/runtime/mainnet/src/weights/pallet_tx_pause.rs @@ -0,0 +1,7 @@ +// Placeholder weight mapping for `pallet-tx-pause` until we record chain-specific benchmarks. +// +// We reuse the upstream Substrate weight assumptions which are conservative enough for +// bootstrapping. Once DataHaven-specific paused-call logic is added we should regenerate weights in +// this module via the runtime benchmarking CLI. + +pub type WeightInfo = pallet_tx_pause::weights::SubstrateWeight; diff --git a/operator/runtime/mainnet/tests/lib.rs b/operator/runtime/mainnet/tests/lib.rs index c6d5eaef..e94b5930 100644 --- a/operator/runtime/mainnet/tests/lib.rs +++ b/operator/runtime/mainnet/tests/lib.rs @@ -5,6 +5,7 @@ pub mod governance; mod migrations; mod native_token_transfer; mod proxy; +mod safe_mode_tx_pause; use common::*; use datahaven_mainnet_runtime::{ diff --git a/operator/runtime/mainnet/tests/safe_mode_tx_pause.rs b/operator/runtime/mainnet/tests/safe_mode_tx_pause.rs new file mode 100644 index 00000000..d7a56d78 --- /dev/null +++ b/operator/runtime/mainnet/tests/safe_mode_tx_pause.rs @@ -0,0 +1,431 @@ +#![allow(clippy::too_many_arguments)] + +#[path = "common.rs"] +mod common; + +use common::{account_id, ExtBuilder, ALICE, BOB}; +use datahaven_mainnet_runtime::{ + Runtime, RuntimeCall, RuntimeEvent, RuntimeOrigin, System, UncheckedExtrinsic, +}; +use frame_support::{assert_noop, assert_ok, BoundedVec}; +use pallet_safe_mode::EnteredUntil; +use pallet_tx_pause::{Error as TxPauseError, RuntimeCallNameOf}; +use sp_runtime::{ + traits::Dispatchable, + transaction_validity::{InvalidTransaction, TransactionSource, TransactionValidityError}, +}; +use sp_transaction_pool::runtime_api::runtime_decl_for_tagged_transaction_queue::TaggedTransactionQueueV3; + +fn call_name(call: &RuntimeCall) -> RuntimeCallNameOf { + use frame_support::traits::GetCallMetadata; + let metadata = call.get_call_metadata(); + ( + BoundedVec::try_from(metadata.pallet_name.as_bytes().to_vec()).unwrap(), + BoundedVec::try_from(metadata.function_name.as_bytes().to_vec()).unwrap(), + ) +} + +fn transfer_call(amount: u128) -> RuntimeCall { + RuntimeCall::Balances(pallet_balances::Call::transfer_keep_alive { + dest: account_id(BOB), + value: amount, + }) +} + +mod safe_mode { + use super::*; + + #[test] + fn force_enter_requires_root() { + ExtBuilder::default() + .with_sudo(account_id(ALICE)) + .build() + .execute_with(|| { + assert_ok!( + RuntimeCall::SafeMode(pallet_safe_mode::Call::force_enter {}) + .dispatch(RuntimeOrigin::root()) + ); + + assert_noop!( + RuntimeCall::SafeMode(pallet_safe_mode::Call::force_enter {}) + .dispatch(RuntimeOrigin::signed(account_id(ALICE))), + sp_runtime::DispatchError::BadOrigin + ); + + assert!(EnteredUntil::::get().is_some()); + System::assert_last_event(RuntimeEvent::SafeMode(pallet_safe_mode::Event::< + Runtime, + >::Entered { + until: EnteredUntil::::get().unwrap(), + })); + }); + } + + #[test] + fn active_safe_mode_blocks_non_whitelisted_calls() { + ExtBuilder::default() + .with_sudo(account_id(ALICE)) + .build() + .execute_with(|| { + assert_ok!( + RuntimeCall::SafeMode(pallet_safe_mode::Call::force_enter {}) + .dispatch(RuntimeOrigin::root()) + ); + + let xt = transfer_call(1u128); + let unchecked_xt = UncheckedExtrinsic::new_bare(xt.into()); + let validity = Runtime::validate_transaction( + TransactionSource::External, + unchecked_xt, + Default::default(), + ); + assert_eq!( + validity, + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)) + ); + }); + } + + #[test] + fn whitelisted_calls_dispatch_in_safe_mode() { + ExtBuilder::default() + .with_sudo(account_id(ALICE)) + .build() + .execute_with(|| { + assert_ok!( + RuntimeCall::SafeMode(pallet_safe_mode::Call::force_enter {}) + .dispatch(RuntimeOrigin::root()) + ); + + assert_ok!(RuntimeCall::SafeMode(pallet_safe_mode::Call::force_exit {}) + .dispatch(RuntimeOrigin::root())); + + assert!(EnteredUntil::::get().is_none()); + }); + } +} + +mod tx_pause { + use super::*; + + #[test] + fn pause_requires_root() { + ExtBuilder::default().build().execute_with(|| { + let call = transfer_call(1u128); + let call_name = call_name(&call); + + assert_ok!(RuntimeCall::TxPause(pallet_tx_pause::Call::pause { + full_name: call_name.clone(), + }) + .dispatch(RuntimeOrigin::root())); + + assert_noop!( + RuntimeCall::TxPause(pallet_tx_pause::Call::pause { + full_name: call_name.clone(), + }) + .dispatch(RuntimeOrigin::signed(account_id(ALICE))), + sp_runtime::DispatchError::BadOrigin + ); + + assert_ok!( + RuntimeCall::TxPause(pallet_tx_pause::Call::unpause { ident: call_name }) + .dispatch(RuntimeOrigin::root()) + ); + }); + } + + #[test] + fn paused_call_is_blocked() { + ExtBuilder::default().build().execute_with(|| { + let call = transfer_call(1u128); + let call_name = call_name(&call); + + assert_ok!(RuntimeCall::TxPause(pallet_tx_pause::Call::pause { + full_name: call_name.clone(), + }) + .dispatch(RuntimeOrigin::root())); + + let xt = UncheckedExtrinsic::new_bare(call.clone().into()); + assert_eq!( + Runtime::validate_transaction(TransactionSource::External, xt, Default::default()), + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)) + ); + + assert_noop!( + call.clone() + .dispatch(RuntimeOrigin::signed(account_id(ALICE))), + frame_system::Error::::CallFiltered + ); + + assert_ok!( + RuntimeCall::TxPause(pallet_tx_pause::Call::unpause { ident: call_name }) + .dispatch(RuntimeOrigin::root()) + ); + + // After unpause, the call should be dispatchable + assert_ok!(call.dispatch(RuntimeOrigin::signed(account_id(ALICE)))); + }); + } + + #[test] + fn whitelisted_call_cannot_be_paused() { + ExtBuilder::default().build().execute_with(|| { + let call = RuntimeCall::SafeMode(pallet_safe_mode::Call::force_exit {}); + let call_name = call_name(&call); + + assert_noop!( + RuntimeCall::TxPause(pallet_tx_pause::Call::pause { + full_name: call_name, + }) + .dispatch(RuntimeOrigin::root()), + TxPauseError::::Unpausable + ); + }); + } +} + +mod combined_behaviour { + use super::*; + + #[test] + fn dual_restrictions_require_both_to_clear() { + ExtBuilder::default() + .with_sudo(account_id(ALICE)) + .build() + .execute_with(|| { + assert_ok!( + RuntimeCall::SafeMode(pallet_safe_mode::Call::force_enter {}) + .dispatch(RuntimeOrigin::root()) + ); + + let call = transfer_call(1u128); + let call_name = call_name(&call); + + assert_ok!(RuntimeCall::TxPause(pallet_tx_pause::Call::pause { + full_name: call_name.clone(), + }) + .dispatch(RuntimeOrigin::root())); + + let xt = UncheckedExtrinsic::new_bare(call.clone().into()); + let validity = Runtime::validate_transaction( + TransactionSource::External, + xt, + Default::default(), + ); + assert_eq!( + validity, + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)) + ); + + assert_ok!(RuntimeCall::TxPause(pallet_tx_pause::Call::unpause { + ident: call_name.clone() + }) + .dispatch(RuntimeOrigin::root())); + + let xt = UncheckedExtrinsic::new_bare(call.clone().into()); + let still_blocked = Runtime::validate_transaction( + TransactionSource::External, + xt, + Default::default(), + ); + assert_eq!( + still_blocked, + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)) + ); + + assert_ok!(RuntimeCall::SafeMode(pallet_safe_mode::Call::force_exit {}) + .dispatch(RuntimeOrigin::root())); + + // After exiting safe mode and unpausing, call should be dispatchable + assert_ok!(call + .clone() + .dispatch(RuntimeOrigin::signed(account_id(ALICE)))); + + assert_ok!(RuntimeCall::TxPause(pallet_tx_pause::Call::pause { + full_name: call_name, + }) + .dispatch(RuntimeOrigin::root())); + + let xt = UncheckedExtrinsic::new_bare(call.into()); + assert_eq!( + Runtime::validate_transaction( + TransactionSource::External, + xt, + Default::default() + ), + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)) + ); + }); + } + + #[test] + fn control_plane_calls_work_under_restrictions() { + ExtBuilder::default() + .with_sudo(account_id(ALICE)) + .build() + .execute_with(|| { + assert_ok!( + RuntimeCall::SafeMode(pallet_safe_mode::Call::force_enter {}) + .dispatch(RuntimeOrigin::root()) + ); + + let call = transfer_call(1u128); + let call_name = call_name(&call); + + assert_ok!(RuntimeCall::TxPause(pallet_tx_pause::Call::pause { + full_name: call_name.clone(), + }) + .dispatch(RuntimeOrigin::root())); + + assert_ok!(RuntimeCall::TxPause(pallet_tx_pause::Call::unpause { + ident: call_name.clone() + }) + .dispatch(RuntimeOrigin::root())); + + assert_ok!(RuntimeCall::TxPause(pallet_tx_pause::Call::pause { + full_name: call_name, + }) + .dispatch(RuntimeOrigin::root())); + + assert_ok!(RuntimeCall::SafeMode(pallet_safe_mode::Call::force_exit {}) + .dispatch(RuntimeOrigin::root())); + }); + } + + #[test] + fn governance_whitelisted_calls_work_during_safe_mode() { + use sp_core::H256; + + ExtBuilder::default() + .with_sudo(account_id(ALICE)) + .with_balances(vec![(account_id(ALICE), 1_000_000_000_000)]) + .build() + .execute_with(|| { + // Enter safe mode + assert_ok!( + RuntimeCall::SafeMode(pallet_safe_mode::Call::force_enter {}) + .dispatch(RuntimeOrigin::root()) + ); + + // Verify safe mode is active + assert!(EnteredUntil::::get().is_some()); + + // Verify normal calls are blocked during safe mode + let normal_call = transfer_call(100); + assert_noop!( + normal_call.dispatch(RuntimeOrigin::signed(account_id(ALICE))), + frame_system::Error::::CallFiltered + ); + + // Test Whitelist pallet - critical for emergency runtime upgrades + let call_hash = H256::random(); + assert_ok!( + RuntimeCall::Whitelist(pallet_whitelist::Call::whitelist_call { call_hash }) + .dispatch(RuntimeOrigin::root()) + ); + + // Test Preimage pallet - required for storing governance call data + let dummy_preimage = vec![1u8; 32]; + let preimage_result = RuntimeCall::Preimage(pallet_preimage::Call::note_preimage { + bytes: dummy_preimage, + }) + .dispatch(RuntimeOrigin::signed(account_id(ALICE))); + + match preimage_result { + Ok(_) => {} + Err(e) => { + let call_filtered_error: sp_runtime::DispatchError = + frame_system::Error::::CallFiltered.into(); + assert_ne!( + format!("{:?}", e.error), + format!("{:?}", call_filtered_error), + "Preimage calls should not be filtered by safe mode" + ); + } + } + + // Test Scheduler pallet - needed for time-delayed governance actions + let scheduler_result = RuntimeCall::Scheduler(pallet_scheduler::Call::cancel { + when: 100, + index: 0, + }) + .dispatch(RuntimeOrigin::root()); + + match scheduler_result { + Ok(_) => {} + Err(e) => { + let call_filtered_error: sp_runtime::DispatchError = + frame_system::Error::::CallFiltered.into(); + assert_ne!( + format!("{:?}", e.error), + format!("{:?}", call_filtered_error), + "Scheduler calls should not be filtered by safe mode" + ); + } + } + + // Test Referenda pallet - core OpenGov proposal system + let referenda_result = + RuntimeCall::Referenda(pallet_referenda::Call::cancel { index: 0 }) + .dispatch(RuntimeOrigin::root()); + + match referenda_result { + Ok(_) => {} + Err(e) => { + let call_filtered_error: sp_runtime::DispatchError = + frame_system::Error::::CallFiltered.into(); + assert_ne!( + format!("{:?}", e.error), + format!("{:?}", call_filtered_error), + "Referenda calls should not be filtered by safe mode" + ); + } + } + + // Test ConvictionVoting - allows token holders to vote during emergencies + let voting_result = RuntimeCall::ConvictionVoting( + pallet_conviction_voting::Call::remove_other_vote { + target: account_id(BOB), + class: 0, + index: 0, + }, + ) + .dispatch(RuntimeOrigin::signed(account_id(ALICE))); + + match voting_result { + Ok(_) => {} + Err(e) => { + let call_filtered_error: sp_runtime::DispatchError = + frame_system::Error::::CallFiltered.into(); + assert_ne!( + format!("{:?}", e.error), + format!("{:?}", call_filtered_error), + "ConvictionVoting calls should not be filtered by safe mode" + ); + } + } + + // Test TechnicalCommittee - expert oversight for emergency actions + let tech_committee_result = + RuntimeCall::TechnicalCommittee(pallet_collective::Call::set_members { + new_members: vec![account_id(ALICE)], + prime: None, + old_count: 0, + }) + .dispatch(RuntimeOrigin::root()); + + match tech_committee_result { + Ok(_) => {} + Err(e) => { + let call_filtered_error: sp_runtime::DispatchError = + frame_system::Error::::CallFiltered.into(); + assert_ne!( + format!("{:?}", e.error), + format!("{:?}", call_filtered_error), + "TechnicalCommittee calls should not be filtered by safe mode" + ); + } + } + }); + } +} diff --git a/operator/runtime/stagenet/Cargo.toml b/operator/runtime/stagenet/Cargo.toml index 49882ac1..1d7a9ff1 100644 --- a/operator/runtime/stagenet/Cargo.toml +++ b/operator/runtime/stagenet/Cargo.toml @@ -64,6 +64,8 @@ pallet-outbound-commitment-store = { workspace = true } pallet-datahaven-native-transfer = { workspace = true } pallet-parameters = { workspace = true } pallet-preimage = { workspace = true } +pallet-safe-mode = { workspace = true } +pallet-tx-pause = { workspace = true } pallet-proxy = { workspace = true } pallet-referenda = { workspace = true } pallet-scheduler = { workspace = true } @@ -212,6 +214,8 @@ std = [ "pallet-offences/std", "pallet-parameters/std", "pallet-preimage/std", + "pallet-safe-mode/std", + "pallet-tx-pause/std", "pallet-referenda/std", "pallet-proxy/std", "pallet-scheduler/std", @@ -311,6 +315,8 @@ runtime-benchmarks = [ "pallet-offences/runtime-benchmarks", "pallet-parameters/runtime-benchmarks", "pallet-preimage/runtime-benchmarks", + "pallet-safe-mode/runtime-benchmarks", + "pallet-tx-pause/runtime-benchmarks", "pallet-referenda/runtime-benchmarks", "pallet-proxy/runtime-benchmarks", "pallet-scheduler/runtime-benchmarks", @@ -359,6 +365,8 @@ try-runtime = [ "pallet-offences/try-runtime", "pallet-parameters/try-runtime", "pallet-preimage/try-runtime", + "pallet-safe-mode/try-runtime", + "pallet-tx-pause/try-runtime", "pallet-referenda/try-runtime", "pallet-proxy/try-runtime", "pallet-scheduler/try-runtime", diff --git a/operator/runtime/stagenet/src/benchmarks.rs b/operator/runtime/stagenet/src/benchmarks.rs index 7e752f52..22db3d3c 100644 --- a/operator/runtime/stagenet/src/benchmarks.rs +++ b/operator/runtime/stagenet/src/benchmarks.rs @@ -53,6 +53,8 @@ frame_benchmarking::define_benchmarks!( [pallet_transaction_payment, TransactionPayment] [pallet_parameters, Parameters] [pallet_message_queue, MessageQueue] + [pallet_safe_mode, SafeMode] + [pallet_tx_pause, TxPause] // Governance pallets [pallet_collective_technical_committee, TechnicalCommittee] diff --git a/operator/runtime/stagenet/src/configs/mod.rs b/operator/runtime/stagenet/src/configs/mod.rs index 6df2639f..049a20c3 100644 --- a/operator/runtime/stagenet/src/configs/mod.rs +++ b/operator/runtime/stagenet/src/configs/mod.rs @@ -35,8 +35,8 @@ use super::{ ExternalValidatorsRewards, Hash, Historical, ImOnline, MessageQueue, MultiBlockMigrations, Nonce, Offences, OriginCaller, OutboundCommitmentStore, PalletInfo, Preimage, Referenda, Runtime, RuntimeCall, RuntimeEvent, RuntimeFreezeReason, RuntimeHoldReason, RuntimeOrigin, - RuntimeTask, Scheduler, Session, SessionKeys, Signature, System, Timestamp, Treasury, - BLOCK_HASH_COUNT, EXTRINSIC_BASE_WEIGHT, MAXIMUM_BLOCK_WEIGHT, NORMAL_BLOCK_WEIGHT, + RuntimeTask, SafeMode, Scheduler, Session, SessionKeys, Signature, System, Timestamp, Treasury, + TxPause, BLOCK_HASH_COUNT, EXTRINSIC_BASE_WEIGHT, MAXIMUM_BLOCK_WEIGHT, NORMAL_BLOCK_WEIGHT, NORMAL_DISPATCH_RATIO, SLOT_DURATION, VERSION, }; use codec::{Decode, Encode, MaxEncodedLen}; @@ -91,6 +91,10 @@ use datahaven_runtime_common::{ FailedMigrationHandler as DefaultFailedMigrationHandler, MigrationCursorMaxLen, MigrationIdentifierMaxLen, MigrationStatusHandler, }, + safe_mode::{ + ReleaseDelayNone, RuntimeCallFilter, SafeModeDuration, SafeModeEnterDeposit, + SafeModeExtendDeposit, TxPauseWhitelistedCalls, + }, time::{EpochDurationInBlocks, DAYS, MILLISECS_PER_BLOCK}, }; use dhp_bridge::{EigenLayerMessageProcessor, NativeTokenTransferMessageProcessor}; @@ -233,6 +237,36 @@ impl Contains for NormalCallFilter { } } +/// Calls that can bypass the safe-mode pallet. +/// These calls are essential for emergency governance and system maintenance. +pub struct SafeModeWhitelistedCalls; +impl Contains for SafeModeWhitelistedCalls { + fn contains(call: &RuntimeCall) -> bool { + match call { + // Core system calls + RuntimeCall::System(_) => true, + // Safe mode management + RuntimeCall::SafeMode(_) => true, + // Transaction pause management + RuntimeCall::TxPause(_) => true, + // Emergency admin access (testnet/dev only) + RuntimeCall::Sudo(_) => true, + // Governance infrastructure - critical for emergency responses + RuntimeCall::Whitelist(_) => true, + RuntimeCall::Preimage(_) => true, + RuntimeCall::Scheduler(_) => true, + RuntimeCall::ConvictionVoting(_) => true, + RuntimeCall::Referenda(_) => true, + RuntimeCall::TechnicalCommittee(_) => true, + RuntimeCall::TreasuryCouncil(_) => true, + _ => false, + } + } +} + +pub type StagenetRuntimeCallFilter = + RuntimeCallFilter; + /// The default types are being injected by [`derive_impl`](`frame_support::derive_impl`) from /// [`SoloChainDefaultConfig`](`struct@frame_system::config_preludes::SolochainDefaultConfig`), /// but overridden as needed. @@ -265,8 +299,8 @@ impl frame_system::Config for Runtime { type MaxConsumers = frame_support::traits::ConstU32<16>; type SystemWeightInfo = stagenet_weights::frame_system::WeightInfo; type MultiBlockMigrator = MultiBlockMigrations; - /// Use the NormalCallFilter to restrict certain runtime calls - type BaseCallFilter = NormalCallFilter; + /// Use the combined call filter to apply Normal, SafeMode, and TxPause restrictions + type BaseCallFilter = StagenetRuntimeCallFilter; } // 1 in 4 blocks (on average, not counting collisions) will be primary babe blocks. @@ -1481,6 +1515,38 @@ impl pallet_datahaven_native_transfer::Config for Runtime { type WeightInfo = stagenet_weights::pallet_datahaven_native_transfer::WeightInfo; } +//╔══════════════════════════════════════════════════════════════════════════════════════════════════════════════════╗ +//║ SAFE MODE & TX PAUSE PALLETS ║ +//╚══════════════════════════════════════════════════════════════════════════════════════════════════════════════════╝ + +impl pallet_safe_mode::Config for Runtime { + type RuntimeEvent = RuntimeEvent; + type Currency = Balances; + type RuntimeHoldReason = RuntimeHoldReason; + type WhitelistedCalls = SafeModeWhitelistedCalls; + type EnterDuration = SafeModeDuration; + type ExtendDuration = SafeModeDuration; + type EnterDepositAmount = SafeModeEnterDeposit; + type ExtendDepositAmount = SafeModeExtendDeposit; + type ForceEnterOrigin = EnsureRootWithSuccess; + type ForceExtendOrigin = EnsureRootWithSuccess; + type ForceExitOrigin = EnsureRoot; + type ForceDepositOrigin = EnsureRoot; + type ReleaseDelay = ReleaseDelayNone; + type Notify = (); + type WeightInfo = stagenet_weights::pallet_safe_mode::WeightInfo; +} + +impl pallet_tx_pause::Config for Runtime { + type RuntimeEvent = RuntimeEvent; + type RuntimeCall = RuntimeCall; + type PauseOrigin = EnsureRoot; + type UnpauseOrigin = EnsureRoot; + type WhitelistedCalls = TxPauseWhitelistedCalls; + type MaxNameLen = ConstU32<256>; + type WeightInfo = stagenet_weights::pallet_tx_pause::WeightInfo; +} + #[cfg(test)] mod tests { use super::*; diff --git a/operator/runtime/stagenet/src/lib.rs b/operator/runtime/stagenet/src/lib.rs index d536edd9..3407f555 100644 --- a/operator/runtime/stagenet/src/lib.rs +++ b/operator/runtime/stagenet/src/lib.rs @@ -380,6 +380,12 @@ mod runtime { #[runtime::pallet_index(39)] pub type MultiBlockMigrations = pallet_migrations; + + #[runtime::pallet_index(103)] + pub type SafeMode = pallet_safe_mode; + + #[runtime::pallet_index(104)] + pub type TxPause = pallet_tx_pause; // ╚═════════════════ Polkadot SDK Utility Pallets ══════════════════╝ // ╔═════════════════════════ Governance Pallets ════════════════════╗ diff --git a/operator/runtime/stagenet/src/weights/mod.rs b/operator/runtime/stagenet/src/weights/mod.rs index b39064f7..a430d426 100644 --- a/operator/runtime/stagenet/src/weights/mod.rs +++ b/operator/runtime/stagenet/src/weights/mod.rs @@ -43,11 +43,13 @@ pub mod pallet_multisig; pub mod pallet_parameters; pub mod pallet_preimage; pub mod pallet_proxy; +pub mod pallet_safe_mode; pub mod pallet_scheduler; pub mod pallet_sudo; pub mod pallet_timestamp; pub mod pallet_transaction_payment; pub mod pallet_treasury; +pub mod pallet_tx_pause; pub mod pallet_utility; // Governance pallets diff --git a/operator/runtime/stagenet/src/weights/pallet_safe_mode.rs b/operator/runtime/stagenet/src/weights/pallet_safe_mode.rs new file mode 100644 index 00000000..e7a54199 --- /dev/null +++ b/operator/runtime/stagenet/src/weights/pallet_safe_mode.rs @@ -0,0 +1,7 @@ +// Placeholder weight mapping for `pallet-safe-mode` until we record chain-specific benchmarks. +// +// We reuse the upstream Substrate weight assumptions which are conservative enough for +// bootstrapping. Once DataHaven-specific safe-mode migrations are added we should regenerate +// weights in this module via the runtime benchmarking CLI. + +pub type WeightInfo = pallet_safe_mode::weights::SubstrateWeight; diff --git a/operator/runtime/stagenet/src/weights/pallet_tx_pause.rs b/operator/runtime/stagenet/src/weights/pallet_tx_pause.rs new file mode 100644 index 00000000..28676e27 --- /dev/null +++ b/operator/runtime/stagenet/src/weights/pallet_tx_pause.rs @@ -0,0 +1,7 @@ +// Placeholder weight mapping for `pallet-tx-pause` until we record chain-specific benchmarks. +// +// We reuse the upstream Substrate weight assumptions which are conservative enough for +// bootstrapping. Once DataHaven-specific paused-call logic is added we should regenerate weights in +// this module via the runtime benchmarking CLI. + +pub type WeightInfo = pallet_tx_pause::weights::SubstrateWeight; diff --git a/operator/runtime/stagenet/tests/lib.rs b/operator/runtime/stagenet/tests/lib.rs index 4213f411..3766015d 100644 --- a/operator/runtime/stagenet/tests/lib.rs +++ b/operator/runtime/stagenet/tests/lib.rs @@ -4,6 +4,7 @@ pub mod common; pub mod governance; mod native_token_transfer; mod proxy; +mod safe_mode_tx_pause; use common::*; use datahaven_stagenet_runtime::{ diff --git a/operator/runtime/stagenet/tests/safe_mode_tx_pause.rs b/operator/runtime/stagenet/tests/safe_mode_tx_pause.rs new file mode 100644 index 00000000..9f55a04b --- /dev/null +++ b/operator/runtime/stagenet/tests/safe_mode_tx_pause.rs @@ -0,0 +1,432 @@ +#![allow(clippy::too_many_arguments)] + +#[path = "common.rs"] +mod common; + +use common::{account_id, ExtBuilder, ALICE, BOB}; +use datahaven_stagenet_runtime::{ + Runtime, RuntimeCall, RuntimeEvent, RuntimeOrigin, SafeMode, System, TxPause, + UncheckedExtrinsic, +}; +use frame_support::{assert_noop, assert_ok, BoundedVec}; +use pallet_safe_mode::EnteredUntil; +use pallet_tx_pause::{Error as TxPauseError, RuntimeCallNameOf}; +use sp_runtime::{ + traits::Dispatchable, + transaction_validity::{InvalidTransaction, TransactionSource, TransactionValidityError}, +}; +use sp_transaction_pool::runtime_api::runtime_decl_for_tagged_transaction_queue::TaggedTransactionQueueV3; + +fn call_name(call: &RuntimeCall) -> RuntimeCallNameOf { + use frame_support::traits::GetCallMetadata; + let metadata = call.get_call_metadata(); + ( + BoundedVec::try_from(metadata.pallet_name.as_bytes().to_vec()).unwrap(), + BoundedVec::try_from(metadata.function_name.as_bytes().to_vec()).unwrap(), + ) +} + +fn transfer_call(amount: u128) -> RuntimeCall { + RuntimeCall::Balances(pallet_balances::Call::transfer_keep_alive { + dest: account_id(BOB), + value: amount, + }) +} + +mod safe_mode { + use super::*; + + #[test] + fn force_enter_requires_root() { + ExtBuilder::default() + .with_sudo(account_id(ALICE)) + .build() + .execute_with(|| { + assert_ok!( + RuntimeCall::SafeMode(pallet_safe_mode::Call::force_enter {}) + .dispatch(RuntimeOrigin::root()) + ); + + assert_noop!( + RuntimeCall::SafeMode(pallet_safe_mode::Call::force_enter {}) + .dispatch(RuntimeOrigin::signed(account_id(ALICE))), + sp_runtime::DispatchError::BadOrigin + ); + + assert!(EnteredUntil::::get().is_some()); + System::assert_last_event(RuntimeEvent::SafeMode(pallet_safe_mode::Event::< + Runtime, + >::Entered { + until: EnteredUntil::::get().unwrap(), + })); + }); + } + + #[test] + fn active_safe_mode_blocks_non_whitelisted_calls() { + ExtBuilder::default() + .with_sudo(account_id(ALICE)) + .build() + .execute_with(|| { + assert_ok!( + RuntimeCall::SafeMode(pallet_safe_mode::Call::force_enter {}) + .dispatch(RuntimeOrigin::root()) + ); + + let xt = transfer_call(1u128); + let unchecked_xt = UncheckedExtrinsic::new_bare(xt.into()); + let validity = Runtime::validate_transaction( + TransactionSource::External, + unchecked_xt, + Default::default(), + ); + assert_eq!( + validity, + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)) + ); + }); + } + + #[test] + fn whitelisted_calls_dispatch_in_safe_mode() { + ExtBuilder::default() + .with_sudo(account_id(ALICE)) + .build() + .execute_with(|| { + assert_ok!( + RuntimeCall::SafeMode(pallet_safe_mode::Call::force_enter {}) + .dispatch(RuntimeOrigin::root()) + ); + + assert_ok!(RuntimeCall::SafeMode(pallet_safe_mode::Call::force_exit {}) + .dispatch(RuntimeOrigin::root())); + + assert!(EnteredUntil::::get().is_none()); + }); + } +} + +mod tx_pause { + use super::*; + + #[test] + fn pause_requires_root() { + ExtBuilder::default().build().execute_with(|| { + let call = transfer_call(1u128); + let call_name = call_name(&call); + + assert_ok!(RuntimeCall::TxPause(pallet_tx_pause::Call::pause { + full_name: call_name.clone(), + }) + .dispatch(RuntimeOrigin::root())); + + assert_noop!( + RuntimeCall::TxPause(pallet_tx_pause::Call::pause { + full_name: call_name.clone(), + }) + .dispatch(RuntimeOrigin::signed(account_id(ALICE))), + sp_runtime::DispatchError::BadOrigin + ); + + assert_ok!( + RuntimeCall::TxPause(pallet_tx_pause::Call::unpause { ident: call_name }) + .dispatch(RuntimeOrigin::root()) + ); + }); + } + + #[test] + fn paused_call_is_blocked() { + ExtBuilder::default().build().execute_with(|| { + let call = transfer_call(1u128); + let call_name = call_name(&call); + + assert_ok!(RuntimeCall::TxPause(pallet_tx_pause::Call::pause { + full_name: call_name.clone(), + }) + .dispatch(RuntimeOrigin::root())); + + let xt = UncheckedExtrinsic::new_bare(call.clone().into()); + assert_eq!( + Runtime::validate_transaction(TransactionSource::External, xt, Default::default()), + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)) + ); + + assert_noop!( + call.clone() + .dispatch(RuntimeOrigin::signed(account_id(ALICE))), + frame_system::Error::::CallFiltered + ); + + assert_ok!( + RuntimeCall::TxPause(pallet_tx_pause::Call::unpause { ident: call_name }) + .dispatch(RuntimeOrigin::root()) + ); + + // After unpause, the call should be dispatchable + assert_ok!(call.dispatch(RuntimeOrigin::signed(account_id(ALICE)))); + }); + } + + #[test] + fn whitelisted_call_cannot_be_paused() { + ExtBuilder::default().build().execute_with(|| { + let call = RuntimeCall::SafeMode(pallet_safe_mode::Call::force_exit {}); + let call_name = call_name(&call); + + assert_noop!( + RuntimeCall::TxPause(pallet_tx_pause::Call::pause { + full_name: call_name, + }) + .dispatch(RuntimeOrigin::root()), + TxPauseError::::Unpausable + ); + }); + } +} + +mod combined_behaviour { + use super::*; + + #[test] + fn dual_restrictions_require_both_to_clear() { + ExtBuilder::default() + .with_sudo(account_id(ALICE)) + .build() + .execute_with(|| { + assert_ok!( + RuntimeCall::SafeMode(pallet_safe_mode::Call::force_enter {}) + .dispatch(RuntimeOrigin::root()) + ); + + let call = transfer_call(1u128); + let call_name = call_name(&call); + + assert_ok!(RuntimeCall::TxPause(pallet_tx_pause::Call::pause { + full_name: call_name.clone(), + }) + .dispatch(RuntimeOrigin::root())); + + let xt = UncheckedExtrinsic::new_bare(call.clone().into()); + let validity = Runtime::validate_transaction( + TransactionSource::External, + xt, + Default::default(), + ); + assert_eq!( + validity, + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)) + ); + + assert_ok!(RuntimeCall::TxPause(pallet_tx_pause::Call::unpause { + ident: call_name.clone() + }) + .dispatch(RuntimeOrigin::root())); + + let xt = UncheckedExtrinsic::new_bare(call.clone().into()); + let still_blocked = Runtime::validate_transaction( + TransactionSource::External, + xt, + Default::default(), + ); + assert_eq!( + still_blocked, + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)) + ); + + assert_ok!(RuntimeCall::SafeMode(pallet_safe_mode::Call::force_exit {}) + .dispatch(RuntimeOrigin::root())); + + // After exiting safe mode and unpausing, call should be dispatchable + assert_ok!(call + .clone() + .dispatch(RuntimeOrigin::signed(account_id(ALICE)))); + + assert_ok!(RuntimeCall::TxPause(pallet_tx_pause::Call::pause { + full_name: call_name, + }) + .dispatch(RuntimeOrigin::root())); + + let xt = UncheckedExtrinsic::new_bare(call.into()); + assert_eq!( + Runtime::validate_transaction( + TransactionSource::External, + xt, + Default::default() + ), + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)) + ); + }); + } + + #[test] + fn control_plane_calls_work_under_restrictions() { + ExtBuilder::default() + .with_sudo(account_id(ALICE)) + .build() + .execute_with(|| { + assert_ok!( + RuntimeCall::SafeMode(pallet_safe_mode::Call::force_enter {}) + .dispatch(RuntimeOrigin::root()) + ); + + let call = transfer_call(1u128); + let call_name = call_name(&call); + + assert_ok!(RuntimeCall::TxPause(pallet_tx_pause::Call::pause { + full_name: call_name.clone(), + }) + .dispatch(RuntimeOrigin::root())); + + assert_ok!(RuntimeCall::TxPause(pallet_tx_pause::Call::unpause { + ident: call_name.clone() + }) + .dispatch(RuntimeOrigin::root())); + + assert_ok!(RuntimeCall::TxPause(pallet_tx_pause::Call::pause { + full_name: call_name, + }) + .dispatch(RuntimeOrigin::root())); + + assert_ok!(RuntimeCall::SafeMode(pallet_safe_mode::Call::force_exit {}) + .dispatch(RuntimeOrigin::root())); + }); + } + + #[test] + fn governance_whitelisted_calls_work_during_safe_mode() { + use sp_core::H256; + + ExtBuilder::default() + .with_sudo(account_id(ALICE)) + .with_balances(vec![(account_id(ALICE), 1_000_000_000_000)]) + .build() + .execute_with(|| { + // Enter safe mode + assert_ok!( + RuntimeCall::SafeMode(pallet_safe_mode::Call::force_enter {}) + .dispatch(RuntimeOrigin::root()) + ); + + // Verify safe mode is active + assert!(EnteredUntil::::get().is_some()); + + // Verify normal calls are blocked during safe mode + let normal_call = transfer_call(100); + assert_noop!( + normal_call.dispatch(RuntimeOrigin::signed(account_id(ALICE))), + frame_system::Error::::CallFiltered + ); + + // Test Whitelist pallet - critical for emergency runtime upgrades + let call_hash = H256::random(); + assert_ok!( + RuntimeCall::Whitelist(pallet_whitelist::Call::whitelist_call { call_hash }) + .dispatch(RuntimeOrigin::root()) + ); + + // Test Preimage pallet - required for storing governance call data + let dummy_preimage = vec![1u8; 32]; + let preimage_result = RuntimeCall::Preimage(pallet_preimage::Call::note_preimage { + bytes: dummy_preimage, + }) + .dispatch(RuntimeOrigin::signed(account_id(ALICE))); + + match preimage_result { + Ok(_) => {} + Err(e) => { + let call_filtered_error: sp_runtime::DispatchError = + frame_system::Error::::CallFiltered.into(); + assert_ne!( + format!("{:?}", e.error), + format!("{:?}", call_filtered_error), + "Preimage calls should not be filtered by safe mode" + ); + } + } + + // Test Scheduler pallet - needed for time-delayed governance actions + let scheduler_result = RuntimeCall::Scheduler(pallet_scheduler::Call::cancel { + when: 100, + index: 0, + }) + .dispatch(RuntimeOrigin::root()); + + match scheduler_result { + Ok(_) => {} + Err(e) => { + let call_filtered_error: sp_runtime::DispatchError = + frame_system::Error::::CallFiltered.into(); + assert_ne!( + format!("{:?}", e.error), + format!("{:?}", call_filtered_error), + "Scheduler calls should not be filtered by safe mode" + ); + } + } + + // Test Referenda pallet - core OpenGov proposal system + let referenda_result = + RuntimeCall::Referenda(pallet_referenda::Call::cancel { index: 0 }) + .dispatch(RuntimeOrigin::root()); + + match referenda_result { + Ok(_) => {} + Err(e) => { + let call_filtered_error: sp_runtime::DispatchError = + frame_system::Error::::CallFiltered.into(); + assert_ne!( + format!("{:?}", e.error), + format!("{:?}", call_filtered_error), + "Referenda calls should not be filtered by safe mode" + ); + } + } + + // Test ConvictionVoting - allows token holders to vote during emergencies + let voting_result = RuntimeCall::ConvictionVoting( + pallet_conviction_voting::Call::remove_other_vote { + target: account_id(BOB), + class: 0, + index: 0, + }, + ) + .dispatch(RuntimeOrigin::signed(account_id(ALICE))); + + match voting_result { + Ok(_) => {} + Err(e) => { + let call_filtered_error: sp_runtime::DispatchError = + frame_system::Error::::CallFiltered.into(); + assert_ne!( + format!("{:?}", e.error), + format!("{:?}", call_filtered_error), + "ConvictionVoting calls should not be filtered by safe mode" + ); + } + } + + // Test TechnicalCommittee - expert oversight for emergency actions + let tech_committee_result = + RuntimeCall::TechnicalCommittee(pallet_collective::Call::set_members { + new_members: vec![account_id(ALICE)], + prime: None, + old_count: 0, + }) + .dispatch(RuntimeOrigin::root()); + + match tech_committee_result { + Ok(_) => {} + Err(e) => { + let call_filtered_error: sp_runtime::DispatchError = + frame_system::Error::::CallFiltered.into(); + assert_ne!( + format!("{:?}", e.error), + format!("{:?}", call_filtered_error), + "TechnicalCommittee calls should not be filtered by safe mode" + ); + } + } + }); + } +} diff --git a/operator/runtime/testnet/Cargo.toml b/operator/runtime/testnet/Cargo.toml index 98f22639..3f30753e 100644 --- a/operator/runtime/testnet/Cargo.toml +++ b/operator/runtime/testnet/Cargo.toml @@ -64,6 +64,8 @@ pallet-offences = { workspace = true } pallet-outbound-commitment-store = { workspace = true } pallet-parameters = { workspace = true } pallet-preimage = { workspace = true } +pallet-safe-mode = { workspace = true } +pallet-tx-pause = { workspace = true } pallet-proxy = { workspace = true } pallet-referenda = { workspace = true } pallet-scheduler = { workspace = true } @@ -209,6 +211,8 @@ std = [ "pallet-offences/std", "pallet-parameters/std", "pallet-preimage/std", + "pallet-safe-mode/std", + "pallet-tx-pause/std", "pallet-referenda/std", "pallet-proxy/std", "pallet-scheduler/std", @@ -310,6 +314,8 @@ runtime-benchmarks = [ "pallet-offences/runtime-benchmarks", "pallet-parameters/runtime-benchmarks", "pallet-preimage/runtime-benchmarks", + "pallet-safe-mode/runtime-benchmarks", + "pallet-tx-pause/runtime-benchmarks", "pallet-referenda/runtime-benchmarks", "pallet-proxy/runtime-benchmarks", "pallet-scheduler/runtime-benchmarks", @@ -358,6 +364,8 @@ try-runtime = [ "pallet-offences/try-runtime", "pallet-parameters/try-runtime", "pallet-preimage/try-runtime", + "pallet-safe-mode/try-runtime", + "pallet-tx-pause/try-runtime", "pallet-referenda/try-runtime", "pallet-proxy/try-runtime", "pallet-scheduler/try-runtime", diff --git a/operator/runtime/testnet/src/benchmarks.rs b/operator/runtime/testnet/src/benchmarks.rs index 830165b4..9f5147b4 100644 --- a/operator/runtime/testnet/src/benchmarks.rs +++ b/operator/runtime/testnet/src/benchmarks.rs @@ -52,6 +52,8 @@ frame_benchmarking::define_benchmarks!( [pallet_transaction_payment, TransactionPayment] [pallet_parameters, Parameters] [pallet_message_queue, MessageQueue] + [pallet_safe_mode, SafeMode] + [pallet_tx_pause, TxPause] // EVM pallets [pallet_evm, Evm] diff --git a/operator/runtime/testnet/src/configs/mod.rs b/operator/runtime/testnet/src/configs/mod.rs index d3163efa..e0757667 100644 --- a/operator/runtime/testnet/src/configs/mod.rs +++ b/operator/runtime/testnet/src/configs/mod.rs @@ -35,8 +35,8 @@ use super::{ ExternalValidatorsRewards, Hash, Historical, ImOnline, MessageQueue, MultiBlockMigrations, Nonce, Offences, OriginCaller, OutboundCommitmentStore, PalletInfo, Preimage, Referenda, Runtime, RuntimeCall, RuntimeEvent, RuntimeFreezeReason, RuntimeHoldReason, RuntimeOrigin, - RuntimeTask, Scheduler, Session, SessionKeys, Signature, System, Timestamp, Treasury, - BLOCK_HASH_COUNT, EXTRINSIC_BASE_WEIGHT, MAXIMUM_BLOCK_WEIGHT, NORMAL_BLOCK_WEIGHT, + RuntimeTask, SafeMode, Scheduler, Session, SessionKeys, Signature, System, Timestamp, Treasury, + TxPause, BLOCK_HASH_COUNT, EXTRINSIC_BASE_WEIGHT, MAXIMUM_BLOCK_WEIGHT, NORMAL_BLOCK_WEIGHT, NORMAL_DISPATCH_RATIO, SLOT_DURATION, VERSION, }; use codec::{Decode, Encode, MaxEncodedLen}; @@ -91,6 +91,10 @@ use datahaven_runtime_common::{ FailedMigrationHandler as DefaultFailedMigrationHandler, MigrationCursorMaxLen, MigrationIdentifierMaxLen, MigrationStatusHandler, }, + safe_mode::{ + ReleaseDelayNone, RuntimeCallFilter, SafeModeDuration, SafeModeEnterDeposit, + SafeModeExtendDeposit, TxPauseWhitelistedCalls, + }, time::{EpochDurationInBlocks, DAYS, MILLISECS_PER_BLOCK}, }; use dhp_bridge::{EigenLayerMessageProcessor, NativeTokenTransferMessageProcessor}; @@ -233,6 +237,36 @@ impl Contains for NormalCallFilter { } } +/// Calls that can bypass the safe-mode pallet. +/// These calls are essential for emergency governance and system maintenance. +pub struct SafeModeWhitelistedCalls; +impl Contains for SafeModeWhitelistedCalls { + fn contains(call: &RuntimeCall) -> bool { + match call { + // Core system calls + RuntimeCall::System(_) => true, + // Safe mode management + RuntimeCall::SafeMode(_) => true, + // Transaction pause management + RuntimeCall::TxPause(_) => true, + // Emergency admin access (testnet/dev only) + RuntimeCall::Sudo(_) => true, + // Governance infrastructure - critical for emergency responses + RuntimeCall::Whitelist(_) => true, + RuntimeCall::Preimage(_) => true, + RuntimeCall::Scheduler(_) => true, + RuntimeCall::ConvictionVoting(_) => true, + RuntimeCall::Referenda(_) => true, + RuntimeCall::TechnicalCommittee(_) => true, + RuntimeCall::TreasuryCouncil(_) => true, + _ => false, + } + } +} + +pub type TestnetRuntimeCallFilter = + RuntimeCallFilter; + /// The default types are being injected by [`derive_impl`](`frame_support::derive_impl`) from /// [`SoloChainDefaultConfig`](`struct@frame_system::config_preludes::SolochainDefaultConfig`), /// but overridden as needed. @@ -265,8 +299,8 @@ impl frame_system::Config for Runtime { type MaxConsumers = frame_support::traits::ConstU32<16>; type SystemWeightInfo = testnet_weights::frame_system::WeightInfo; type MultiBlockMigrator = MultiBlockMigrations; - /// Use the NormalCallFilter to restrict certain runtime calls - type BaseCallFilter = NormalCallFilter; + /// Use the combined call filter to apply Normal, SafeMode, and TxPause restrictions + type BaseCallFilter = TestnetRuntimeCallFilter; } // 1 in 4 blocks (on average, not counting collisions) will be primary babe blocks. @@ -1479,6 +1513,38 @@ impl pallet_datahaven_native_transfer::Config for Runtime { type WeightInfo = testnet_weights::pallet_datahaven_native_transfer::WeightInfo; } +//╔══════════════════════════════════════════════════════════════════════════════════════════════════════════════════╗ +//║ SAFE MODE & TX PAUSE PALLETS ║ +//╚══════════════════════════════════════════════════════════════════════════════════════════════════════════════════╝ + +impl pallet_safe_mode::Config for Runtime { + type RuntimeEvent = RuntimeEvent; + type Currency = Balances; + type RuntimeHoldReason = RuntimeHoldReason; + type WhitelistedCalls = SafeModeWhitelistedCalls; + type EnterDuration = SafeModeDuration; + type ExtendDuration = SafeModeDuration; + type EnterDepositAmount = SafeModeEnterDeposit; + type ExtendDepositAmount = SafeModeExtendDeposit; + type ForceEnterOrigin = EnsureRootWithSuccess; + type ForceExtendOrigin = EnsureRootWithSuccess; + type ForceExitOrigin = EnsureRoot; + type ForceDepositOrigin = EnsureRoot; + type ReleaseDelay = ReleaseDelayNone; + type Notify = (); + type WeightInfo = testnet_weights::pallet_safe_mode::WeightInfo; +} + +impl pallet_tx_pause::Config for Runtime { + type RuntimeEvent = RuntimeEvent; + type RuntimeCall = RuntimeCall; + type PauseOrigin = EnsureRoot; + type UnpauseOrigin = EnsureRoot; + type WhitelistedCalls = TxPauseWhitelistedCalls; + type MaxNameLen = ConstU32<256>; + type WeightInfo = testnet_weights::pallet_tx_pause::WeightInfo; +} + #[cfg(test)] mod tests { use super::*; diff --git a/operator/runtime/testnet/src/lib.rs b/operator/runtime/testnet/src/lib.rs index 1e217379..87ea4db4 100644 --- a/operator/runtime/testnet/src/lib.rs +++ b/operator/runtime/testnet/src/lib.rs @@ -377,6 +377,12 @@ mod runtime { #[runtime::pallet_index(39)] pub type MultiBlockMigrations = pallet_migrations; + + #[runtime::pallet_index(103)] + pub type SafeMode = pallet_safe_mode; + + #[runtime::pallet_index(104)] + pub type TxPause = pallet_tx_pause; // ╚═════════════════ Polkadot SDK Utility Pallets ══════════════════╝ // ╔═════════════════════════ Governance Pallets ════════════════════╗ diff --git a/operator/runtime/testnet/src/weights/mod.rs b/operator/runtime/testnet/src/weights/mod.rs index e335ea69..93b00589 100644 --- a/operator/runtime/testnet/src/weights/mod.rs +++ b/operator/runtime/testnet/src/weights/mod.rs @@ -43,11 +43,13 @@ pub mod pallet_multisig; pub mod pallet_parameters; pub mod pallet_preimage; pub mod pallet_proxy; +pub mod pallet_safe_mode; pub mod pallet_scheduler; pub mod pallet_sudo; pub mod pallet_timestamp; pub mod pallet_transaction_payment; pub mod pallet_treasury; +pub mod pallet_tx_pause; pub mod pallet_utility; // Governance pallets diff --git a/operator/runtime/testnet/src/weights/pallet_safe_mode.rs b/operator/runtime/testnet/src/weights/pallet_safe_mode.rs new file mode 100644 index 00000000..e7a54199 --- /dev/null +++ b/operator/runtime/testnet/src/weights/pallet_safe_mode.rs @@ -0,0 +1,7 @@ +// Placeholder weight mapping for `pallet-safe-mode` until we record chain-specific benchmarks. +// +// We reuse the upstream Substrate weight assumptions which are conservative enough for +// bootstrapping. Once DataHaven-specific safe-mode migrations are added we should regenerate +// weights in this module via the runtime benchmarking CLI. + +pub type WeightInfo = pallet_safe_mode::weights::SubstrateWeight; diff --git a/operator/runtime/testnet/src/weights/pallet_tx_pause.rs b/operator/runtime/testnet/src/weights/pallet_tx_pause.rs new file mode 100644 index 00000000..28676e27 --- /dev/null +++ b/operator/runtime/testnet/src/weights/pallet_tx_pause.rs @@ -0,0 +1,7 @@ +// Placeholder weight mapping for `pallet-tx-pause` until we record chain-specific benchmarks. +// +// We reuse the upstream Substrate weight assumptions which are conservative enough for +// bootstrapping. Once DataHaven-specific paused-call logic is added we should regenerate weights in +// this module via the runtime benchmarking CLI. + +pub type WeightInfo = pallet_tx_pause::weights::SubstrateWeight; diff --git a/operator/runtime/testnet/tests/lib.rs b/operator/runtime/testnet/tests/lib.rs index dd8d1ed3..63cf45ef 100644 --- a/operator/runtime/testnet/tests/lib.rs +++ b/operator/runtime/testnet/tests/lib.rs @@ -4,6 +4,7 @@ pub mod common; pub mod governance; mod native_token_transfer; mod proxy; +mod safe_mode_tx_pause; use common::*; use datahaven_testnet_runtime::{ diff --git a/operator/runtime/testnet/tests/safe_mode_tx_pause.rs b/operator/runtime/testnet/tests/safe_mode_tx_pause.rs new file mode 100644 index 00000000..7c086ab9 --- /dev/null +++ b/operator/runtime/testnet/tests/safe_mode_tx_pause.rs @@ -0,0 +1,535 @@ +#![allow(clippy::too_many_arguments)] + +#[path = "common.rs"] +mod common; + +use common::{account_id, ExtBuilder, ALICE, BOB}; +use datahaven_testnet_runtime::{ + Runtime, RuntimeCall, RuntimeEvent, RuntimeOrigin, SafeMode, System, TxPause, + UncheckedExtrinsic, +}; +use frame_support::{assert_noop, assert_ok, BoundedVec}; +use pallet_safe_mode::EnteredUntil; +use pallet_tx_pause::{Error as TxPauseError, RuntimeCallNameOf}; +use sp_runtime::{ + traits::Dispatchable, + transaction_validity::{InvalidTransaction, TransactionSource, TransactionValidityError}, +}; +use sp_transaction_pool::runtime_api::runtime_decl_for_tagged_transaction_queue::TaggedTransactionQueueV3; + +fn call_name(call: &RuntimeCall) -> RuntimeCallNameOf { + use frame_support::traits::GetCallMetadata; + let metadata = call.get_call_metadata(); + ( + BoundedVec::try_from(metadata.pallet_name.as_bytes().to_vec()).unwrap(), + BoundedVec::try_from(metadata.function_name.as_bytes().to_vec()).unwrap(), + ) +} + +fn transfer_call(amount: u128) -> RuntimeCall { + RuntimeCall::Balances(pallet_balances::Call::transfer_keep_alive { + dest: account_id(BOB), + value: amount, + }) +} + +mod safe_mode { + use super::*; + + #[test] + fn force_enter_requires_root() { + ExtBuilder::default() + .with_sudo(account_id(ALICE)) + .build() + .execute_with(|| { + assert_ok!( + RuntimeCall::SafeMode(pallet_safe_mode::Call::force_enter {}) + .dispatch(RuntimeOrigin::root()) + ); + + assert_noop!( + RuntimeCall::SafeMode(pallet_safe_mode::Call::force_enter {}) + .dispatch(RuntimeOrigin::signed(account_id(ALICE))), + sp_runtime::DispatchError::BadOrigin + ); + + assert!(EnteredUntil::::get().is_some()); + System::assert_last_event(RuntimeEvent::SafeMode(pallet_safe_mode::Event::< + Runtime, + >::Entered { + until: EnteredUntil::::get().unwrap(), + })); + }); + } + + #[test] + fn active_safe_mode_blocks_non_whitelisted_calls() { + ExtBuilder::default() + .with_sudo(account_id(ALICE)) + .build() + .execute_with(|| { + assert_ok!( + RuntimeCall::SafeMode(pallet_safe_mode::Call::force_enter {}) + .dispatch(RuntimeOrigin::root()) + ); + + let xt = transfer_call(1u128); + let unchecked_xt = UncheckedExtrinsic::new_bare(xt.into()); + let validity = Runtime::validate_transaction( + TransactionSource::External, + unchecked_xt, + Default::default(), + ); + assert_eq!( + validity, + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)) + ); + }); + } + + #[test] + fn whitelisted_calls_dispatch_in_safe_mode() { + ExtBuilder::default() + .with_sudo(account_id(ALICE)) + .build() + .execute_with(|| { + assert_ok!( + RuntimeCall::SafeMode(pallet_safe_mode::Call::force_enter {}) + .dispatch(RuntimeOrigin::root()) + ); + + assert_ok!(RuntimeCall::SafeMode(pallet_safe_mode::Call::force_exit {}) + .dispatch(RuntimeOrigin::root())); + + assert!(EnteredUntil::::get().is_none()); + }); + } + + #[test] + fn exit_restores_normal_flow() { + ExtBuilder::default() + .with_sudo(account_id(ALICE)) + .with_balances(vec![(account_id(ALICE), 1_000_000)]) + .build() + .execute_with(|| { + // Enter safe mode + assert_ok!( + RuntimeCall::SafeMode(pallet_safe_mode::Call::force_enter {}) + .dispatch(RuntimeOrigin::root()) + ); + + let call = transfer_call(100); + + // Verify call is blocked in safe mode + let xt = UncheckedExtrinsic::new_bare(call.clone().into()); + assert_eq!( + Runtime::validate_transaction( + TransactionSource::External, + xt, + Default::default() + ), + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)) + ); + + // Exit safe mode + assert_ok!(RuntimeCall::SafeMode(pallet_safe_mode::Call::force_exit {}) + .dispatch(RuntimeOrigin::root())); + + // Verify call now works + assert_ok!(call.dispatch(RuntimeOrigin::signed(account_id(ALICE)))); + }); + } + + #[test] + fn sudo_bypass() { + ExtBuilder::default() + .with_sudo(account_id(ALICE)) + .with_balances(vec![(account_id(ALICE), 1_000_000)]) + .build() + .execute_with(|| { + // Enter safe mode + assert_ok!( + RuntimeCall::SafeMode(pallet_safe_mode::Call::force_enter {}) + .dispatch(RuntimeOrigin::root()) + ); + + let transfer = transfer_call(100); + + // Wrap in sudo call + let sudo_call = RuntimeCall::Sudo(pallet_sudo::Call::sudo { + call: Box::new(transfer), + }); + + // Sudo should bypass safe mode filter + assert_ok!(sudo_call.dispatch(RuntimeOrigin::signed(account_id(ALICE)))); + }); + } +} + +mod tx_pause { + use super::*; + + #[test] + fn pause_requires_root() { + ExtBuilder::default().build().execute_with(|| { + let call = transfer_call(1u128); + let call_name = call_name(&call); + + assert_ok!(RuntimeCall::TxPause(pallet_tx_pause::Call::pause { + full_name: call_name.clone(), + }) + .dispatch(RuntimeOrigin::root())); + + assert_noop!( + RuntimeCall::TxPause(pallet_tx_pause::Call::pause { + full_name: call_name.clone(), + }) + .dispatch(RuntimeOrigin::signed(account_id(ALICE))), + sp_runtime::DispatchError::BadOrigin + ); + + assert_ok!( + RuntimeCall::TxPause(pallet_tx_pause::Call::unpause { ident: call_name }) + .dispatch(RuntimeOrigin::root()) + ); + }); + } + + #[test] + fn paused_call_is_blocked() { + ExtBuilder::default().build().execute_with(|| { + let call = transfer_call(1u128); + let call_name = call_name(&call); + + assert_ok!(RuntimeCall::TxPause(pallet_tx_pause::Call::pause { + full_name: call_name.clone(), + }) + .dispatch(RuntimeOrigin::root())); + + let xt = UncheckedExtrinsic::new_bare(call.clone().into()); + assert_eq!( + Runtime::validate_transaction(TransactionSource::External, xt, Default::default()), + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)) + ); + + assert_noop!( + call.clone() + .dispatch(RuntimeOrigin::signed(account_id(ALICE))), + frame_system::Error::::CallFiltered + ); + + assert_ok!( + RuntimeCall::TxPause(pallet_tx_pause::Call::unpause { ident: call_name }) + .dispatch(RuntimeOrigin::root()) + ); + + // After unpause, the call should be dispatchable + assert_ok!(call.dispatch(RuntimeOrigin::signed(account_id(ALICE)))); + }); + } + + #[test] + fn whitelisted_call_cannot_be_paused() { + ExtBuilder::default().build().execute_with(|| { + let call = RuntimeCall::SafeMode(pallet_safe_mode::Call::force_exit {}); + let call_name = call_name(&call); + + assert_noop!( + RuntimeCall::TxPause(pallet_tx_pause::Call::pause { + full_name: call_name, + }) + .dispatch(RuntimeOrigin::root()), + TxPauseError::::Unpausable + ); + }); + } +} + +mod combined_behaviour { + use super::*; + + #[test] + fn dual_restrictions_require_both_to_clear() { + ExtBuilder::default() + .with_sudo(account_id(ALICE)) + .build() + .execute_with(|| { + assert_ok!( + RuntimeCall::SafeMode(pallet_safe_mode::Call::force_enter {}) + .dispatch(RuntimeOrigin::root()) + ); + + let call = transfer_call(1u128); + let call_name = call_name(&call); + + assert_ok!(RuntimeCall::TxPause(pallet_tx_pause::Call::pause { + full_name: call_name.clone(), + }) + .dispatch(RuntimeOrigin::root())); + + let xt = UncheckedExtrinsic::new_bare(call.clone().into()); + let validity = Runtime::validate_transaction( + TransactionSource::External, + xt, + Default::default(), + ); + assert_eq!( + validity, + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)) + ); + + assert_ok!(RuntimeCall::TxPause(pallet_tx_pause::Call::unpause { + ident: call_name.clone() + }) + .dispatch(RuntimeOrigin::root())); + + let xt = UncheckedExtrinsic::new_bare(call.clone().into()); + let still_blocked = Runtime::validate_transaction( + TransactionSource::External, + xt, + Default::default(), + ); + assert_eq!( + still_blocked, + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)) + ); + + assert_ok!(RuntimeCall::SafeMode(pallet_safe_mode::Call::force_exit {}) + .dispatch(RuntimeOrigin::root())); + + // After exiting safe mode and unpausing, call should be dispatchable + assert_ok!(call + .clone() + .dispatch(RuntimeOrigin::signed(account_id(ALICE)))); + + assert_ok!(RuntimeCall::TxPause(pallet_tx_pause::Call::pause { + full_name: call_name, + }) + .dispatch(RuntimeOrigin::root())); + + let xt = UncheckedExtrinsic::new_bare(call.into()); + assert_eq!( + Runtime::validate_transaction( + TransactionSource::External, + xt, + Default::default() + ), + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)) + ); + }); + } + + #[test] + fn control_plane_calls_work_under_restrictions() { + ExtBuilder::default() + .with_sudo(account_id(ALICE)) + .build() + .execute_with(|| { + assert_ok!( + RuntimeCall::SafeMode(pallet_safe_mode::Call::force_enter {}) + .dispatch(RuntimeOrigin::root()) + ); + + let call = transfer_call(1u128); + let call_name = call_name(&call); + + assert_ok!(RuntimeCall::TxPause(pallet_tx_pause::Call::pause { + full_name: call_name.clone(), + }) + .dispatch(RuntimeOrigin::root())); + + assert_ok!(RuntimeCall::TxPause(pallet_tx_pause::Call::unpause { + ident: call_name.clone() + }) + .dispatch(RuntimeOrigin::root())); + + assert_ok!(RuntimeCall::TxPause(pallet_tx_pause::Call::pause { + full_name: call_name, + }) + .dispatch(RuntimeOrigin::root())); + + assert_ok!(RuntimeCall::SafeMode(pallet_safe_mode::Call::force_exit {}) + .dispatch(RuntimeOrigin::root())); + }); + } + + #[test] + fn governance_whitelisted_calls_work_during_safe_mode() { + use sp_core::H256; + + ExtBuilder::default() + .with_sudo(account_id(ALICE)) + .with_balances(vec![(account_id(ALICE), 1_000_000_000_000)]) + .build() + .execute_with(|| { + // Enter safe mode + assert_ok!( + RuntimeCall::SafeMode(pallet_safe_mode::Call::force_enter {}) + .dispatch(RuntimeOrigin::root()) + ); + + // Verify safe mode is active + assert!(EnteredUntil::::get().is_some()); + + // Verify normal calls are blocked during safe mode + let normal_call = transfer_call(100); + assert_noop!( + normal_call.dispatch(RuntimeOrigin::signed(account_id(ALICE))), + frame_system::Error::::CallFiltered + ); + + // Test Whitelist pallet - critical for emergency runtime upgrades + let call_hash = H256::random(); + assert_ok!( + RuntimeCall::Whitelist(pallet_whitelist::Call::whitelist_call { call_hash }) + .dispatch(RuntimeOrigin::root()) + ); + + // Test Preimage pallet - required for storing governance call data + let dummy_preimage = vec![1u8; 32]; + let preimage_result = RuntimeCall::Preimage(pallet_preimage::Call::note_preimage { + bytes: dummy_preimage, + }) + .dispatch(RuntimeOrigin::signed(account_id(ALICE))); + + match preimage_result { + Ok(_) => {} + Err(e) => { + let call_filtered_error: sp_runtime::DispatchError = + frame_system::Error::::CallFiltered.into(); + assert_ne!( + format!("{:?}", e.error), + format!("{:?}", call_filtered_error), + "Preimage calls should not be filtered by safe mode" + ); + } + } + + // Test Scheduler pallet - needed for time-delayed governance actions + let scheduler_result = RuntimeCall::Scheduler(pallet_scheduler::Call::cancel { + when: 100, + index: 0, + }) + .dispatch(RuntimeOrigin::root()); + + match scheduler_result { + Ok(_) => {} + Err(e) => { + let call_filtered_error: sp_runtime::DispatchError = + frame_system::Error::::CallFiltered.into(); + assert_ne!( + format!("{:?}", e.error), + format!("{:?}", call_filtered_error), + "Scheduler calls should not be filtered by safe mode" + ); + } + } + + // Test Referenda pallet - core OpenGov proposal system + let referenda_result = + RuntimeCall::Referenda(pallet_referenda::Call::cancel { index: 0 }) + .dispatch(RuntimeOrigin::root()); + + match referenda_result { + Ok(_) => {} + Err(e) => { + let call_filtered_error: sp_runtime::DispatchError = + frame_system::Error::::CallFiltered.into(); + assert_ne!( + format!("{:?}", e.error), + format!("{:?}", call_filtered_error), + "Referenda calls should not be filtered by safe mode" + ); + } + } + + // Test ConvictionVoting - allows token holders to vote during emergencies + let voting_result = RuntimeCall::ConvictionVoting( + pallet_conviction_voting::Call::remove_other_vote { + target: account_id(BOB), + class: 0, + index: 0, + }, + ) + .dispatch(RuntimeOrigin::signed(account_id(ALICE))); + + match voting_result { + Ok(_) => {} + Err(e) => { + let call_filtered_error: sp_runtime::DispatchError = + frame_system::Error::::CallFiltered.into(); + assert_ne!( + format!("{:?}", e.error), + format!("{:?}", call_filtered_error), + "ConvictionVoting calls should not be filtered by safe mode" + ); + } + } + + // Test TechnicalCommittee - expert oversight for emergency actions + let tech_committee_result = + RuntimeCall::TechnicalCommittee(pallet_collective::Call::set_members { + new_members: vec![account_id(ALICE)], + prime: None, + old_count: 0, + }) + .dispatch(RuntimeOrigin::root()); + + match tech_committee_result { + Ok(_) => {} + Err(e) => { + let call_filtered_error: sp_runtime::DispatchError = + frame_system::Error::::CallFiltered.into(); + assert_ne!( + format!("{:?}", e.error), + format!("{:?}", call_filtered_error), + "TechnicalCommittee calls should not be filtered by safe mode" + ); + } + } + }); + } + + #[test] + fn error_surface_consistency() { + ExtBuilder::default() + .with_sudo(account_id(ALICE)) + .with_balances(vec![(account_id(ALICE), 1_000_000)]) + .build() + .execute_with(|| { + // Activate both restrictions + assert_ok!( + RuntimeCall::SafeMode(pallet_safe_mode::Call::force_enter {}) + .dispatch(RuntimeOrigin::root()) + ); + + let call = transfer_call(100); + let call_name = call_name(&call); + + assert_ok!(RuntimeCall::TxPause(pallet_tx_pause::Call::pause { + full_name: call_name, + }) + .dispatch(RuntimeOrigin::root())); + + // Validate the blocked call - should return consistent error + let xt = UncheckedExtrinsic::new_bare(call.clone().into()); + let validation_result = Runtime::validate_transaction( + TransactionSource::External, + xt, + Default::default(), + ); + + // Should return InvalidTransaction::Call + assert_eq!( + validation_result, + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)) + ); + + // Dispatch should also fail with consistent error + assert_noop!( + call.dispatch(RuntimeOrigin::signed(account_id(ALICE))), + frame_system::Error::::CallFiltered + ); + }); + } +} diff --git a/test/.papi/descriptors/package.json b/test/.papi/descriptors/package.json index 021754cd..e5757e41 100644 --- a/test/.papi/descriptors/package.json +++ b/test/.papi/descriptors/package.json @@ -1,5 +1,5 @@ { - "version": "0.1.0-autogenerated.10311212470204876148", + "version": "0.1.0-autogenerated.11494808361211823293", "name": "@polkadot-api/descriptors", "files": [ "dist" diff --git a/test/.papi/metadata/datahaven.scale b/test/.papi/metadata/datahaven.scale index c935d7e4..98e2427e 100644 Binary files a/test/.papi/metadata/datahaven.scale and b/test/.papi/metadata/datahaven.scale differ