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