// This file is part of Substrate. // Copyright (C) Parity Technologies (UK) Ltd. // SPDX-License-Identifier: Apache-2.0 // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //! # Safe Mode //! //! Trigger for stopping all extrinsics outside of a specific whitelist. //! //! ## Pallet API //! //! See the [`pallet`] module for more information about the interfaces this pallet exposes, //! including its configuration trait, dispatchables, storage items, events, and errors. //! //! ## Overview //! //! Safe mode is entered via two paths (deposit or forced) until a set block number. //! The mode is exited when the block number is reached or a call to one of the exit extrinsics is //! made. A `WhitelistedCalls` configuration item contains all calls that can be executed while in //! safe mode. //! //! ### Primary Features //! //! - Entering safe mode can be via privileged origin or anyone who places a deposit. //! - Origin configuration items are separated for privileged entering and exiting safe mode. //! - A configurable duration sets the number of blocks after which the system will exit safe mode. //! - Safe mode may be extended beyond the configured exit by additional calls. //! //! ### Example //! //! Configuration of call filters: //! //! ```ignore //! impl frame_system::Config for Runtime { //! // … //! type BaseCallFilter = InsideBoth; //! // … //! } //! ``` //! //! Entering safe mode with deposit: #![doc = docify::embed!("src/tests.rs", can_activate)] //! //! Entering safe mode via privileged origin: #![doc = docify::embed!("src/tests.rs", can_force_activate_with_config_origin)] //! //! Exiting safe mode via privileged origin: #![doc = docify::embed!("src/tests.rs", can_force_deactivate_with_config_origin)] //! //! ## Low Level / Implementation Details //! //! ### Use Cost //! //! A storage value (`EnteredUntil`) is used to store the block safe mode will be exited on. //! Using the call filter will require a db read of that storage on the first extrinsic. //! The storage will be added to the overlay and incur low cost for all additional calls. #![cfg_attr(not(feature = "std"), no_std)] #![deny(rustdoc::broken_intra_doc_links)] mod benchmarking; pub mod mock; mod tests; pub mod weights; use frame_support::{ defensive_assert, pallet_prelude::*, traits::{ fungible::{ self, hold::{Inspect, Mutate}, }, tokens::{Fortitude, Precision}, CallMetadata, Contains, Defensive, GetCallMetadata, PalletInfoAccess, SafeModeNotify, }, weights::Weight, DefaultNoBound, }; use frame_system::pallet_prelude::*; use sp_arithmetic::traits::Zero; use sp_runtime::traits::Saturating; pub use pallet::*; pub use weights::*; type BalanceOf = <::Currency as fungible::Inspect<::AccountId>>::Balance; #[frame_support::pallet] pub mod pallet { use super::*; #[pallet::pallet] pub struct Pallet(PhantomData); #[pallet::config] pub trait Config: frame_system::Config { /// The overarching event type. type RuntimeEvent: From> + IsType<::RuntimeEvent>; /// Currency type for this pallet, used for Deposits. type Currency: Inspect + Mutate; /// The hold reason when reserving funds for entering or extending the safe-mode. type RuntimeHoldReason: From; /// Contains all runtime calls in any pallet that can be dispatched even while the safe-mode /// is entered. /// /// The safe-mode pallet cannot disable it's own calls, and does not need to be explicitly /// added here. type WhitelistedCalls: Contains; /// For how many blocks the safe-mode will be entered by [`Pallet::enter`]. #[pallet::constant] type EnterDuration: Get>; /// For how many blocks the safe-mode can be extended by each [`Pallet::extend`] call. /// /// This does not impose a hard limit as the safe-mode can be extended multiple times. #[pallet::constant] type ExtendDuration: Get>; /// The amount that will be reserved upon calling [`Pallet::enter`]. /// /// `None` disallows permissionlessly enabling the safe-mode and is a sane default. #[pallet::constant] type EnterDepositAmount: Get>>; /// The amount that will be reserved upon calling [`Pallet::extend`]. /// /// `None` disallows permissionlessly extending the safe-mode and is a sane default. #[pallet::constant] type ExtendDepositAmount: Get>>; /// The origin that may call [`Pallet::force_enter`]. /// /// The `Success` value is the number of blocks that this origin can enter safe-mode for. type ForceEnterOrigin: EnsureOrigin>; /// The origin that may call [`Pallet::force_extend`]. /// /// The `Success` value is the number of blocks that this origin can extend the safe-mode. type ForceExtendOrigin: EnsureOrigin>; /// The origin that may call [`Pallet::force_enter`]. type ForceExitOrigin: EnsureOrigin; /// The only origin that can force to release or slash a deposit. type ForceDepositOrigin: EnsureOrigin; /// Notifies external logic when the safe-mode is being entered or exited. type Notify: SafeModeNotify; /// The minimal duration a deposit will remain reserved after safe-mode is entered or /// extended, unless [`Pallet::force_release_deposit`] is successfully called sooner. /// /// Every deposit is tied to a specific activation or extension, thus each deposit can be /// released independently after the delay for it has passed. /// /// `None` disallows permissionlessly releasing the safe-mode deposits and is a sane /// default. #[pallet::constant] type ReleaseDelay: Get>>; // Weight information for extrinsics in this pallet. type WeightInfo: WeightInfo; } #[pallet::error] pub enum Error { /// The safe-mode is (already or still) entered. Entered, /// The safe-mode is (already or still) exited. Exited, /// This functionality of the pallet is disabled by the configuration. NotConfigured, /// There is no balance reserved. NoDeposit, /// The account already has a deposit reserved and can therefore not enter or extend again. AlreadyDeposited, /// This deposit cannot be released yet. CannotReleaseYet, /// An error from the underlying `Currency`. CurrencyError, } #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum Event { /// The safe-mode was entered until inclusively this block. Entered { until: BlockNumberFor }, /// The safe-mode was extended until inclusively this block. Extended { until: BlockNumberFor }, /// Exited the safe-mode for a specific reason. Exited { reason: ExitReason }, /// An account reserved funds for either entering or extending the safe-mode. DepositPlaced { account: T::AccountId, amount: BalanceOf }, /// An account had a reserve released that was reserved. DepositReleased { account: T::AccountId, amount: BalanceOf }, /// An account had reserve slashed that was reserved. DepositSlashed { account: T::AccountId, amount: BalanceOf }, /// Could not hold funds for entering or extending the safe-mode. /// /// This error comes from the underlying `Currency`. CannotDeposit, /// Could not release funds for entering or extending the safe-mode. /// /// This error comes from the underlying `Currency`. CannotRelease, } /// The reason why the safe-mode was deactivated. #[derive(Copy, Clone, PartialEq, Eq, RuntimeDebug, Encode, Decode, TypeInfo, MaxEncodedLen)] pub enum ExitReason { /// The safe-mode was automatically deactivated after it's duration ran out. Timeout, /// The safe-mode was forcefully deactivated by [`Pallet::force_exit`]. Force, } /// Contains the last block number that the safe-mode will remain entered in. /// /// Set to `None` when safe-mode is exited. /// /// Safe-mode is automatically exited when the current block number exceeds this value. #[pallet::storage] pub type EnteredUntil = StorageValue<_, BlockNumberFor, OptionQuery>; /// Holds the reserve that was taken from an account at a specific block number. /// /// This helps governance to have an overview of outstanding deposits that should be returned or /// slashed. #[pallet::storage] pub type Deposits = StorageDoubleMap< _, Twox64Concat, T::AccountId, Twox64Concat, BlockNumberFor, BalanceOf, OptionQuery, >; /// Configure the initial state of this pallet in the genesis block. #[pallet::genesis_config] #[derive(DefaultNoBound)] pub struct GenesisConfig { pub entered_until: Option>, } #[pallet::genesis_build] impl BuildGenesisConfig for GenesisConfig { fn build(&self) { if let Some(block) = self.entered_until { EnteredUntil::::put(block); } } } /// A reason for the pallet placing a hold on funds. #[pallet::composite_enum] pub enum HoldReason { /// Funds are held for entering or extending the safe-mode. #[codec(index = 0)] EnterOrExtend, } #[pallet::call] impl Pallet { /// Enter safe-mode permissionlessly for [`Config::EnterDuration`] blocks. /// /// Reserves [`Config::EnterDepositAmount`] from the caller's account. /// Emits an [`Event::Entered`] event on success. /// Errors with [`Error::Entered`] if the safe-mode is already entered. /// Errors with [`Error::NotConfigured`] if the deposit amount is `None`. #[pallet::call_index(0)] #[pallet::weight(T::WeightInfo::enter())] pub fn enter(origin: OriginFor) -> DispatchResult { let who = ensure_signed(origin)?; Self::do_enter(Some(who), T::EnterDuration::get()).map_err(Into::into) } /// Enter safe-mode by force for a per-origin configured number of blocks. /// /// Emits an [`Event::Entered`] event on success. /// Errors with [`Error::Entered`] if the safe-mode is already entered. /// /// Can only be called by the [`Config::ForceEnterOrigin`] origin. #[pallet::call_index(1)] #[pallet::weight(T::WeightInfo::force_enter())] pub fn force_enter(origin: OriginFor) -> DispatchResult { let duration = T::ForceEnterOrigin::ensure_origin(origin)?; Self::do_enter(None, duration).map_err(Into::into) } /// Extend the safe-mode permissionlessly for [`Config::ExtendDuration`] blocks. /// /// This accumulates on top of the current remaining duration. /// Reserves [`Config::ExtendDepositAmount`] from the caller's account. /// Emits an [`Event::Extended`] event on success. /// Errors with [`Error::Exited`] if the safe-mode is entered. /// Errors with [`Error::NotConfigured`] if the deposit amount is `None`. /// /// This may be called by any signed origin with [`Config::ExtendDepositAmount`] free /// currency to reserve. This call can be disabled for all origins by configuring /// [`Config::ExtendDepositAmount`] to `None`. #[pallet::call_index(2)] #[pallet::weight(T::WeightInfo::extend())] pub fn extend(origin: OriginFor) -> DispatchResult { let who = ensure_signed(origin)?; Self::do_extend(Some(who), T::ExtendDuration::get()).map_err(Into::into) } /// Extend the safe-mode by force for a per-origin configured number of blocks. /// /// Emits an [`Event::Extended`] event on success. /// Errors with [`Error::Exited`] if the safe-mode is inactive. /// /// Can only be called by the [`Config::ForceExtendOrigin`] origin. #[pallet::call_index(3)] #[pallet::weight(T::WeightInfo::force_extend())] pub fn force_extend(origin: OriginFor) -> DispatchResult { let duration = T::ForceExtendOrigin::ensure_origin(origin)?; Self::do_extend(None, duration).map_err(Into::into) } /// Exit safe-mode by force. /// /// Emits an [`Event::Exited`] with [`ExitReason::Force`] event on success. /// Errors with [`Error::Exited`] if the safe-mode is inactive. /// /// Note: `safe-mode` will be automatically deactivated by [`Pallet::on_initialize`] hook /// after the block height is greater than the [`EnteredUntil`] storage item. /// Emits an [`Event::Exited`] with [`ExitReason::Timeout`] event when deactivated in the /// hook. #[pallet::call_index(4)] #[pallet::weight(T::WeightInfo::force_exit())] pub fn force_exit(origin: OriginFor) -> DispatchResult { T::ForceExitOrigin::ensure_origin(origin)?; Self::do_exit(ExitReason::Force).map_err(Into::into) } /// Slash a deposit for an account that entered or extended safe-mode at a given /// historical block. /// /// This can only be called while safe-mode is entered. /// /// Emits a [`Event::DepositSlashed`] event on success. /// Errors with [`Error::Entered`] if safe-mode is entered. /// /// Can only be called by the [`Config::ForceDepositOrigin`] origin. #[pallet::call_index(5)] #[pallet::weight(T::WeightInfo::force_slash_deposit())] pub fn force_slash_deposit( origin: OriginFor, account: T::AccountId, block: BlockNumberFor, ) -> DispatchResult { T::ForceDepositOrigin::ensure_origin(origin)?; Self::do_force_deposit(account, block).map_err(Into::into) } /// Permissionlessly release a deposit for an account that entered safe-mode at a /// given historical block. /// /// The call can be completely disabled by setting [`Config::ReleaseDelay`] to `None`. /// This cannot be called while safe-mode is entered and not until /// [`Config::ReleaseDelay`] blocks have passed since safe-mode was entered. /// /// Emits a [`Event::DepositReleased`] event on success. /// Errors with [`Error::Entered`] if the safe-mode is entered. /// Errors with [`Error::CannotReleaseYet`] if [`Config::ReleaseDelay`] block have not /// passed since safe-mode was entered. Errors with [`Error::NoDeposit`] if the payee has no /// reserved currency at the block specified. #[pallet::call_index(6)] #[pallet::weight(T::WeightInfo::release_deposit())] pub fn release_deposit( origin: OriginFor, account: T::AccountId, block: BlockNumberFor, ) -> DispatchResult { ensure_signed(origin)?; Self::do_release(false, account, block).map_err(Into::into) } /// Force to release a deposit for an account that entered safe-mode at a given /// historical block. /// /// This can be called while safe-mode is still entered. /// /// Emits a [`Event::DepositReleased`] event on success. /// Errors with [`Error::Entered`] if safe-mode is entered. /// Errors with [`Error::NoDeposit`] if the payee has no reserved currency at the /// specified block. /// /// Can only be called by the [`Config::ForceDepositOrigin`] origin. #[pallet::call_index(7)] #[pallet::weight(T::WeightInfo::force_release_deposit())] pub fn force_release_deposit( origin: OriginFor, account: T::AccountId, block: BlockNumberFor, ) -> DispatchResult { T::ForceDepositOrigin::ensure_origin(origin)?; Self::do_release(true, account, block).map_err(Into::into) } } #[pallet::hooks] impl Hooks> for Pallet { /// Automatically exits safe-mode when the current block number is greater than /// [`EnteredUntil`]. fn on_initialize(current: BlockNumberFor) -> Weight { let Some(limit) = EnteredUntil::::get() else { return T::WeightInfo::on_initialize_noop() }; if current > limit { let _ = Self::do_exit(ExitReason::Timeout).defensive_proof("Only Errors if safe-mode is not entered. Safe-mode has already been checked to be entered; qed"); T::WeightInfo::on_initialize_exit() } else { T::WeightInfo::on_initialize_noop() } } } } impl Pallet { /// Logic for the [`crate::Pallet::enter`] and [`crate::Pallet::force_enter`] calls. pub(crate) fn do_enter( who: Option, duration: BlockNumberFor, ) -> Result<(), Error> { ensure!(!Self::is_entered(), Error::::Entered); if let Some(who) = who { let amount = T::EnterDepositAmount::get().ok_or(Error::::NotConfigured)?; Self::hold(who, amount)?; } let until = >::block_number().saturating_add(duration); EnteredUntil::::put(until); Self::deposit_event(Event::Entered { until }); T::Notify::entered(); Ok(()) } /// Logic for the [`crate::Pallet::extend`] and [`crate::Pallet::force_extend`] calls. pub(crate) fn do_extend( who: Option, duration: BlockNumberFor, ) -> Result<(), Error> { let mut until = EnteredUntil::::get().ok_or(Error::::Exited)?; if let Some(who) = who { let amount = T::ExtendDepositAmount::get().ok_or(Error::::NotConfigured)?; Self::hold(who, amount)?; } until.saturating_accrue(duration); EnteredUntil::::put(until); Self::deposit_event(Event::::Extended { until }); Ok(()) } /// Logic for the [`crate::Pallet::force_exit`] call. /// /// Errors if safe-mode is already exited. pub(crate) fn do_exit(reason: ExitReason) -> Result<(), Error> { let _until = EnteredUntil::::take().ok_or(Error::::Exited)?; Self::deposit_event(Event::Exited { reason }); T::Notify::exited(); Ok(()) } /// Logic for the [`crate::Pallet::release_deposit`] and /// [`crate::Pallet::force_release_deposit`] calls. pub(crate) fn do_release( force: bool, account: T::AccountId, block: BlockNumberFor, ) -> Result<(), Error> { let amount = Deposits::::take(&account, &block).ok_or(Error::::NoDeposit)?; if !force { ensure!(!Self::is_entered(), Error::::Entered); let delay = T::ReleaseDelay::get().ok_or(Error::::NotConfigured)?; let now = >::block_number(); ensure!(now > block.saturating_add(delay), Error::::CannotReleaseYet); } let amount = T::Currency::release( &&HoldReason::EnterOrExtend.into(), &account, amount, Precision::BestEffort, ) .map_err(|_| Error::::CurrencyError)?; Self::deposit_event(Event::::DepositReleased { account, amount }); Ok(()) } /// Logic for the [`crate::Pallet::slash_deposit`] call. pub(crate) fn do_force_deposit( account: T::AccountId, block: BlockNumberFor, ) -> Result<(), Error> { let amount = Deposits::::take(&account, block).ok_or(Error::::NoDeposit)?; let burned = T::Currency::burn_held( &&HoldReason::EnterOrExtend.into(), &account, amount, Precision::BestEffort, Fortitude::Force, ) .map_err(|_| Error::::CurrencyError)?; defensive_assert!(burned == amount, "Could not burn the full held amount"); Self::deposit_event(Event::::DepositSlashed { account, amount }); Ok(()) } /// Place a hold for exactly `amount` and store it in `Deposits`. /// /// Errors if the account already has a hold for the same reason. fn hold(who: T::AccountId, amount: BalanceOf) -> Result<(), Error> { let block = >::block_number(); if !T::Currency::balance_on_hold(&HoldReason::EnterOrExtend.into(), &who).is_zero() { return Err(Error::::AlreadyDeposited.into()) } T::Currency::hold(&HoldReason::EnterOrExtend.into(), &who, amount) .map_err(|_| Error::::CurrencyError)?; Deposits::::insert(&who, block, amount); Self::deposit_event(Event::::DepositPlaced { account: who, amount }); Ok(()) } /// Return whether `safe-mode` is entered. pub fn is_entered() -> bool { EnteredUntil::::exists() } /// Return whether the given call is allowed to be dispatched. pub fn is_allowed(call: &T::RuntimeCall) -> bool where T::RuntimeCall: GetCallMetadata, { let CallMetadata { pallet_name, .. } = call.get_call_metadata(); // SAFETY: The `SafeMode` pallet is always allowed. if pallet_name == as PalletInfoAccess>::name() { return true } if Self::is_entered() { T::WhitelistedCalls::contains(call) } else { true } } } impl Contains for Pallet where T::RuntimeCall: GetCallMetadata, { /// Return whether the given call is allowed to be dispatched. fn contains(call: &T::RuntimeCall) -> bool { Pallet::::is_allowed(call) } } impl frame_support::traits::SafeMode for Pallet { type BlockNumber = BlockNumberFor; fn is_entered() -> bool { Self::is_entered() } fn remaining() -> Option> { EnteredUntil::::get().map(|until| { let now = >::block_number(); until.saturating_sub(now) }) } fn enter(duration: BlockNumberFor) -> Result<(), frame_support::traits::SafeModeError> { Self::do_enter(None, duration).map_err(Into::into) } fn extend(duration: BlockNumberFor) -> Result<(), frame_support::traits::SafeModeError> { Self::do_extend(None, duration).map_err(Into::into) } fn exit() -> Result<(), frame_support::traits::SafeModeError> { Self::do_exit(ExitReason::Force).map_err(Into::into) } } impl From> for frame_support::traits::SafeModeError { fn from(err: Error) -> Self { match err { Error::::Entered => Self::AlreadyEntered, Error::::Exited => Self::AlreadyExited, _ => Self::Unknown, } } }