From ac09a4f2bb87ed05976ce9703a52529f704c8c66 Mon Sep 17 00:00:00 2001 From: Ahmad Kaouk <56095276+ahmadkaouk@users.noreply.github.com> Date: Mon, 6 Oct 2025 19:00:10 +0200 Subject: [PATCH] feat: Add SafeMode and TxPause Pallets (#192) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Overview This PR integrates the `pallet-safe-mode` and `pallet-tx-pause` from Polkadot SDK to provide comprehensive emergency governance controls across all DataHaven runtime networks (mainnet, stagenet, testnet). ### Key Changes #### πŸ”§ **Core Integration** - **Dependencies**: Added `pallet-safe-mode` and `pallet-tx-pause` from `polkadot-stable2412-6` - **Runtime Integration**: Integrated both pallets across all three runtime networks with pallet indices 103 and 104 - **Call Filtering**: Implemented unified `RuntimeCallFilter` that combines Normal, SafeMode, and TxPause restrictions #### πŸ›‘οΈ **SafeMode Pallet Configuration** - **Duration**: 1 day activation period (`DAYS` constant) - **Deposits**: Disabled permissionless entry/extension (all `None`) - **Origins**: Root-only for all force operations (`force_enter`, `force_exit`, `force_extend`, etc.) - **Whitelisting**: SafeMode and Sudo calls are immune to restrictions #### ⏸️ **TxPause Pallet Configuration** - **Origins**: Root-only pause/unpause control - **Whitelisting**: SafeMode and Sudo calls cannot be paused - **Max Call Name Length**: 256 characters #### πŸ—οΈ **Architecture** - **Shared Types**: Created `operator/runtime/common/src/safe_mode.rs` with reusable configurations - **Combined Filtering**: `RuntimeCallFilter` applies all three filter layers (Normal + SafeMode + TxPause) - **Consistent Config**: Identical configuration across mainnet, stagenet, and testnet #### πŸ“Š **Infrastructure Updates** - **Benchmarking**: Added both pallets to benchmark suites across all networks - **Weight Mappings**: Placeholder weights using Substrate defaults (ready for chain-specific benchmarking) - **Metadata**: Updated runtime metadata for new pallet exposure #### πŸ§ͺ **Testing Framework** - **Coverage**: Tests for individual pallet behavior, combined restrictions, whitelisting, and edge cases ### Emergency Control Capabilities **SafeMode Pallet** (8 calls): - User calls: `enter`, `extend`, `release_deposit` - Force calls: `force_enter`, `force_exit`, `force_extend`, `force_slash_deposit`, `force_release_deposit` **TxPause Pallet** (2 calls): - `pause_call` / `unpause_call` - Granular transaction type pausing --------- Co-authored-by: Steve Degosserie <723552+stiiifff@users.noreply.github.com> --- operator/Cargo.lock | 43 ++ operator/Cargo.toml | 2 + operator/runtime/common/Cargo.toml | 6 + operator/runtime/common/src/lib.rs | 2 + operator/runtime/common/src/safe_mode.rs | 73 +++ operator/runtime/mainnet/Cargo.toml | 8 + operator/runtime/mainnet/src/benchmarks.rs | 2 + operator/runtime/mainnet/src/configs/mod.rs | 74 ++- operator/runtime/mainnet/src/lib.rs | 6 + operator/runtime/mainnet/src/weights/mod.rs | 2 + .../mainnet/src/weights/pallet_safe_mode.rs | 7 + .../mainnet/src/weights/pallet_tx_pause.rs | 7 + operator/runtime/mainnet/tests/lib.rs | 1 + .../mainnet/tests/safe_mode_tx_pause.rs | 431 ++++++++++++++ operator/runtime/stagenet/Cargo.toml | 8 + operator/runtime/stagenet/src/benchmarks.rs | 2 + operator/runtime/stagenet/src/configs/mod.rs | 74 ++- operator/runtime/stagenet/src/lib.rs | 6 + operator/runtime/stagenet/src/weights/mod.rs | 2 + .../stagenet/src/weights/pallet_safe_mode.rs | 7 + .../stagenet/src/weights/pallet_tx_pause.rs | 7 + operator/runtime/stagenet/tests/lib.rs | 1 + .../stagenet/tests/safe_mode_tx_pause.rs | 432 ++++++++++++++ operator/runtime/testnet/Cargo.toml | 8 + operator/runtime/testnet/src/benchmarks.rs | 2 + operator/runtime/testnet/src/configs/mod.rs | 74 ++- operator/runtime/testnet/src/lib.rs | 6 + operator/runtime/testnet/src/weights/mod.rs | 2 + .../testnet/src/weights/pallet_safe_mode.rs | 7 + .../testnet/src/weights/pallet_tx_pause.rs | 7 + operator/runtime/testnet/tests/lib.rs | 1 + .../testnet/tests/safe_mode_tx_pause.rs | 535 ++++++++++++++++++ test/.papi/descriptors/package.json | 2 +- test/.papi/metadata/datahaven.scale | Bin 608513 -> 616961 bytes 34 files changed, 1834 insertions(+), 13 deletions(-) create mode 100644 operator/runtime/common/src/safe_mode.rs create mode 100644 operator/runtime/mainnet/src/weights/pallet_safe_mode.rs create mode 100644 operator/runtime/mainnet/src/weights/pallet_tx_pause.rs create mode 100644 operator/runtime/mainnet/tests/safe_mode_tx_pause.rs create mode 100644 operator/runtime/stagenet/src/weights/pallet_safe_mode.rs create mode 100644 operator/runtime/stagenet/src/weights/pallet_tx_pause.rs create mode 100644 operator/runtime/stagenet/tests/safe_mode_tx_pause.rs create mode 100644 operator/runtime/testnet/src/weights/pallet_safe_mode.rs create mode 100644 operator/runtime/testnet/src/weights/pallet_tx_pause.rs create mode 100644 operator/runtime/testnet/tests/safe_mode_tx_pause.rs 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 c935d7e428413a105d061cfe29958e6d1f8cd842..98e2427e1967d74c2178defc0223411a566fd751 100644 GIT binary patch delta 20287 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