Skip to main content

pallet_staking_async_ah_client/
lib.rs

1// This file is part of Substrate.
2
3// Copyright (C) Parity Technologies (UK) Ltd.
4// SPDX-License-Identifier: Apache-2.0
5
6// Licensed under the Apache License, Version 2.0 (the "License");
7// you may not use this file except in compliance with the License.
8// You may obtain a copy of the License at
9//
10// 	http://www.apache.org/licenses/LICENSE-2.0
11//
12// Unless required by applicable law or agreed to in writing, software
13// distributed under the License is distributed on an "AS IS" BASIS,
14// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15// See the License for the specific language governing permissions and
16// limitations under the License.
17
18//! The client for AssetHub, intended to be used in the relay chain.
19//!
20//! The counter-part for this pallet is `pallet-staking-async-rc-client` on AssetHub.
21//!
22//! This documentation is divided into the following sections:
23//!
24//! 1. Incoming messages: the messages that we receive from the relay chian.
25//! 2. Outgoing messages: the messaged that we sent to the relay chain.
26//! 3. Local interfaces: the interfaces that we expose to other pallets in the runtime.
27//!
28//! ## Incoming Messages
29//!
30//! All incoming messages are handled via [`Call`]. They are all gated to be dispatched only by
31//! [`Config::AssetHubOrigin`]. The only one is:
32//!
33//! * [`Call::validator_set`]: A new validator set for a planning session index.
34//!
35//! ## Outgoing Messages
36//!
37//! All outgoing messages are handled by a single trait
38//! [`pallet_staking_async_rc_client::SendToAssetHub`]. They match the incoming messages of the
39//! `rc-client` pallet.
40//!
41//! ## Local Interfaces:
42//!
43//! Living on the relay chain, this pallet must:
44//!
45//! * Implement [`pallet_session::SessionManager`] (and historical variant thereof) to _give_
46//!   information to the session pallet.
47//! * Implements [`SessionInterface`] to _receive_ information from the session pallet
48//! * Implement [`sp_staking::offence::OnOffenceHandler`].
49//! * Implement reward related APIs ([`frame_support::traits::RewardsReporter`]).
50//!
51//! ## Future Plans
52//!
53//! * Governance functions to force set validators.
54
55#![cfg_attr(not(feature = "std"), no_std)]
56
57pub use pallet::*;
58
59#[cfg(test)]
60pub mod mock;
61
62extern crate alloc;
63use alloc::vec::Vec;
64use frame_support::{
65	pallet_prelude::*,
66	traits::{Defensive, DefensiveSaturating, RewardsReporter},
67};
68pub use pallet_staking_async_rc_client::SendToAssetHub;
69use pallet_staking_async_rc_client::{self as rc_client};
70use sp_runtime::SaturatedConversion;
71use sp_staking::offence::OffenceDetails;
72
73/// The balance type seen from this pallet's PoV.
74pub type BalanceOf<T> = <T as Config>::CurrencyBalance;
75
76/// Type alias for offence details
77pub type OffenceDetailsOf<T> = OffenceDetails<
78	<T as frame_system::Config>::AccountId,
79	(
80		<T as frame_system::Config>::AccountId,
81		sp_staking::Exposure<<T as frame_system::Config>::AccountId, BalanceOf<T>>,
82	),
83>;
84
85const LOG_TARGET: &str = "runtime::staking-async::ah-client";
86
87// syntactic sugar for logging.
88#[macro_export]
89macro_rules! log {
90	($level:tt, $patter:expr $(, $values:expr)* $(,)?) => {
91		log::$level!(
92			target: $crate::LOG_TARGET,
93			concat!("[{:?}] ⬇️ ", $patter), <frame_system::Pallet<T>>::block_number() $(, $values)*
94		)
95	};
96}
97
98/// Re-export `SessionInterface` from `pallet_session`.
99///
100/// This trait provides the interface to talk to the local session pallet for cross-chain
101/// session management.
102pub use pallet_session::SessionInterface;
103
104/// Represents the operating mode of the pallet.
105#[derive(
106	Default,
107	DecodeWithMemTracking,
108	Encode,
109	Decode,
110	MaxEncodedLen,
111	TypeInfo,
112	Clone,
113	PartialEq,
114	Eq,
115	RuntimeDebug,
116	serde::Serialize,
117	serde::Deserialize,
118)]
119pub enum OperatingMode {
120	/// Fully delegated mode.
121	///
122	/// In this mode, the pallet performs no core logic and forwards all relevant operations
123	/// to the fallback implementation defined in the pallet's `Config::Fallback`.
124	///
125	/// This mode is useful when staking is in synchronous mode and waiting for the signal to
126	/// transition to asynchronous mode.
127	#[default]
128	Passive,
129
130	/// Buffered mode for deferred execution.
131	///
132	/// In this mode, offences are accepted and buffered for later transmission to AssetHub.
133	/// However, session change reports are dropped.
134	///
135	/// This mode is useful when the counterpart pallet `pallet-staking-async-rc-client` on
136	/// AssetHub is not yet ready to process incoming messages.
137	Buffered,
138
139	/// Fully active mode.
140	///
141	/// The pallet performs all core logic directly and handles messages immediately.
142	///
143	/// This mode is useful when staking is ready to execute in asynchronous mode and the
144	/// counterpart pallet `pallet-staking-async-rc-client` is ready to accept messages.
145	Active,
146}
147
148impl OperatingMode {
149	fn can_accept_validator_set(&self) -> bool {
150		matches!(self, OperatingMode::Active)
151	}
152}
153
154/// See `pallet_staking::DefaultExposureOf`. This type is the same, except it is duplicated here so
155/// that an rc-runtime can use it after `pallet-staking` is fully removed as a dependency.
156pub struct DefaultExposureOf<T>(core::marker::PhantomData<T>);
157
158impl<T: Config>
159	sp_runtime::traits::Convert<
160		T::AccountId,
161		Option<sp_staking::Exposure<T::AccountId, BalanceOf<T>>>,
162	> for DefaultExposureOf<T>
163{
164	fn convert(
165		validator: T::AccountId,
166	) -> Option<sp_staking::Exposure<T::AccountId, BalanceOf<T>>> {
167		T::SessionInterface::validators()
168			.contains(&validator)
169			.then_some(Default::default())
170	}
171}
172
173#[frame_support::pallet]
174pub mod pallet {
175	use crate::*;
176	use alloc::vec;
177	use frame_support::traits::{Hooks, UnixTime};
178	use frame_system::pallet_prelude::*;
179	use pallet_session::{historical, SessionManager};
180	use pallet_staking_async_rc_client::SessionReport;
181	use sp_runtime::{Perbill, Saturating};
182	use sp_staking::{
183		offence::{OffenceSeverity, OnOffenceHandler},
184		SessionIndex,
185	};
186
187	const STORAGE_VERSION: StorageVersion = StorageVersion::new(1);
188
189	#[pallet::config]
190	pub trait Config: frame_system::Config {
191		/// The balance type of the runtime's currency interface.
192		type CurrencyBalance: sp_runtime::traits::AtLeast32BitUnsigned
193			+ codec::FullCodec
194			+ DecodeWithMemTracking
195			+ codec::HasCompact<Type: DecodeWithMemTracking>
196			+ Copy
197			+ MaybeSerializeDeserialize
198			+ core::fmt::Debug
199			+ Default
200			+ From<u64>
201			+ TypeInfo
202			+ Send
203			+ Sync
204			+ MaxEncodedLen;
205
206		/// An origin type that ensures an incoming message is from asset hub.
207		type AssetHubOrigin: EnsureOrigin<Self::RuntimeOrigin>;
208
209		/// The origin that can control this pallet's operations.
210		type AdminOrigin: EnsureOrigin<Self::RuntimeOrigin>;
211
212		/// Our communication interface to AssetHub.
213		type SendToAssetHub: SendToAssetHub<AccountId = Self::AccountId>;
214
215		/// A safety measure that asserts an incoming validator set must be at least this large.
216		type MinimumValidatorSetSize: Get<u32>;
217
218		/// A safety measure that asserts when iterating over validator points (to be sent to AH),
219		/// we don't iterate too many times.
220		///
221		/// Validator may change session to session, and if session reports are not sent, validator
222		/// points that we store may well grow beyond the size of the validator set. Yet, a too
223		/// large of an upper bound may also exceed the maximum size of a single DMP message.
224		/// Consult the test `message_queue_sizes` for more information.
225		///
226		/// Note that in case a single session report is larger than a single DMP message, it might
227		/// still be sent over if we use
228		/// [`pallet_staking_async_rc_client::XCMSender::split_then_send`]. This will make the size
229		/// of each individual message smaller, yet, it will still try and push them all to the
230		/// queue at the same time.
231		type MaximumValidatorsWithPoints: Get<u32>;
232
233		/// A type that gives us a reliable unix timestamp.
234		type UnixTime: UnixTime;
235
236		/// Number of points to award a validator per block authored.
237		type PointsPerBlock: Get<u32>;
238
239		/// Maximum number of offences to batch in a single message to AssetHub. Actual sending
240		/// happens `on_initialize`. Offences get infinite "retries", and are never dropped.
241		///
242		/// A sensible value should be such that sending this batch is small enough to not exhaust
243		/// the DMP queue. The size of a single offence is documented in `message_queue_sizes` test
244		/// (74 bytes).
245		type MaxOffenceBatchSize: Get<u32>;
246
247		/// Interface to talk to the local Session pallet.
248		type SessionInterface: SessionInterface<
249			ValidatorId = Self::AccountId,
250			AccountId = Self::AccountId,
251		>;
252
253		/// A fallback implementation to delegate logic to when the pallet is in
254		/// [`OperatingMode::Passive`].
255		///
256		/// This type must implement the `historical::SessionManager` and `OnOffenceHandler`
257		/// interface and is expected to behave as a stand-in for this pallet’s core logic when
258		/// delegation is active.
259		type Fallback: pallet_session::SessionManager<Self::AccountId>
260			+ OnOffenceHandler<
261				Self::AccountId,
262				(Self::AccountId, sp_staking::Exposure<Self::AccountId, BalanceOf<Self>>),
263				Weight,
264			> + frame_support::traits::RewardsReporter<Self::AccountId>
265			+ pallet_authorship::EventHandler<Self::AccountId, BlockNumberFor<Self>>;
266
267		/// Maximum number of times we try to send a session report to AssetHub, after which, if
268		/// sending still fails, we drop it.
269		type MaxSessionReportRetries: Get<u32>;
270	}
271
272	#[pallet::pallet]
273	#[pallet::storage_version(STORAGE_VERSION)]
274	pub struct Pallet<T>(_);
275
276	/// The queued validator sets for a given planning session index.
277	///
278	/// This is received via a call from AssetHub.
279	#[pallet::storage]
280	#[pallet::unbounded]
281	pub type ValidatorSet<T: Config> = StorageValue<_, (u32, Vec<T::AccountId>), OptionQuery>;
282
283	/// An incomplete validator set report.
284	#[pallet::storage]
285	#[pallet::unbounded]
286	pub type IncompleteValidatorSetReport<T: Config> =
287		StorageValue<_, rc_client::ValidatorSetReport<T::AccountId>, OptionQuery>;
288
289	/// All of the points of the validators.
290	///
291	/// This is populated during a session, and is flushed and sent over via [`SendToAssetHub`]
292	/// at each session end.
293	#[pallet::storage]
294	pub type ValidatorPoints<T: Config> =
295		StorageMap<_, Twox64Concat, T::AccountId, u32, ValueQuery>;
296
297	/// Indicates the current operating mode of the pallet.
298	///
299	/// This value determines how the pallet behaves in response to incoming and outgoing messages,
300	/// particularly whether it should execute logic directly, defer it, or delegate it entirely.
301	#[pallet::storage]
302	pub type Mode<T: Config> = StorageValue<_, OperatingMode, ValueQuery>;
303
304	/// A storage value that is set when a `new_session` gives a new validator set to the session
305	/// pallet, and is cleared on the next call.
306	///
307	/// The inner u32 is the id of the said activated validator set. While not relevant here, good
308	/// to know this is the planning era index of staking-async on AH.
309	///
310	/// Once cleared, we know a validator set has been activated, and therefore we can send a
311	/// timestamp to AH.
312	#[pallet::storage]
313	pub type NextSessionChangesValidators<T: Config> = StorageValue<_, u32, OptionQuery>;
314
315	/// The session index at which the latest elected validator set was applied.
316	///
317	/// This is used to determine if an offence, given a session index, is in the current active era
318	/// or not.
319	#[pallet::storage]
320	pub type ValidatorSetAppliedAt<T: Config> = StorageValue<_, SessionIndex, OptionQuery>;
321
322	/// A session report that is outgoing, and should be sent.
323	///
324	/// This will be attempted to be sent, possibly on every `on_initialize` call, until it is sent,
325	/// or the second value reaches zero, at which point we drop it.
326	#[pallet::storage]
327	#[pallet::unbounded]
328	pub type OutgoingSessionReport<T: Config> =
329		StorageValue<_, (SessionReport<T::AccountId>, u32), OptionQuery>;
330
331	/// Wrapper struct for storing offences, and getting them back page by page.
332	///
333	/// It has only two interfaces:
334	///
335	/// * [`OffenceSendQueue::append`], to add a single offence.
336	/// * [`OffenceSendQueue::get_and_maybe_delete`] which retrieves the last page. Depending on the
337	///   closure, it may also delete that page. The returned value is indeed
338	///   [`Config::MaxOffenceBatchSize`] or less items.
339	///
340	/// Internally, it manages `OffenceSendQueueOffences` and `OffenceSendQueueCursor`, both of
341	/// which should NEVER be used manually.
342	pub struct OffenceSendQueue<T: Config>(core::marker::PhantomData<T>);
343
344	/// A single buffered offence in [`OffenceSendQueue`].
345	pub type QueuedOffenceOf<T> =
346		(SessionIndex, rc_client::Offence<<T as frame_system::Config>::AccountId>);
347	/// A page of buffered offences in [`OffenceSendQueue`].
348	pub type QueuedOffencePageOf<T> =
349		BoundedVec<QueuedOffenceOf<T>, <T as Config>::MaxOffenceBatchSize>;
350
351	impl<T: Config> OffenceSendQueue<T> {
352		/// Add a single offence to the queue.
353		pub fn append(o: QueuedOffenceOf<T>) {
354			let mut index = OffenceSendQueueCursor::<T>::get();
355			match OffenceSendQueueOffences::<T>::try_mutate(index, |b| b.try_push(o.clone())) {
356				Ok(_) => {
357					// `index` had empty slot -- all good.
358				},
359				Err(_) => {
360					debug_assert!(
361						!OffenceSendQueueOffences::<T>::contains_key(index + 1),
362						"next page should be empty"
363					);
364					index += 1;
365					OffenceSendQueueOffences::<T>::insert(
366						index,
367						BoundedVec::<_, _>::try_from(vec![o]).defensive_unwrap_or_default(),
368					);
369					OffenceSendQueueCursor::<T>::mutate(|i| *i += 1);
370				},
371			}
372		}
373
374		// Get the last page of offences, and delete it if `op` returns `Ok(())`.
375		pub fn get_and_maybe_delete(op: impl FnOnce(QueuedOffencePageOf<T>) -> Result<(), ()>) {
376			let index = OffenceSendQueueCursor::<T>::get();
377			let page = OffenceSendQueueOffences::<T>::get(index);
378			let res = op(page);
379			match res {
380				Ok(_) => {
381					OffenceSendQueueOffences::<T>::remove(index);
382					OffenceSendQueueCursor::<T>::mutate(|i| *i = i.saturating_sub(1))
383				},
384				Err(_) => {
385					// nada
386				},
387			}
388		}
389
390		#[cfg(feature = "std")]
391		pub fn pages() -> u32 {
392			let last_page = if Self::last_page_empty() { 0 } else { 1 };
393			OffenceSendQueueCursor::<T>::get().saturating_add(last_page)
394		}
395
396		#[cfg(feature = "std")]
397		pub fn count() -> u32 {
398			let last_index = OffenceSendQueueCursor::<T>::get();
399			let last_page = OffenceSendQueueOffences::<T>::get(last_index);
400			let last_page_count = last_page.len() as u32;
401			last_index.saturating_mul(T::MaxOffenceBatchSize::get()) + last_page_count
402		}
403
404		#[cfg(feature = "std")]
405		fn last_page_empty() -> bool {
406			OffenceSendQueueOffences::<T>::get(OffenceSendQueueCursor::<T>::get()).is_empty()
407		}
408	}
409
410	/// Internal storage item of [`OffenceSendQueue`]. Should not be used manually.
411	#[pallet::storage]
412	#[pallet::unbounded]
413	pub(crate) type OffenceSendQueueOffences<T: Config> =
414		StorageMap<_, Twox64Concat, u32, QueuedOffencePageOf<T>, ValueQuery>;
415	/// Internal storage item of [`OffenceSendQueue`]. Should not be used manually.
416	#[pallet::storage]
417	pub(crate) type OffenceSendQueueCursor<T: Config> = StorageValue<_, u32, ValueQuery>;
418
419	#[pallet::genesis_config]
420	#[derive(frame_support::DefaultNoBound, frame_support::DebugNoBound)]
421	pub struct GenesisConfig<T: Config> {
422		/// The initial operating mode of the pallet.
423		pub operating_mode: OperatingMode,
424		pub _marker: core::marker::PhantomData<T>,
425	}
426
427	#[pallet::genesis_build]
428	impl<T: Config> BuildGenesisConfig for GenesisConfig<T> {
429		fn build(&self) {
430			// Set the initial operating mode of the pallet.
431			Mode::<T>::put(self.operating_mode.clone());
432		}
433	}
434
435	#[pallet::error]
436	pub enum Error<T> {
437		/// Could not process incoming message because incoming messages are blocked.
438		Blocked,
439	}
440
441	#[pallet::event]
442	#[pallet::generate_deposit(fn deposit_event)]
443	pub enum Event<T: Config> {
444		/// A new validator set has been received.
445		ValidatorSetReceived {
446			id: u32,
447			new_validator_set_count: u32,
448			prune_up_to: Option<SessionIndex>,
449			leftover: bool,
450		},
451		/// We could not merge, and therefore dropped a buffered message.
452		///
453		/// Note that this event is more resembling an error, but we use an event because in this
454		/// pallet we need to mutate storage upon some failures.
455		CouldNotMergeAndDropped,
456		/// The validator set received is way too small, as per
457		/// [`Config::MinimumValidatorSetSize`].
458		SetTooSmallAndDropped,
459		/// Something occurred that should never happen under normal operation. Logged as an event
460		/// for fail-safe observability.
461		Unexpected(UnexpectedKind),
462		/// Session keys updated for a validator.
463		SessionKeysUpdated { stash: T::AccountId, update: SessionKeysUpdate },
464	}
465
466	/// The type of session keys update received from AssetHub.
467	#[derive(Clone, Encode, Decode, DecodeWithMemTracking, PartialEq, TypeInfo, Debug)]
468	pub enum SessionKeysUpdate {
469		/// Session keys have been set.
470		Set,
471		/// Session keys have been purged.
472		Purged,
473	}
474
475	/// Represents unexpected or invariant-breaking conditions encountered during execution.
476	///
477	/// These variants are emitted as [`Event::Unexpected`] and indicate a defensive check has
478	/// failed. While these should never occur under normal operation, they are useful for
479	/// diagnosing issues in production or test environments.
480	#[derive(Clone, Encode, Decode, DecodeWithMemTracking, PartialEq, TypeInfo, RuntimeDebug)]
481	pub enum UnexpectedKind {
482		/// A validator set was received while the pallet is in [`OperatingMode::Passive`].
483		ReceivedValidatorSetWhilePassive,
484
485		/// An unexpected transition was applied between operating modes.
486		///
487		/// Expected transitions are linear and forward-only: `Passive` → `Buffered` → `Active`.
488		UnexpectedModeTransition,
489
490		/// A session report failed to be sent.
491		///
492		/// We will store, and retry it for a number of more block.
493		SessionReportSendFailed,
494
495		/// A session report failed enough times that we should drop it.
496		///
497		/// We will retain the validator points, and send them over in the next session we receive
498		/// from pallet-session.
499		SessionReportDropped,
500
501		/// An offence report failed to be sent.
502		///
503		/// It will be retried again in the next block. We never drop them.
504		OffenceSendFailed,
505
506		/// Some validator points didn't make it to be included in the session report. Should
507		/// never happen, and means:
508		///
509		/// * a too low of a value is assigned to [`Config::MaximumValidatorsWithPoints`]
510		/// * Those who are calling into our `RewardsReporter` likely have a bad view of the
511		///   validator set, and are spamming us.
512		ValidatorPointDropped,
513
514		/// Session keys received from AssetHub failed to decode.
515		///
516		/// This should never happen since AssetHub validates keys before forwarding them.
517		/// If this occurs, it indicates a mismatch between AH and RC key types or a bug.
518		InvalidKeysFromAssetHub,
519	}
520
521	#[pallet::call]
522	impl<T: Config> Pallet<T> {
523		#[pallet::call_index(0)]
524		#[pallet::weight(
525			// Reads:
526			// - OperatingMode
527			// - IncompleteValidatorSetReport
528			// Writes:
529			// - IncompleteValidatorSetReport or ValidatorSet
530			// ignoring `T::SessionInterface::prune_up_to`
531			T::DbWeight::get().reads_writes(2, 1)
532		)]
533		pub fn validator_set(
534			origin: OriginFor<T>,
535			report: rc_client::ValidatorSetReport<T::AccountId>,
536		) -> DispatchResult {
537			// Ensure the origin is one of Root or whatever is representing AssetHub.
538			log!(debug, "Received new validator set report {}", report);
539			T::AssetHubOrigin::ensure_origin_or_root(origin)?;
540
541			// Check the operating mode.
542			let mode = Mode::<T>::get();
543			ensure!(mode.can_accept_validator_set(), Error::<T>::Blocked);
544
545			let maybe_merged_report = match IncompleteValidatorSetReport::<T>::take() {
546				Some(old) => old.merge(report.clone()),
547				None => Ok(report),
548			};
549
550			if maybe_merged_report.is_err() {
551				Self::deposit_event(Event::CouldNotMergeAndDropped);
552				debug_assert!(
553					IncompleteValidatorSetReport::<T>::get().is_none(),
554					"we have ::take() it above, we don't want to keep the old data"
555				);
556				return Ok(());
557			}
558
559			let report = maybe_merged_report.expect("checked above; qed");
560
561			if report.leftover {
562				// buffer it, and nothing further to do.
563				Self::deposit_event(Event::ValidatorSetReceived {
564					id: report.id,
565					new_validator_set_count: report.new_validator_set.len() as u32,
566					prune_up_to: report.prune_up_to,
567					leftover: report.leftover,
568				});
569				IncompleteValidatorSetReport::<T>::put(report);
570			} else {
571				// message is complete, process it.
572				let rc_client::ValidatorSetReport {
573					id,
574					leftover,
575					mut new_validator_set,
576					prune_up_to,
577				} = report;
578
579				// ensure the validator set, deduplicated, is not too big.
580				new_validator_set.sort();
581				new_validator_set.dedup();
582
583				if (new_validator_set.len() as u32) < T::MinimumValidatorSetSize::get() {
584					Self::deposit_event(Event::SetTooSmallAndDropped);
585					debug_assert!(
586						IncompleteValidatorSetReport::<T>::get().is_none(),
587						"we have ::take() it above, we don't want to keep the old data"
588					);
589					return Ok(());
590				}
591
592				Self::deposit_event(Event::ValidatorSetReceived {
593					id,
594					new_validator_set_count: new_validator_set.len() as u32,
595					prune_up_to,
596					leftover,
597				});
598
599				// Save the validator set.
600				ValidatorSet::<T>::put((id, new_validator_set));
601				if let Some(index) = prune_up_to {
602					T::SessionInterface::prune_up_to(index);
603				}
604			}
605
606			Ok(())
607		}
608
609		/// Allows governance to force set the operating mode of the pallet.
610		#[pallet::call_index(1)]
611		#[pallet::weight(T::DbWeight::get().writes(1))]
612		pub fn set_mode(origin: OriginFor<T>, mode: OperatingMode) -> DispatchResult {
613			T::AdminOrigin::ensure_origin(origin)?;
614			Self::do_set_mode(mode);
615			Ok(())
616		}
617
618		/// manually do what this pallet was meant to do at the end of the migration.
619		#[pallet::call_index(2)]
620		#[pallet::weight(T::DbWeight::get().writes(1))]
621		pub fn force_on_migration_end(origin: OriginFor<T>) -> DispatchResult {
622			T::AdminOrigin::ensure_origin(origin)?;
623			Self::on_migration_end();
624			Ok(())
625		}
626
627		/// Set session keys for a validator, forwarded from AssetHub.
628		///
629		/// This is called when a validator sets their session keys on AssetHub, which forwards
630		/// the request to the RelayChain via XCM.
631		///
632		/// AssetHub validates both keys and ownership proof before sending.
633		/// RC trusts AH's validation and does not re-validate.
634		#[pallet::call_index(3)]
635		#[pallet::weight(T::SessionInterface::set_keys_weight())]
636		pub fn set_keys_from_ah(
637			origin: OriginFor<T>,
638			stash: T::AccountId,
639			keys: Vec<u8>,
640		) -> DispatchResult {
641			T::AssetHubOrigin::ensure_origin_or_root(origin)?;
642			log::info!(target: LOG_TARGET, "Received set_keys request from AssetHub for {stash:?}");
643
644			// Decode the keys from bytes (AH already validated, this is just for type conversion)
645			let session_keys =
646				match <<T as Config>::SessionInterface as SessionInterface>::Keys::decode(
647					&mut &keys[..],
648				) {
649					Ok(keys) => keys,
650					Err(e) => {
651						// This should never happen since AH validates keys before forwarding.
652						// Returning Ok() allows the event to be observed for monitoring.
653						log!(
654							warn,
655							"InvalidKeysFromAssetHub: failed to decode keys for {:?}: {:?}",
656							stash,
657							e
658						);
659						Self::deposit_event(Event::Unexpected(
660							UnexpectedKind::InvalidKeysFromAssetHub,
661						));
662						return Ok(());
663					},
664				};
665
666			T::SessionInterface::set_keys(&stash, session_keys)?;
667			Self::deposit_event(Event::SessionKeysUpdated {
668				stash,
669				update: SessionKeysUpdate::Set,
670			});
671			Ok(())
672		}
673
674		/// Purge session keys for a validator, forwarded from AssetHub.
675		///
676		/// This is called when a validator purges their session keys on AssetHub, which forwards
677		/// the request to the RelayChain via XCM.
678		#[pallet::call_index(4)]
679		#[pallet::weight(T::SessionInterface::purge_keys_weight())]
680		pub fn purge_keys_from_ah(origin: OriginFor<T>, stash: T::AccountId) -> DispatchResult {
681			T::AssetHubOrigin::ensure_origin_or_root(origin)?;
682			log::info!(target: LOG_TARGET, "Received purge_keys request from AssetHub for {stash:?}");
683
684			T::SessionInterface::purge_keys(&stash)?;
685			Self::deposit_event(Event::SessionKeysUpdated {
686				stash,
687				update: SessionKeysUpdate::Purged,
688			});
689			Ok(())
690		}
691	}
692
693	#[pallet::hooks]
694	impl<T: Config> Hooks<BlockNumberFor<T>> for Pallet<T> {
695		fn on_initialize(_n: BlockNumberFor<T>) -> Weight {
696			let mut weight = Weight::zero();
697
698			let mode = Mode::<T>::get();
699			weight = weight.saturating_add(T::DbWeight::get().reads(1));
700			if mode != OperatingMode::Active {
701				return weight;
702			}
703
704			// if we have any pending session reports, send it.
705			weight.saturating_accrue(T::DbWeight::get().reads(1));
706			if let Some((session_report, retries_left)) = OutgoingSessionReport::<T>::take() {
707				match T::SendToAssetHub::relay_session_report(session_report.clone()) {
708					Ok(()) => {
709						// report was sent, all good, it is already deleted.
710					},
711					Err(()) => {
712						log!(error, "Failed to send session report to assethub");
713						Self::deposit_event(Event::<T>::Unexpected(
714							UnexpectedKind::SessionReportSendFailed,
715						));
716						if let Some(new_retries_left) = retries_left.checked_sub(One::one()) {
717							OutgoingSessionReport::<T>::put((session_report, new_retries_left))
718						} else {
719							// recreate the validator points, so they will be sent in the next
720							// report.
721							session_report.validator_points.into_iter().for_each(|(v, p)| {
722								ValidatorPoints::<T>::mutate(v, |existing_points| {
723									*existing_points = existing_points.defensive_saturating_add(p)
724								});
725							});
726
727							Self::deposit_event(Event::<T>::Unexpected(
728								UnexpectedKind::SessionReportDropped,
729							));
730						}
731					},
732				}
733			}
734
735			// then, take a page from our send queue, and if present, send it.
736			weight.saturating_accrue(T::DbWeight::get().reads(2));
737			OffenceSendQueue::<T>::get_and_maybe_delete(|page| {
738				if page.is_empty() {
739					return Ok(())
740				}
741				// send the page if not empty. If sending returns `Ok`, we delete this page.
742				T::SendToAssetHub::relay_new_offence_paged(page.into_inner()).inspect_err(|_| {
743					Self::deposit_event(Event::Unexpected(UnexpectedKind::OffenceSendFailed));
744				})
745			});
746
747			weight
748		}
749
750		fn integrity_test() {
751			assert!(T::MaxOffenceBatchSize::get() > 0, "Offence Batch size must be at least 1");
752		}
753	}
754
755	impl<T: Config>
756		historical::SessionManager<T::AccountId, sp_staking::Exposure<T::AccountId, BalanceOf<T>>>
757		for Pallet<T>
758	{
759		fn new_session(
760			new_index: sp_staking::SessionIndex,
761		) -> Option<
762			Vec<(
763				<T as frame_system::Config>::AccountId,
764				sp_staking::Exposure<T::AccountId, BalanceOf<T>>,
765			)>,
766		> {
767			<Self as pallet_session::SessionManager<_>>::new_session(new_index)
768				.map(|v| v.into_iter().map(|v| (v, sp_staking::Exposure::default())).collect())
769		}
770
771		fn new_session_genesis(
772			new_index: SessionIndex,
773		) -> Option<Vec<(T::AccountId, sp_staking::Exposure<T::AccountId, BalanceOf<T>>)>> {
774			if Mode::<T>::get() == OperatingMode::Passive {
775				T::Fallback::new_session_genesis(new_index).map(|validators| {
776					validators.into_iter().map(|v| (v, sp_staking::Exposure::default())).collect()
777				})
778			} else {
779				None
780			}
781		}
782
783		fn start_session(start_index: SessionIndex) {
784			<Self as pallet_session::SessionManager<_>>::start_session(start_index)
785		}
786
787		fn end_session(end_index: SessionIndex) {
788			<Self as pallet_session::SessionManager<_>>::end_session(end_index)
789		}
790	}
791
792	impl<T: Config> pallet_session::SessionManager<T::AccountId> for Pallet<T> {
793		fn new_session(session_index: u32) -> Option<Vec<T::AccountId>> {
794			match Mode::<T>::get() {
795				OperatingMode::Passive => T::Fallback::new_session(session_index),
796				// In `Buffered` mode, we drop the session report and do nothing.
797				OperatingMode::Buffered => None,
798				OperatingMode::Active => Self::do_new_session(),
799			}
800		}
801
802		fn start_session(session_index: u32) {
803			if Mode::<T>::get() == OperatingMode::Passive {
804				T::Fallback::start_session(session_index)
805			}
806		}
807
808		fn new_session_genesis(new_index: SessionIndex) -> Option<Vec<T::AccountId>> {
809			if Mode::<T>::get() == OperatingMode::Passive {
810				T::Fallback::new_session_genesis(new_index)
811			} else {
812				None
813			}
814		}
815
816		fn end_session(session_index: u32) {
817			match Mode::<T>::get() {
818				OperatingMode::Passive => T::Fallback::end_session(session_index),
819				// In `Buffered` mode, we drop the session report and do nothing.
820				OperatingMode::Buffered => (),
821				OperatingMode::Active => Self::do_end_session(session_index),
822			}
823		}
824	}
825
826	impl<T: Config>
827		OnOffenceHandler<
828			T::AccountId,
829			(T::AccountId, sp_staking::Exposure<T::AccountId, BalanceOf<T>>),
830			Weight,
831		> for Pallet<T>
832	{
833		fn on_offence(
834			offenders: &[OffenceDetails<
835				T::AccountId,
836				(T::AccountId, sp_staking::Exposure<T::AccountId, BalanceOf<T>>),
837			>],
838			slash_fraction: &[Perbill],
839			slash_session: SessionIndex,
840		) -> Weight {
841			match Mode::<T>::get() {
842				OperatingMode::Passive => {
843					// delegate to the fallback implementation.
844					T::Fallback::on_offence(offenders, slash_fraction, slash_session)
845				},
846				OperatingMode::Buffered =>
847					Self::on_offence_buffered(offenders, slash_fraction, slash_session),
848				OperatingMode::Active =>
849					Self::on_offence_active(offenders, slash_fraction, slash_session),
850			}
851		}
852	}
853
854	impl<T: Config> RewardsReporter<T::AccountId> for Pallet<T> {
855		fn reward_by_ids(rewards: impl IntoIterator<Item = (T::AccountId, u32)>) {
856			match Mode::<T>::get() {
857				OperatingMode::Passive => T::Fallback::reward_by_ids(rewards),
858				OperatingMode::Buffered | OperatingMode::Active => Self::do_reward_by_ids(rewards),
859			}
860		}
861	}
862
863	impl<T: Config> pallet_authorship::EventHandler<T::AccountId, BlockNumberFor<T>> for Pallet<T> {
864		fn note_author(author: T::AccountId) {
865			match Mode::<T>::get() {
866				OperatingMode::Passive => T::Fallback::note_author(author),
867				OperatingMode::Buffered | OperatingMode::Active => Self::do_note_author(author),
868			}
869		}
870	}
871
872	impl<T: Config> Pallet<T> {
873		/// Hook to be called when the AssetHub migration begins.
874		///
875		/// This transitions the pallet into [`OperatingMode::Buffered`], meaning it will act as the
876		/// primary staking module on the relay chain but will buffer outgoing messages instead of
877		/// sending them to AssetHub.
878		///
879		/// While in this mode, the pallet stops delegating to the fallback implementation and
880		/// temporarily accumulates events for later processing.
881		pub fn on_migration_start() {
882			debug_assert!(
883				Mode::<T>::get() == OperatingMode::Passive,
884				"we should only be called when in passive mode"
885			);
886			Self::do_set_mode(OperatingMode::Buffered);
887		}
888
889		/// Hook to be called when the AssetHub migration is complete.
890		///
891		/// This transitions the pallet into [`OperatingMode::Active`], meaning the counterpart
892		/// pallet on AssetHub is ready to accept incoming messages, and this pallet can resume
893		/// sending them.
894		///
895		/// In this mode, the pallet becomes fully active and processes all staking-related events
896		/// directly.
897		pub fn on_migration_end() {
898			debug_assert!(
899				Mode::<T>::get() == OperatingMode::Buffered,
900				"we should only be called when in buffered mode"
901			);
902			Self::do_set_mode(OperatingMode::Active);
903
904			// Buffered offences will be processed gradually by on_initialize
905			// using MaxOffenceBatchSize to prevent block overload.
906		}
907
908		fn do_set_mode(new_mode: OperatingMode) {
909			let old_mode = Mode::<T>::get();
910			let unexpected = match new_mode {
911				// `Passive` is the initial state, and not expected to be set by the user.
912				OperatingMode::Passive => true,
913				OperatingMode::Buffered => old_mode != OperatingMode::Passive,
914				OperatingMode::Active => old_mode != OperatingMode::Buffered,
915			};
916
917			// this is a defensive check, and should never happen under normal operation.
918			if unexpected {
919				log!(warn, "Unexpected mode transition from {:?} to {:?}", old_mode, new_mode);
920				Self::deposit_event(Event::Unexpected(UnexpectedKind::UnexpectedModeTransition));
921			}
922
923			// apply new mode anyway.
924			Mode::<T>::put(new_mode);
925		}
926
927		fn do_new_session() -> Option<Vec<T::AccountId>> {
928			ValidatorSet::<T>::take().map(|(id, val_set)| {
929				// store the id to be sent back in the next session back to AH
930				NextSessionChangesValidators::<T>::put(id);
931				val_set
932			})
933		}
934
935		fn do_end_session(end_index: u32) {
936			// take and delete all validator points, limited by `MaximumValidatorsWithPoints`.
937			let validator_points = ValidatorPoints::<T>::iter()
938				.drain()
939				.take(T::MaximumValidatorsWithPoints::get() as usize)
940				.collect::<Vec<_>>();
941
942			// If there were more validators than `MaximumValidatorsWithPoints`..
943			if ValidatorPoints::<T>::iter().next().is_some() {
944				// ..not much more we can do about it other than an event.
945				Self::deposit_event(Event::<T>::Unexpected(UnexpectedKind::ValidatorPointDropped))
946			}
947
948			let activation_timestamp = NextSessionChangesValidators::<T>::take().map(|id| {
949				// keep track of starting session index at which the validator set was applied.
950				ValidatorSetAppliedAt::<T>::put(end_index + 1);
951				// set the timestamp and the identifier of the validator set.
952				(T::UnixTime::now().as_millis().saturated_into::<u64>(), id)
953			});
954
955			let session_report = pallet_staking_async_rc_client::SessionReport {
956				end_index,
957				validator_points,
958				activation_timestamp,
959				leftover: false,
960			};
961
962			// queue the session report to be sent.
963			OutgoingSessionReport::<T>::put((session_report, T::MaxSessionReportRetries::get()));
964		}
965
966		fn do_reward_by_ids(rewards: impl IntoIterator<Item = (T::AccountId, u32)>) {
967			for (validator_id, points) in rewards {
968				ValidatorPoints::<T>::mutate(validator_id, |balance| {
969					balance.saturating_accrue(points);
970				});
971			}
972		}
973
974		fn do_note_author(author: T::AccountId) {
975			ValidatorPoints::<T>::mutate(author, |points| {
976				points.saturating_accrue(T::PointsPerBlock::get());
977			});
978		}
979
980		/// Check if an offence is from the active validator set.
981		fn is_ongoing_offence(slash_session: SessionIndex) -> bool {
982			ValidatorSetAppliedAt::<T>::get()
983				.map(|start_session| slash_session >= start_session)
984				.unwrap_or(false)
985		}
986
987		/// Handle offences in Buffered mode.
988		fn on_offence_buffered(
989			offenders: &[OffenceDetailsOf<T>],
990			slash_fraction: &[Perbill],
991			slash_session: SessionIndex,
992		) -> Weight {
993			let ongoing_offence = Self::is_ongoing_offence(slash_session);
994
995			offenders.iter().cloned().zip(slash_fraction).for_each(|(offence, fraction)| {
996				if ongoing_offence {
997					// report the offence to the session pallet.
998					T::SessionInterface::report_offence(
999						offence.offender.0.clone(),
1000						OffenceSeverity(*fraction),
1001					);
1002				}
1003
1004				let (offender, _full_identification) = offence.offender;
1005				let reporters = offence.reporters;
1006
1007				// In `Buffered` mode, we buffer the offences for later processing.
1008				OffenceSendQueue::<T>::append((
1009					slash_session,
1010					rc_client::Offence {
1011						offender: offender.clone(),
1012						reporters: reporters.into_iter().take(1).collect(),
1013						slash_fraction: *fraction,
1014					},
1015				));
1016			});
1017
1018			T::DbWeight::get().reads_writes(1, 1)
1019		}
1020
1021		/// Handle offences in Active mode.
1022		fn on_offence_active(
1023			offenders: &[OffenceDetailsOf<T>],
1024			slash_fraction: &[Perbill],
1025			slash_session: SessionIndex,
1026		) -> Weight {
1027			let ongoing_offence = Self::is_ongoing_offence(slash_session);
1028
1029			offenders.iter().cloned().zip(slash_fraction).for_each(|(offence, fraction)| {
1030				if ongoing_offence {
1031					// report the offence to the session pallet.
1032					T::SessionInterface::report_offence(
1033						offence.offender.0.clone(),
1034						OffenceSeverity(*fraction),
1035					);
1036				}
1037
1038				let (offender, _full_identification) = offence.offender;
1039				let reporters = offence.reporters;
1040
1041				// prepare an `Offence` instance for the XCM message. Note that we drop
1042				// the identification.
1043				let offence = rc_client::Offence {
1044					offender,
1045					reporters: reporters.into_iter().take(1).collect(),
1046					slash_fraction: *fraction,
1047				};
1048				OffenceSendQueue::<T>::append((slash_session, offence))
1049			});
1050
1051			T::DbWeight::get().reads_writes(2, 2)
1052		}
1053	}
1054}
1055
1056#[cfg(test)]
1057mod keys_from_ah_tests {
1058	use super::*;
1059	use crate::mock::*;
1060	use codec::Encode;
1061	use frame_support::{assert_noop, assert_ok, hypothetically};
1062	use sp_runtime::DispatchError;
1063
1064	#[test]
1065	fn set_keys_from_ah() {
1066		new_test_ext().execute_with(|| {
1067			System::set_block_number(1);
1068			let stash = 42u64;
1069			let keys = MockSessionKeys { dummy: [1u8; 32] };
1070
1071			// success with root origin
1072			hypothetically!({
1073				SetKeysCalls::take();
1074				assert_ok!(StakingAsyncAhClient::set_keys_from_ah(
1075					RuntimeOrigin::root(),
1076					stash,
1077					keys.encode(),
1078				));
1079				assert_eq!(SetKeysCalls::get(), vec![(stash, keys.clone())]);
1080				System::assert_has_event(
1081					Event::<Test>::SessionKeysUpdated { stash, update: SessionKeysUpdate::Set }
1082						.into(),
1083				);
1084			});
1085
1086			// rejects bad origin
1087			hypothetically!({
1088				SetKeysCalls::take();
1089				assert_noop!(
1090					StakingAsyncAhClient::set_keys_from_ah(
1091						RuntimeOrigin::signed(1),
1092						stash,
1093						keys.encode(),
1094					),
1095					DispatchError::BadOrigin
1096				);
1097				assert!(SetKeysCalls::get().is_empty());
1098			});
1099
1100			// handles invalid keys gracefully
1101			hypothetically!({
1102				SetKeysCalls::take();
1103				assert_ok!(StakingAsyncAhClient::set_keys_from_ah(
1104					RuntimeOrigin::root(),
1105					stash,
1106					vec![1u8, 2, 3], // invalid encoding
1107				));
1108				assert!(SetKeysCalls::get().is_empty());
1109				System::assert_has_event(
1110					Event::<Test>::Unexpected(UnexpectedKind::InvalidKeysFromAssetHub).into(),
1111				);
1112			});
1113		});
1114	}
1115
1116	#[test]
1117	fn purge_keys_from_ah() {
1118		new_test_ext().execute_with(|| {
1119			System::set_block_number(1);
1120			let stash = 42u64;
1121
1122			// success with root origin
1123			hypothetically!({
1124				PurgeKeysCalls::take();
1125				assert_ok!(StakingAsyncAhClient::purge_keys_from_ah(RuntimeOrigin::root(), stash));
1126				assert_eq!(PurgeKeysCalls::get(), vec![stash]);
1127				System::assert_has_event(
1128					Event::<Test>::SessionKeysUpdated { stash, update: SessionKeysUpdate::Purged }
1129						.into(),
1130				);
1131			});
1132
1133			// rejects bad origin
1134			hypothetically!({
1135				PurgeKeysCalls::take();
1136				assert_noop!(
1137					StakingAsyncAhClient::purge_keys_from_ah(RuntimeOrigin::signed(1), stash),
1138					DispatchError::BadOrigin
1139				);
1140				assert!(PurgeKeysCalls::get().is_empty());
1141			});
1142		});
1143	}
1144}
1145
1146#[cfg(test)]
1147mod send_queue_tests {
1148	use frame_support::hypothetically;
1149	use sp_runtime::Perbill;
1150
1151	use super::*;
1152	use crate::mock::*;
1153
1154	// (cursor, len_of_pages)
1155	fn status() -> (u32, Vec<u32>) {
1156		let mut sorted = OffenceSendQueueOffences::<Test>::iter().collect::<Vec<_>>();
1157		sorted.sort_by(|x, y| x.0.cmp(&y.0));
1158		(
1159			OffenceSendQueueCursor::<Test>::get(),
1160			sorted.into_iter().map(|(_, v)| v.len() as u32).collect(),
1161		)
1162	}
1163
1164	#[test]
1165	fn append_and_take() {
1166		new_test_ext().execute_with(|| {
1167			let o = (
1168				42,
1169				rc_client::Offence {
1170					offender: 42,
1171					reporters: vec![],
1172					slash_fraction: Perbill::from_percent(10),
1173				},
1174			);
1175			let page_size = <Test as Config>::MaxOffenceBatchSize::get();
1176			assert_eq!(page_size % 2, 0, "page size should be even");
1177
1178			assert_eq!(status(), (0, vec![]));
1179
1180			// --- when empty
1181
1182			assert_eq!(OffenceSendQueue::<Test>::count(), 0);
1183			assert_eq!(OffenceSendQueue::<Test>::pages(), 0);
1184
1185			// get and keep
1186			hypothetically!({
1187				OffenceSendQueue::<Test>::get_and_maybe_delete(|page| {
1188					assert_eq!(page.len(), 0);
1189					Err(())
1190				});
1191				assert_eq!(status(), (0, vec![]));
1192			});
1193
1194			// get and delete
1195			hypothetically!({
1196				OffenceSendQueue::<Test>::get_and_maybe_delete(|page| {
1197					assert_eq!(page.len(), 0);
1198					Ok(())
1199				});
1200				assert_eq!(status(), (0, vec![]));
1201			});
1202
1203			// -------- when 1 page half filled
1204			for _ in 0..page_size / 2 {
1205				OffenceSendQueue::<Test>::append(o.clone());
1206			}
1207			assert_eq!(status(), (0, vec![page_size / 2]));
1208			assert_eq!(OffenceSendQueue::<Test>::count(), page_size / 2);
1209			assert_eq!(OffenceSendQueue::<Test>::pages(), 1);
1210
1211			// get and keep
1212			hypothetically!({
1213				OffenceSendQueue::<Test>::get_and_maybe_delete(|page| {
1214					assert_eq!(page.len() as u32, page_size / 2);
1215					Err(())
1216				});
1217				assert_eq!(status(), (0, vec![page_size / 2]));
1218			});
1219
1220			// get and delete
1221			hypothetically!({
1222				OffenceSendQueue::<Test>::get_and_maybe_delete(|page| {
1223					assert_eq!(page.len() as u32, page_size / 2);
1224					Ok(())
1225				});
1226				assert_eq!(status(), (0, vec![]));
1227				assert_eq!(OffenceSendQueue::<Test>::count(), 0);
1228				assert_eq!(OffenceSendQueue::<Test>::pages(), 0);
1229			});
1230
1231			// -------- when 1 page full
1232			for _ in 0..page_size / 2 {
1233				OffenceSendQueue::<Test>::append(o.clone());
1234			}
1235			assert_eq!(status(), (0, vec![page_size]));
1236			assert_eq!(OffenceSendQueue::<Test>::count(), page_size);
1237			assert_eq!(OffenceSendQueue::<Test>::pages(), 1);
1238
1239			// get and keep
1240			hypothetically!({
1241				OffenceSendQueue::<Test>::get_and_maybe_delete(|page| {
1242					assert_eq!(page.len() as u32, page_size);
1243					Err(())
1244				});
1245				assert_eq!(status(), (0, vec![page_size]));
1246			});
1247
1248			// get and delete
1249			hypothetically!({
1250				OffenceSendQueue::<Test>::get_and_maybe_delete(|page| {
1251					assert_eq!(page.len() as u32, page_size);
1252					Ok(())
1253				});
1254				assert_eq!(status(), (0, vec![]));
1255			});
1256
1257			// -------- when more than 1 page full
1258			OffenceSendQueue::<Test>::append(o.clone());
1259			assert_eq!(status(), (1, vec![page_size, 1]));
1260			assert_eq!(OffenceSendQueue::<Test>::count(), page_size + 1);
1261			assert_eq!(OffenceSendQueue::<Test>::pages(), 2);
1262
1263			// get and keep
1264			hypothetically!({
1265				OffenceSendQueue::<Test>::get_and_maybe_delete(|page| {
1266					assert_eq!(page.len(), 1);
1267					Err(())
1268				});
1269				assert_eq!(status(), (1, vec![page_size, 1]));
1270			});
1271
1272			// get and delete
1273			hypothetically!({
1274				OffenceSendQueue::<Test>::get_and_maybe_delete(|page| {
1275					assert_eq!(page.len(), 1);
1276					Ok(())
1277				});
1278				assert_eq!(status(), (0, vec![page_size]));
1279			});
1280		})
1281	}
1282}