referrerpolicy=no-referrer-when-downgrade

pallet_staking_async_rc_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 the relay chain, intended to be used in AssetHub.
19//!
20//! The counter-part for this pallet is `pallet-staking-async-ah-client` on the relay chain.
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 the
31//! relay chain origin, as per [`Config::RelayChainOrigin`].
32//!
33//! After potential queuing, they are passed to pallet-staking-async via [`AHStakingInterface`].
34//!
35//! The calls are:
36//!
37//! * [`Call::relay_session_report`]: A report from the relay chain, indicating the end of a
38//!   session. We allow ourselves to know an implementation detail: **The ending of session `x`
39//!   always implies start of session `x+1` and planning of session `x+2`.** This allows us to have
40//!   just one message per session.
41//!
42//! > Note that in the code, due to historical reasons, planning of a new session is called
43//! > `new_session`.
44//!
45//! * [`Call::relay_new_offence_paged`]: A report of one or more offences on the relay chain.
46//!
47//! ## Outgoing Messages
48//!
49//! The outgoing messages are expressed in [`SendToRelayChain`].
50//!
51//! ## Local Interfaces
52//!
53//! Within this pallet, we need to talk to the staking-async pallet in AH. This is done via
54//! [`AHStakingInterface`] trait.
55//!
56//! The staking pallet in AH has no communication with session pallet whatsoever, therefore its
57//! implementation of `SessionManager`, and it associated type `SessionInterface` no longer exists.
58//! Moreover, pallet-staking-async no longer has a notion of timestamp locally, and only relies in
59//! the timestamp passed in in the `SessionReport`.
60//!
61//! ## Shared Types
62//!
63//! Note that a number of types need to be shared between this crate and `ah-client`. For now, as a
64//! convention, they are kept in this crate. This can later be decoupled into a shared crate, or
65//! `sp-staking`.
66//!
67//! TODO: the rest should go to staking-async docs.
68//!
69//! ## Session Change
70//!
71//! Further details of how the session change works follows. These details are important to how
72//! `pallet-staking-async` should rotate sessions/eras going forward.
73//!
74//! ### Synchronous Model
75//!
76//! Let's first consider the old school model, when staking and session lived in the same runtime.
77//! Assume 3 sessions is one era.
78//!
79//! The session pallet issues the following events:
80//!
81//! end_session / start_session / new_session (plan session)
82//!
83//! * end 0, start 1, plan 2
84//! * end 1, start 2, plan 3 (new validator set returned)
85//! * end 2, start 3 (new validator set activated), plan 4
86//! * end 3, start 4, plan 5
87//! * end 4, start 5, plan 6 (ah-client to already return validator set) and so on.
88//!
89//! Staking should then do the following:
90//!
91//! * once a request to plan session 3 comes in, it must return a validator set. This is queued
92//!   internally in the session pallet, and is enacted later.
93//! * at the same time, staking increases its notion of `current_era` by 1. Yet, `active_era` is
94//!   intact. This is because the validator elected for era n+1 are not yet active in the session
95//!   pallet.
96//! * once a request to _start_ session 3 comes in, staking will rotate its `active_era` to also be
97//!   incremented to n+1.
98//!
99//! ### Asynchronous Model
100//!
101//! Now, if staking lives in AH and the session pallet lives in the relay chain, how will this look
102//! like?
103//!
104//! Staking knows that by the time the relay-chain session index `3` (and later on `6` and so on) is
105//! _planned_, it must have already returned a validator set via XCM.
106//!
107//! conceptually, staking must:
108//!
109//! - listen to the [`SessionReport`]s coming in, and start a new staking election such that we can
110//!   be sure it is delivered to the RC well before the the message for planning session 3 received.
111//! - Staking should know that, regardless of the timing, these validators correspond to session 3,
112//!   and an upcoming era.
113//! - Staking will keep these pending validators internally within its state.
114//! - Once the message to start session 3 is received, staking will act upon it locally.
115
116#![cfg_attr(not(feature = "std"), no_std)]
117
118extern crate alloc;
119
120#[cfg(feature = "runtime-benchmarks")]
121pub mod benchmarking;
122pub mod weights;
123
124#[cfg(feature = "xcm-sender")]
125use alloc::vec;
126use alloc::vec::Vec;
127use codec::Decode;
128#[cfg(feature = "xcm-sender")]
129use core::fmt::Display;
130#[cfg(feature = "xcm-sender")]
131use frame_support::storage::transactional::with_transaction_opaque_err;
132use frame_support::{
133	pallet_prelude::*,
134	traits::fungible::{
135		hold::{Inspect as HoldInspect, Mutate as HoldMutate},
136		Inspect as FunInspect, Mutate as FunMutate,
137	},
138	weights::Weight,
139};
140#[cfg(feature = "xcm-sender")]
141use sp_runtime::{traits::Convert, TransactionOutcome};
142use sp_runtime::{traits::OpaqueKeys, Perbill};
143use sp_staking::SessionIndex;
144// XCM imports are only used by the optional XCMSender helper struct for runtimes, not by the
145// pallet's public API. The pallet only uses the abstract SendToRelayChain trait.
146//
147// TODO: Consider relocating `staking-async` pallets to `polkadot/pallets/` or
148// `cumulus/pallets/`. These pallets are Polkadot-specific (AHโ†”RC communication) and leak XCM
149// types into FRAME, which historically has been chain-agnostic. Alternatively, the `XCMSender`
150// helper could be moved to runtime level, keeping this pallet XCM-agnostic through the
151// `SendToRelayChain` trait abstraction.
152#[cfg(feature = "xcm-sender")]
153use xcm::latest::{
154	send_xcm, validate_send, ExecuteXcm, Fungibility::Fungible, Instruction, Junction, Location,
155	OriginKind, SendError, SendXcm, WeightLimit, Xcm,
156};
157
158/// Builds an XCM message with `UnpaidExecution` + `Transact` from an encoded call, dispatched
159/// with `origin_kind`.
160///
161/// This is the standard pattern for messages from a system parachain to the relay chain or to a
162/// sibling system parachain, where the destination trusts the origin and doesn't charge fees.
163#[cfg(feature = "xcm-sender")]
164pub fn build_transact_xcm(encoded_call: Vec<u8>, origin_kind: OriginKind) -> Xcm<()> {
165	Xcm(vec![
166		Instruction::UnpaidExecution { weight_limit: WeightLimit::Unlimited, check_origin: None },
167		Instruction::Transact { origin_kind, fallback_max_weight: None, call: encoded_call.into() },
168	])
169}
170
171/// Converts an `AccountId32`-compatible account to an XCM `Location`.
172///
173/// Use this as the `AccountToLoc` parameter for [`XCMSender::send_with_fees`].
174#[cfg(feature = "xcm-sender")]
175pub struct AccountId32ToLocation;
176
177#[cfg(feature = "xcm-sender")]
178impl<AccountId: Into<[u8; 32]>> Convert<AccountId, Location> for AccountId32ToLocation {
179	fn convert(account: AccountId) -> Location {
180		Junction::AccountId32 { network: None, id: account.into() }.into()
181	}
182}
183
184/// Export everything needed for the pallet to be used in the runtime.
185pub use pallet::*;
186pub use weights::WeightInfo;
187
188/// Type alias for balance used in this pallet.
189pub type BalanceOf<T> = <<T as pallet::Config>::Currency as FunInspect<
190	<T as frame_system::Config>::AccountId,
191>>::Balance;
192
193const LOG_TARGET: &str = "runtime::staking-async::rc-client";
194
195// syntactic sugar for logging.
196#[macro_export]
197macro_rules! log {
198	($level:tt, $patter:expr $(, $values:expr)* $(,)?) => {
199		log::$level!(
200			target: $crate::LOG_TARGET,
201			concat!("[{:?}] โฌ†๏ธ ", $patter), <frame_system::Pallet<T>>::block_number() $(, $values)*
202		)
203	};
204}
205
206/// Detailed errors for message send operations.
207#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, DecodeWithMemTracking, TypeInfo)]
208pub enum SendOperationError {
209	/// Failed to validate the message before sending.
210	ValidationFailed,
211	/// Failed to charge delivery fees from the payer.
212	ChargeFeesFailed,
213	/// Failed to deliver the message to the relay chain.
214	DeliveryFailed,
215}
216
217/// Error type for [`SendToRelayChain`] operations.
218#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, DecodeWithMemTracking, TypeInfo)]
219pub enum SendKeysError<Balance> {
220	/// Message send operation failed.
221	Send(SendOperationError),
222	/// Delivery fees exceeded the specified maximum.
223	FeesExceededMax {
224		/// The required fee amount.
225		required: Balance,
226		/// The maximum fee the user was willing to pay.
227		max: Balance,
228	},
229}
230
231/// The communication trait of `pallet-staking-async-rc-client` -> `relay-chain`.
232///
233/// This trait should only encapsulate our _outgoing_ communication to the RC. Any incoming
234/// communication comes it directly via our calls.
235///
236/// In a real runtime, this is implemented via XCM calls, much like how the core-time pallet works.
237/// In a test runtime, it can be wired to direct function calls.
238///
239/// Note: This trait intentionally avoids XCM types in its signature to keep the pallet
240/// XCM-agnostic. The implementation details (XCM, direct calls, etc.) are left to the runtime.
241pub trait SendToRelayChain {
242	/// The validator account ids.
243	type AccountId;
244
245	/// The balance type used for fee limits and reporting.
246	type Balance: Parameter + Member + Copy;
247
248	/// Send a new validator set report to relay chain.
249	#[allow(clippy::result_unit_err)]
250	fn validator_set(report: ValidatorSetReport<Self::AccountId>) -> Result<(), ()>;
251
252	/// Send session keys to relay chain for registration.
253	///
254	/// The keys are forwarded to `pallet-staking-async-ah-client::set_keys_from_ah` on the RC.
255	/// Note: proof is validated on AH side, so only validated keys are sent.
256	///
257	/// The relay chain uses `UnpaidExecution`, so no fees are charged there. Instead, the total
258	/// fee (delivery + remote execution cost) is charged on AssetHub.
259	///
260	/// - `stash`: The validator stash account.
261	/// - `keys`: The encoded session keys.
262	/// - `max_delivery_and_remote_execution_fee`: Optional maximum total fee the user is willing to
263	///   pay. This includes both the XCM delivery fee and the remote execution cost. If the actual
264	///   total fee exceeds this, the operation fails with [`SendKeysError::FeesExceededMax`]. Pass
265	///   `None` for unlimited (no cap).
266	///
267	/// Returns the total fees charged on success (delivery + execution).
268	fn set_keys(
269		stash: Self::AccountId,
270		keys: Vec<u8>,
271		max_delivery_and_remote_execution_fee: Option<Self::Balance>,
272	) -> Result<Self::Balance, SendKeysError<Self::Balance>>;
273
274	/// Send a request to purge session keys on the relay chain.
275	///
276	/// The request is forwarded to `pallet-staking-async-ah-client::purge_keys_from_ah` on the RC.
277	///
278	/// The relay chain uses `UnpaidExecution`, so no fees are charged there. Instead, the total
279	/// fee (delivery + remote execution cost) is charged on AssetHub.
280	///
281	/// - `stash`: The validator stash account.
282	/// - `max_delivery_and_remote_execution_fee`: Optional maximum total fee the user is willing to
283	///   pay. This includes both the XCM delivery fee and the remote execution cost. If the actual
284	///   total fee exceeds this, the operation fails with [`SendKeysError::FeesExceededMax`]. Pass
285	///   `None` for unlimited (no cap).
286	///
287	/// Returns the total fees charged on success (delivery + execution).
288	fn purge_keys(
289		stash: Self::AccountId,
290		max_delivery_and_remote_execution_fee: Option<Self::Balance>,
291	) -> Result<Self::Balance, SendKeysError<Self::Balance>>;
292}
293
294#[cfg(feature = "std")]
295impl SendToRelayChain for () {
296	type AccountId = u64;
297	type Balance = u128;
298	fn validator_set(_report: ValidatorSetReport<Self::AccountId>) -> Result<(), ()> {
299		unimplemented!();
300	}
301	fn set_keys(
302		_stash: Self::AccountId,
303		_keys: Vec<u8>,
304		_max_delivery_and_remote_execution_fee: Option<Self::Balance>,
305	) -> Result<Self::Balance, SendKeysError<Self::Balance>> {
306		unimplemented!();
307	}
308	fn purge_keys(
309		_stash: Self::AccountId,
310		_max_delivery_and_remote_execution_fee: Option<Self::Balance>,
311	) -> Result<Self::Balance, SendKeysError<Self::Balance>> {
312		unimplemented!();
313	}
314}
315
316/// The interface to communicate to asset hub.
317///
318/// This trait should only encapsulate our outgoing communications. Any incoming message is handled
319/// with `Call`s.
320///
321/// In a real runtime, this is implemented via XCM calls, much like how the coretime pallet works.
322/// In a test runtime, it can be wired to direct function call.
323pub trait SendToAssetHub {
324	/// The validator account ids.
325	type AccountId;
326
327	/// Report a session change to AssetHub.
328	///
329	/// Returning `Err(())` means the DMP queue is full, and you should try again in the next block.
330	#[allow(clippy::result_unit_err)]
331	fn relay_session_report(session_report: SessionReport<Self::AccountId>) -> Result<(), ()>;
332
333	#[allow(clippy::result_unit_err)]
334	fn relay_new_offence_paged(
335		offences: Vec<(SessionIndex, Offence<Self::AccountId>)>,
336	) -> Result<(), ()>;
337}
338
339/// A no-op implementation of [`SendToAssetHub`].
340#[cfg(feature = "std")]
341impl SendToAssetHub for () {
342	type AccountId = u64;
343
344	fn relay_session_report(_session_report: SessionReport<Self::AccountId>) -> Result<(), ()> {
345		unimplemented!();
346	}
347
348	fn relay_new_offence_paged(
349		_offences: Vec<(SessionIndex, Offence<Self::AccountId>)>,
350	) -> Result<(), ()> {
351		unimplemented!()
352	}
353}
354
355#[derive(Encode, Decode, DecodeWithMemTracking, Clone, PartialEq, TypeInfo)]
356/// A report about a new validator set. This is sent from AH -> RC.
357pub struct ValidatorSetReport<AccountId> {
358	/// The new validator set.
359	pub new_validator_set: Vec<AccountId>,
360	/// The id of this validator set.
361	///
362	/// Is an always incrementing identifier for this validator set, the activation of which can be
363	/// later pointed to in a `SessionReport`.
364	///
365	/// Implementation detail: within `pallet-staking-async`, this is always set to the
366	/// `planning-era` (aka. `CurrentEra`).
367	pub id: u32,
368	/// Signal the relay chain that it can prune up to this session, and enough eras have passed.
369	///
370	/// This can always have a safety buffer. For example, whatever is a sane value, it can be
371	/// `value - 5`.
372	pub prune_up_to: Option<SessionIndex>,
373	/// Same semantics as [`SessionReport::leftover`].
374	pub leftover: bool,
375}
376
377impl<AccountId: core::fmt::Debug> core::fmt::Debug for ValidatorSetReport<AccountId> {
378	fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
379		f.debug_struct("ValidatorSetReport")
380			.field("new_validator_set", &self.new_validator_set)
381			.field("id", &self.id)
382			.field("prune_up_to", &self.prune_up_to)
383			.field("leftover", &self.leftover)
384			.finish()
385	}
386}
387
388impl<AccountId> core::fmt::Display for ValidatorSetReport<AccountId> {
389	fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
390		f.debug_struct("ValidatorSetReport")
391			.field("new_validator_set", &self.new_validator_set.len())
392			.field("id", &self.id)
393			.field("prune_up_to", &self.prune_up_to)
394			.field("leftover", &self.leftover)
395			.finish()
396	}
397}
398
399impl<AccountId> ValidatorSetReport<AccountId> {
400	/// A new instance of self that is terminal. This is useful when we want to send everything in
401	/// one go.
402	pub fn new_terminal(
403		new_validator_set: Vec<AccountId>,
404		id: u32,
405		prune_up_to: Option<SessionIndex>,
406	) -> Self {
407		Self { new_validator_set, id, prune_up_to, leftover: false }
408	}
409
410	/// Merge oneself with another instance.
411	pub fn merge(mut self, other: Self) -> Result<Self, UnexpectedKind> {
412		if self.id != other.id || self.prune_up_to != other.prune_up_to {
413			// Must be some bug -- don't merge.
414			return Err(UnexpectedKind::ValidatorSetIntegrityFailed);
415		}
416		self.new_validator_set.extend(other.new_validator_set);
417		self.leftover = other.leftover;
418		Ok(self)
419	}
420
421	/// Split self into chunks of `chunk_size` element.
422	pub fn split(self, chunk_size: usize) -> Vec<Self>
423	where
424		AccountId: Clone,
425	{
426		let splitted_points = self.new_validator_set.chunks(chunk_size.max(1)).map(|x| x.to_vec());
427		let mut parts = splitted_points
428			.into_iter()
429			.map(|new_validator_set| Self { new_validator_set, leftover: true, ..self })
430			.collect::<Vec<_>>();
431		if let Some(x) = parts.last_mut() {
432			x.leftover = false
433		}
434		parts
435	}
436}
437
438/// Message for session keys operations (set or purge) sent from AH -> RC.
439///
440/// This type is shared between `rc-client` (AssetHub) and `ah-client` (RelayChain).
441/// The proof is validated on AH side, so only validated keys are sent to RC.
442#[derive(Encode, Decode, DecodeWithMemTracking, Clone, PartialEq, Debug, TypeInfo)]
443pub enum KeysMessage<AccountId> {
444	/// Set session keys for a validator.
445	SetKeys {
446		/// The validator stash account.
447		stash: AccountId,
448		/// The encoded session keys.
449		keys: Vec<u8>,
450	},
451	/// Purge session keys for a validator.
452	PurgeKeys {
453		/// The validator stash account.
454		stash: AccountId,
455	},
456}
457
458impl<AccountId> KeysMessage<AccountId> {
459	/// Create a new SetKeys message.
460	pub fn set_keys(stash: AccountId, keys: Vec<u8>) -> Self {
461		Self::SetKeys { stash, keys }
462	}
463
464	/// Create a new PurgeKeys message.
465	pub fn purge_keys(stash: AccountId) -> Self {
466		Self::PurgeKeys { stash }
467	}
468
469	/// Get the stash account from the message.
470	pub fn stash(&self) -> &AccountId {
471		match self {
472			Self::SetKeys { stash, .. } | Self::PurgeKeys { stash } => stash,
473		}
474	}
475}
476
477#[derive(Encode, Decode, DecodeWithMemTracking, Clone, PartialEq, TypeInfo, MaxEncodedLen)]
478/// The information that is sent from RC -> AH on session end.
479pub struct SessionReport<AccountId> {
480	/// The session that is ending.
481	///
482	/// This always implies start of `end_index + 1`, and planning of `end_index + 2`.
483	pub end_index: SessionIndex,
484	/// All of the points that validators have accumulated.
485	///
486	/// This can be either from block authoring, or from parachain consensus, or anything else.
487	pub validator_points: Vec<(AccountId, u32)>,
488	/// If none, it means no new validator set was activated as a part of this session.
489	///
490	/// If `Some((timestamp, id))`, it means that the new validator set was activated at the given
491	/// timestamp, and the id of the validator set is `id`.
492	///
493	/// This `id` is what was previously communicated to the RC as a part of
494	/// [`ValidatorSetReport::id`].
495	pub activation_timestamp: Option<(u64, u32)>,
496	/// If this session report is self-contained, then it is false.
497	///
498	/// If this session report has some leftover, it should not be acted upon until a subsequent
499	/// message with `leftover = true` comes in. The client pallets should handle this queuing.
500	///
501	/// This is in place to future proof us against possibly needing to send multiple rounds of
502	/// messages to convey all of the `validator_points`.
503	///
504	/// Upon processing, this should always be true, and it should be ignored.
505	pub leftover: bool,
506}
507
508impl<AccountId: core::fmt::Debug> core::fmt::Debug for SessionReport<AccountId> {
509	fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
510		f.debug_struct("SessionReport")
511			.field("end_index", &self.end_index)
512			.field("validator_points", &self.validator_points)
513			.field("activation_timestamp", &self.activation_timestamp)
514			.field("leftover", &self.leftover)
515			.finish()
516	}
517}
518
519impl<AccountId> core::fmt::Display for SessionReport<AccountId> {
520	fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
521		f.debug_struct("SessionReport")
522			.field("end_index", &self.end_index)
523			.field("validator_points", &self.validator_points.len())
524			.field("activation_timestamp", &self.activation_timestamp)
525			.field("leftover", &self.leftover)
526			.finish()
527	}
528}
529
530impl<AccountId> SessionReport<AccountId> {
531	/// A new instance of self that is terminal. This is useful when we want to send everything in
532	/// one go.
533	pub fn new_terminal(
534		end_index: SessionIndex,
535		validator_points: Vec<(AccountId, u32)>,
536		activation_timestamp: Option<(u64, u32)>,
537	) -> Self {
538		Self { end_index, validator_points, activation_timestamp, leftover: false }
539	}
540
541	/// Merge oneself with another instance.
542	pub fn merge(mut self, other: Self) -> Result<Self, UnexpectedKind> {
543		if self.end_index != other.end_index ||
544			self.activation_timestamp != other.activation_timestamp
545		{
546			// Must be some bug -- don't merge.
547			return Err(UnexpectedKind::SessionReportIntegrityFailed);
548		}
549		self.validator_points.extend(other.validator_points);
550		self.leftover = other.leftover;
551		Ok(self)
552	}
553
554	/// Split oneself into `count` number of pieces.
555	pub fn split(self, chunk_size: usize) -> Vec<Self>
556	where
557		AccountId: Clone,
558	{
559		let splitted_points = self.validator_points.chunks(chunk_size.max(1)).map(|x| x.to_vec());
560		let mut parts = splitted_points
561			.into_iter()
562			.map(|validator_points| Self { validator_points, leftover: true, ..self })
563			.collect::<Vec<_>>();
564		if let Some(x) = parts.last_mut() {
565			x.leftover = false
566		}
567		parts
568	}
569}
570
571/// A trait to encapsulate messages between RC and AH that can be splitted into smaller chunks.
572///
573/// Implemented for [`SessionReport`] and [`ValidatorSetReport`].
574#[allow(clippy::len_without_is_empty)]
575pub trait SplittableMessage: Sized {
576	/// Split yourself into pieces of `chunk_size` size.
577	fn split_by(self, chunk_size: usize) -> Vec<Self>;
578
579	/// Current length of the message.
580	fn len(&self) -> usize;
581}
582
583impl<AccountId: Clone> SplittableMessage for SessionReport<AccountId> {
584	fn split_by(self, chunk_size: usize) -> Vec<Self> {
585		self.split(chunk_size)
586	}
587	fn len(&self) -> usize {
588		self.validator_points.len()
589	}
590}
591
592impl<AccountId: Clone> SplittableMessage for ValidatorSetReport<AccountId> {
593	fn split_by(self, chunk_size: usize) -> Vec<Self> {
594		self.split(chunk_size)
595	}
596	fn len(&self) -> usize {
597		self.new_validator_set.len()
598	}
599}
600
601/// Common utility to send XCM messages that can use [`SplittableMessage`].
602///
603/// It can be used both in the RC and AH. `Message` is the splittable message type, and `ToXcm`
604/// should be configured by the user, converting `message` to a valida `Xcm<()>`. It should utilize
605/// the correct call indices, which we only know at the runtime level.
606// NOTE: to have the pallet fully XCM-agnostic, XCMSender should be moved out (to a new or existing
607// XCM helper crate or to runtimes crates directly)
608#[cfg(feature = "xcm-sender")]
609pub struct XCMSender<Sender, Destination, Message, ToXcm>(
610	core::marker::PhantomData<(Sender, Destination, Message, ToXcm)>,
611);
612
613#[cfg(feature = "xcm-sender")]
614impl<Sender, Destination, Message, ToXcm> XCMSender<Sender, Destination, Message, ToXcm>
615where
616	Sender: SendXcm,
617	Destination: Get<Location>,
618	Message: Clone + Encode,
619	ToXcm: Convert<Message, Xcm<()>>,
620{
621	/// Send the message single-shot; no splitting.
622	///
623	/// Useful for sending messages that are already paged/chunked, so we are sure that they fit in
624	/// one message.
625	#[allow(clippy::result_unit_err)]
626	pub fn send(message: Message) -> Result<(), ()> {
627		let xcm = ToXcm::convert(message);
628		let dest = Destination::get();
629		// send_xcm already calls validate internally
630		send_xcm::<Sender>(dest, xcm).map(|_| ()).map_err(|_| ())
631	}
632
633	/// Send the message with fee charging and optional max fee limit.
634	///
635	/// This method validates the XCM message first, calculates the total fee (delivery +
636	/// execution), optionally checks if the total exceeds the specified maximum, charges
637	/// the total from the payer, and then delivers the message.
638	///
639	/// The relay chain uses `UnpaidExecution`, so no fees are charged there. Instead, the
640	/// total cost (delivery + remote execution) is charged upfront on AssetHub.
641	///
642	/// - `message`: The message to send
643	/// - `payer`: The account paying fees
644	/// - `max_delivery_and_remote_execution_fee`: Optional maximum total fee the user is willing to
645	///   pay
646	/// - `execution_cost`: The relay chain execution cost to include in the total
647	///
648	/// Generic parameters:
649	/// - `XcmExec`: The XCM executor that implements `charge_fees`
650	/// - `Call`: The runtime call type (used by XcmExec)
651	/// - `AccountId`: The account identifier type
652	/// - `AccountToLoc`: Converter from AccountId to XCM Location
653	/// - `Balance`: The balance type for fee limits
654	///
655	/// Returns the total fees charged on success (delivery + execution).
656	pub fn send_with_fees<XcmExec, Call, AccountId, AccountToLoc, Balance>(
657		message: Message,
658		payer: AccountId,
659		max_delivery_and_remote_execution_fee: Option<Balance>,
660		execution_cost: Balance,
661	) -> Result<Balance, SendKeysError<Balance>>
662	where
663		XcmExec: ExecuteXcm<Call>,
664		AccountToLoc: Convert<AccountId, Location>,
665		Balance: TryFrom<u128>
666			+ Into<u128>
667			+ PartialOrd
668			+ Copy
669			+ Default
670			+ core::ops::Add<Output = Balance>,
671	{
672		let payer_location = AccountToLoc::convert(payer);
673		let xcm = ToXcm::convert(message);
674		let dest = Destination::get();
675
676		let (ticket, price) = validate_send::<Sender>(dest, xcm).map_err(|e| {
677			log::error!(target: LOG_TARGET, "Failed to validate XCM: {:?}", e);
678			SendKeysError::Send(SendOperationError::ValidationFailed)
679		})?;
680
681		// Extract the delivery fee asset from the price.
682		//
683		// For parachainโ†’relay chain messages, delivery fees are returned as a single
684		// fungible asset. This is based on `ExponentialPrice::price_for_delivery` in
685		// `polkadot/runtime/common/src/xcm_sender.rs` which returns `(AssetId, amount).into()`,
686		// converting to a single-element `Assets` via `impl<T: Into<Asset>> From<T> for Assets`.
687		let fee_asset = price.inner().first().ok_or_else(|| {
688			log::error!(target: LOG_TARGET, "Empty price returned from validate_send");
689			SendKeysError::Send(SendOperationError::ValidationFailed)
690		})?;
691
692		let delivery_fee: Balance = match &fee_asset.fun {
693			Fungible(amount) => Balance::try_from(*amount).map_err(|_| {
694				log::error!(target: LOG_TARGET, "Failed to convert delivery fee amount");
695				SendKeysError::Send(SendOperationError::ValidationFailed)
696			})?,
697			_ => {
698				log::error!(target: LOG_TARGET, "Non-fungible fee asset not supported");
699				return Err(SendKeysError::Send(SendOperationError::ValidationFailed));
700			},
701		};
702
703		// Calculate total fee = delivery + execution
704		let total_fee = delivery_fee + execution_cost;
705
706		// Check max fee before charging
707		if let Some(max) = max_delivery_and_remote_execution_fee {
708			if total_fee > max {
709				return Err(SendKeysError::FeesExceededMax { required: total_fee, max });
710			}
711		}
712
713		// Charge the total fee from the payer using the same asset as delivery fees
714		let total_assets = xcm::latest::Assets::from(xcm::latest::Asset {
715			id: fee_asset.id.clone(),
716			fun: Fungible(total_fee.into()),
717		});
718
719		XcmExec::charge_fees(payer_location, total_assets).map_err(|e| {
720			log::error!(target: LOG_TARGET, "Failed to charge fees: {:?}", e);
721			SendKeysError::Send(SendOperationError::ChargeFeesFailed)
722		})?;
723
724		Sender::deliver(ticket).map_err(|e| {
725			log::error!(target: LOG_TARGET, "Failed to deliver XCM: {:?}", e);
726			SendKeysError::Send(SendOperationError::DeliveryFailed)
727		})?;
728
729		Ok(total_fee)
730	}
731}
732
733#[cfg(feature = "xcm-sender")]
734impl<Sender, Destination, Message, ToXcm> XCMSender<Sender, Destination, Message, ToXcm>
735where
736	Sender: SendXcm,
737	Destination: Get<Location>,
738	Message: SplittableMessage + Display + Clone + Encode,
739	ToXcm: Convert<Message, Xcm<()>>,
740{
741	/// Safe send method to send a `message`, while validating it and using [`SplittableMessage`] to
742	/// split it into smaller pieces if XCM validation fails with `ExceedsMaxMessageSize`. It will
743	/// fail on other errors.
744	///
745	/// Returns `Ok()` if the message was sent using `XCM`, potentially with splitting up to
746	/// `maybe_max_step` times, `Err(())` otherwise.
747	#[deprecated(
748		note = "all staking related VMP messages should fit the single message limits. Should not be used."
749	)]
750	#[allow(clippy::result_unit_err)]
751	pub fn split_then_send(message: Message, maybe_max_steps: Option<u32>) -> Result<(), ()> {
752		let message_type_name = core::any::type_name::<Message>();
753		let dest = Destination::get();
754		let xcms = Self::prepare(message, maybe_max_steps).map_err(|e| {
755			log::error!(target: "runtime::staking-async::rc-client", "๐Ÿ“จ Failed to split message {}: {:?}", message_type_name, e);
756		})?;
757
758		match with_transaction_opaque_err(|| {
759			let all_sent = xcms.into_iter().enumerate().try_for_each(|(idx, xcm)| {
760				log::debug!(target: "runtime::staking-async::rc-client", "๐Ÿ“จ sending {} message index {}, size: {:?}", message_type_name, idx, xcm.encoded_size());
761				send_xcm::<Sender>(dest.clone(), xcm).map(|_| {
762					log::debug!(target: "runtime::staking-async::rc-client", "๐Ÿ“จ Successfully sent {} message part {} to relay chain", message_type_name,  idx);
763				}).inspect_err(|e| {
764					log::error!(target: "runtime::staking-async::rc-client", "๐Ÿ“จ Failed to send {} message to relay chain: {:?}", message_type_name, e);
765				})
766			});
767
768			match all_sent {
769				Ok(()) => TransactionOutcome::Commit(Ok(())),
770				Err(send_err) => TransactionOutcome::Rollback(Err(send_err)),
771			}
772		}) {
773			// just like https://doc.rust-lang.org/src/core/result.rs.html#1746 which I cannot use yet because not in 1.89
774			Ok(inner) => inner.map_err(|_| ()),
775			// unreachable; `with_transaction_opaque_err` always returns `Ok(inner)`
776			Err(_) => Err(()),
777		}
778	}
779
780	fn prepare(message: Message, maybe_max_steps: Option<u32>) -> Result<Vec<Xcm<()>>, SendError> {
781		// initial chunk size is the entire thing, so it will be a vector of 1 item.
782		let mut chunk_size = message.len();
783		let mut steps = 0;
784
785		loop {
786			let current_messages = message.clone().split_by(chunk_size);
787
788			// the first message is the heaviest, the last one might be smaller.
789			let first_message = if let Some(r) = current_messages.first() {
790				r
791			} else {
792				log::debug!(target: "runtime::staking-async::xcm", "๐Ÿ“จ unexpected: no messages to send");
793				return Ok(vec![]);
794			};
795
796			log::debug!(
797				target: "runtime::staking-async::xcm",
798				"๐Ÿ“จ step: {:?}, chunk_size: {:?}, message_size: {:?}",
799				steps,
800				chunk_size,
801				first_message.encoded_size(),
802			);
803
804			let first_xcm = ToXcm::convert(first_message.clone());
805			match <Sender as SendXcm>::validate(&mut Some(Destination::get()), &mut Some(first_xcm))
806			{
807				Ok((_ticket, price)) => {
808					log::debug!(target: "runtime::staking-async::xcm", "๐Ÿ“จ validated, price: {:?}", price);
809					return Ok(current_messages
810						.into_iter()
811						.map(ToXcm::convert)
812						.collect::<Vec<_>>());
813				},
814				Err(SendError::ExceedsMaxMessageSize) => {
815					log::debug!(target: "runtime::staking-async::xcm", "๐Ÿ“จ ExceedsMaxMessageSize -- reducing chunk_size");
816					chunk_size = chunk_size.saturating_div(2);
817					steps += 1;
818					if maybe_max_steps.is_some_and(|max_steps| steps > max_steps) ||
819						chunk_size.is_zero()
820					{
821						log::error!(target: "runtime::staking-async::xcm", "๐Ÿ“จ Exceeded max steps or chunk_size = 0");
822						return Err(SendError::ExceedsMaxMessageSize);
823					} else {
824						// try again with the new `chunk_size`
825						continue;
826					}
827				},
828				Err(other) => {
829					log::error!(target: "runtime::staking-async::xcm", "๐Ÿ“จ other error -- cannot send XCM: {:?}", other);
830					return Err(other);
831				},
832			}
833		}
834	}
835}
836
837/// Our communication trait of `pallet-staking-async-rc-client` -> `pallet-staking-async`.
838///
839/// This is merely a shorthand to avoid tightly-coupling the staking pallet to this pallet. It
840/// limits what we can say to `pallet-staking-async` to only these functions.
841pub trait AHStakingInterface {
842	/// The validator account id type.
843	type AccountId;
844	/// Maximum number of validators that the staking system may have.
845	type MaxValidatorSet: Get<u32>;
846
847	/// New session report from the relay chain.
848	fn on_relay_session_report(report: SessionReport<Self::AccountId>) -> Weight;
849
850	/// Return the weight of `on_relay_session_report` call without executing it.
851	///
852	/// This will return the worst case estimate of the weight. The actual execution will return the
853	/// accurate amount.
854	fn weigh_on_relay_session_report(report: &SessionReport<Self::AccountId>) -> Weight;
855
856	/// Report one or more offences on the relay chain.
857	fn on_new_offences(
858		slash_session: SessionIndex,
859		offences: Vec<Offence<Self::AccountId>>,
860	) -> Weight;
861
862	/// Return the weight of `on_new_offences` call without executing it.
863	///
864	/// This will return the worst case estimate of the weight. The actual execution will return the
865	/// accurate amount.
866	fn weigh_on_new_offences(offence_count: u32) -> Weight;
867
868	/// Get the active era's start session index.
869	///
870	/// Returns the first session index of the currently active era.
871	fn active_era_start_session_index() -> SessionIndex;
872
873	/// Check if an account is a registered validator.
874	///
875	/// Returns true if the account has called `validate()` and is in the `Validators` storage.
876	fn is_validator(who: &Self::AccountId) -> bool;
877}
878
879/// The communication trait of `pallet-staking-async` -> `pallet-staking-async-rc-client`.
880pub trait RcClientInterface {
881	/// The validator account ids.
882	type AccountId;
883
884	/// Report a new validator set.
885	fn validator_set(new_validator_set: Vec<Self::AccountId>, id: u32, prune_up_tp: Option<u32>);
886}
887
888/// An offence on the relay chain. Based on [`sp_staking::offence::OffenceDetails`].
889#[derive(Encode, Decode, DecodeWithMemTracking, Debug, Clone, PartialEq, TypeInfo)]
890pub struct Offence<AccountId> {
891	/// The offender.
892	pub offender: AccountId,
893	/// Those who have reported this offence.
894	pub reporters: Vec<AccountId>,
895	/// The amount that they should be slashed.
896	pub slash_fraction: Perbill,
897}
898
899#[frame_support::pallet]
900pub mod pallet {
901	use super::*;
902	use frame_system::pallet_prelude::{BlockNumberFor, *};
903
904	/// The in-code storage version.
905	const STORAGE_VERSION: StorageVersion = StorageVersion::new(1);
906
907	/// Reasons for holding funds.
908	#[pallet::composite_enum]
909	pub enum HoldReason {
910		/// Deposit held for session keys.
911		#[codec(index = 0)]
912		Keys,
913	}
914
915	/// An incomplete incoming session report that we have not acted upon yet.
916	// Note: this can remain unbounded, as the internals of `AHStakingInterface` is benchmarked, and
917	// is worst case.
918	#[pallet::storage]
919	#[pallet::unbounded]
920	pub type IncompleteSessionReport<T: Config> =
921		StorageValue<_, SessionReport<T::AccountId>, OptionQuery>;
922
923	/// The last session report's `end_index` that we have acted upon.
924	///
925	/// This allows this pallet to ensure a sequentially increasing sequence of session reports
926	/// passed to staking.
927	///
928	/// Note that with the XCM being the backbone of communication, we have a guarantee on the
929	/// ordering of messages. As long as the RC sends session reports in order, we _eventually_
930	/// receive them in the same correct order as well.
931	#[pallet::storage]
932	pub type LastSessionReportEndingIndex<T: Config> = StorageValue<_, SessionIndex, OptionQuery>;
933
934	/// A validator set that is outgoing, and should be sent.
935	///
936	/// This will be attempted to be sent, possibly on every `on_initialize` call, until it is sent,
937	/// or the second value reaches zero, at which point we drop it.
938	#[pallet::storage]
939	// TODO: for now we know this ValidatorSetReport is at most validator-count * 32, and we don't
940	// need its MEL critically.
941	#[pallet::unbounded]
942	pub type OutgoingValidatorSet<T: Config> =
943		StorageValue<_, (ValidatorSetReport<T::AccountId>, u32), OptionQuery>;
944
945	#[pallet::pallet]
946	#[pallet::storage_version(STORAGE_VERSION)]
947	pub struct Pallet<T>(_);
948
949	#[pallet::hooks]
950	impl<T: Config> Hooks<BlockNumberFor<T>> for Pallet<T> {
951		fn on_initialize(_: BlockNumberFor<T>) -> Weight {
952			let mut weight = T::DbWeight::get().reads(1);
953
954			// Early return if no validator set to export
955			if !OutgoingValidatorSet::<T>::exists() {
956				return weight;
957			}
958
959			// Determine if we should export based on session offset
960			let should_export = if T::ValidatorSetExportSession::get() == 0 {
961				// Immediate export mode
962				true
963			} else {
964				// Check if we've reached the target session offset
965				weight.saturating_accrue(T::DbWeight::get().reads(2));
966
967				let last_session_end = LastSessionReportEndingIndex::<T>::get().unwrap_or(0);
968				let last_era_ending_index =
969					T::AHStakingInterface::active_era_start_session_index().saturating_sub(1);
970				let session_offset = last_session_end.saturating_sub(last_era_ending_index);
971
972				session_offset >= T::ValidatorSetExportSession::get()
973			};
974
975			if !should_export {
976				// validator set buffered until target session offset
977				return weight;
978			}
979
980			// good time to export the latest elected validator set
981			weight.saturating_accrue(T::DbWeight::get().reads_writes(1, 1));
982			if let Some((report, retries_left)) = OutgoingValidatorSet::<T>::take() {
983				// Export the validator set
984				weight.saturating_accrue(T::DbWeight::get().writes(1));
985				match T::SendToRelayChain::validator_set(report.clone()) {
986					Ok(()) => {
987						log::debug!(
988							target: LOG_TARGET,
989							"Exported validator set to RC for Era: {}",
990							report.id,
991						);
992					},
993					Err(()) => {
994						log!(error, "Failed to send validator set report to relay chain");
995						weight.saturating_accrue(T::DbWeight::get().writes(1));
996						Self::deposit_event(Event::<T>::Unexpected(
997							UnexpectedKind::ValidatorSetSendFailed,
998						));
999
1000						if let Some(new_retries_left) = retries_left.checked_sub(One::one()) {
1001							weight.saturating_accrue(T::DbWeight::get().writes(1));
1002							OutgoingValidatorSet::<T>::put((report, new_retries_left));
1003						} else {
1004							weight.saturating_accrue(T::DbWeight::get().writes(1));
1005							Self::deposit_event(Event::<T>::Unexpected(
1006								UnexpectedKind::ValidatorSetDropped,
1007							));
1008						}
1009					},
1010				}
1011			} else {
1012				defensive!("OutgoingValidatorSet checked already, must exist.");
1013			}
1014
1015			weight
1016		}
1017	}
1018
1019	#[pallet::config]
1020	pub trait Config: frame_system::Config {
1021		/// An origin type that allows us to be sure a call is being dispatched by the relay chain.
1022		///
1023		/// It be can be configured to something like `Root` or relay chain or similar.
1024		type RelayChainOrigin: EnsureOrigin<Self::RuntimeOrigin>;
1025
1026		/// Our communication handle to the local staking pallet.
1027		type AHStakingInterface: AHStakingInterface<AccountId = Self::AccountId>;
1028
1029		/// Our communication handle to the relay chain.
1030		type SendToRelayChain: SendToRelayChain<
1031			AccountId = Self::AccountId,
1032			Balance = BalanceOf<Self>,
1033		>;
1034
1035		/// Maximum number of times that we retry sending a validator set to RC, after which, if
1036		/// sending still fails, we emit an [`UnexpectedKind::ValidatorSetDropped`] event and drop
1037		/// it.
1038		type MaxValidatorSetRetries: Get<u32>;
1039
1040		/// The end session index within an era post which we export validator set to RC.
1041		///
1042		/// This is a 1-indexed session number relative to the era start:
1043		/// - 0 = export immediately when received from staking pallet
1044		/// - 1 = export at end of first session of era
1045		/// - 5 = export at end of 5th session of era (for 6-session eras)
1046		///
1047		/// The validator set is placed in `OutgoingValidatorSet` when election completes
1048		/// in `pallet-staking-async`. The XCM message is sent when BOTH conditions met:
1049		/// 1. Current session offset >= `ValidatorSetExportSession`
1050		/// 2. `OutgoingValidatorSet` exists (validator set buffered)
1051		///
1052		/// Setting to 0 bypasses the session check and exports immediately.
1053		///
1054		/// Example: With `SessionsPerEra=6` and `ValidatorSetExportSession=4`:
1055		/// - Session 0: Election completes โ†’ validator set buffered in `OutgoingValidatorSet`
1056		/// - Sessions 1-4: Buffered (session offset < 5)
1057		/// - End of Session 4 and start of Session 5: Export triggered.
1058		///
1059		/// Must be < SessionsPerEra.
1060		type ValidatorSetExportSession: Get<SessionIndex>;
1061
1062		/// The session keys type that must match the Relay Chain's `pallet_session::Config::Keys`.
1063		///
1064		/// This is used to validate session keys on AssetHub before forwarding to RC.
1065		/// By decoding keys here, we ensure only valid data is sent via XCM, preventing
1066		/// malicious validators from bloating the XCM queue with garbage.
1067		///
1068		/// The type must implement `OpaqueKeys` for ownership proof validation and `Decode`
1069		/// to verify the keys can be properly decoded.
1070		type RelayChainSessionKeys: OpaqueKeys + Decode;
1071
1072		/// Currency used to hold key deposits.
1073		type Currency: FunMutate<Self::AccountId>
1074			+ HoldMutate<Self::AccountId, Reason: From<HoldReason>>;
1075
1076		/// Deposit held when a validator sets session keys. Released on `purge_keys`.
1077		#[pallet::constant]
1078		type KeyDeposit: Get<BalanceOf<Self>>;
1079
1080		/// Weight information for extrinsics in this pallet.
1081		type WeightInfo: WeightInfo;
1082	}
1083
1084	#[pallet::error]
1085	pub enum Error<T> {
1086		/// Failed to send XCM message to the Relay Chain.
1087		XcmSendFailed,
1088		/// The origin account is not a registered validator.
1089		///
1090		/// Only accounts that have called `validate()` can set or purge session keys. When called
1091		/// via a staking proxy, the origin is the delegating account (stash), which must be a
1092		/// registered validator.
1093		NotValidator,
1094		/// The session keys could not be decoded as the expected RelayChainSessionKeys type.
1095		InvalidKeys,
1096		/// Invalid ownership proof for the session keys.
1097		InvalidProof,
1098		/// Delivery fees exceeded the specified maximum.
1099		FeesExceededMax,
1100	}
1101
1102	#[pallet::event]
1103	#[pallet::generate_deposit(pub(crate) fn deposit_event)]
1104	pub enum Event<T: Config> {
1105		/// A said session report was received.
1106		SessionReportReceived {
1107			end_index: SessionIndex,
1108			activation_timestamp: Option<(u64, u32)>,
1109			validator_points_counts: u32,
1110			leftover: bool,
1111		},
1112		/// A new offence was reported.
1113		OffenceReceived { slash_session: SessionIndex, offences_count: u32 },
1114		/// Fees were charged for a user operation (set_keys or purge_keys).
1115		///
1116		/// The fee includes both XCM delivery fee and relay chain execution cost.
1117		FeesPaid { who: T::AccountId, fees: BalanceOf<T> },
1118		/// Something occurred that should never happen under normal operation.
1119		/// Logged as an event for fail-safe observability.
1120		Unexpected(UnexpectedKind),
1121	}
1122
1123	/// Represents unexpected or invariant-breaking conditions encountered during execution.
1124	///
1125	/// These variants are emitted as [`Event::Unexpected`] and indicate a defensive check has
1126	/// failed. While these should never occur under normal operation, they are useful for
1127	/// diagnosing issues in production or test environments.
1128	#[derive(Clone, Encode, Decode, DecodeWithMemTracking, PartialEq, TypeInfo, Debug)]
1129	pub enum UnexpectedKind {
1130		/// We could not merge the chunks, and therefore dropped the session report.
1131		SessionReportIntegrityFailed,
1132		/// We could not merge the chunks, and therefore dropped the validator set.
1133		ValidatorSetIntegrityFailed,
1134		/// The received session index is more than what we expected.
1135		SessionSkipped,
1136		/// A session in the past was received. This will not raise any errors, just emit an event
1137		/// and stop processing the report.
1138		SessionAlreadyProcessed,
1139		/// A validator set failed to be sent to RC.
1140		///
1141		/// We will store, and retry it for [`Config::MaxValidatorSetRetries`] future blocks.
1142		ValidatorSetSendFailed,
1143		/// A validator set was dropped.
1144		ValidatorSetDropped,
1145	}
1146
1147	impl<T: Config> RcClientInterface for Pallet<T> {
1148		type AccountId = T::AccountId;
1149
1150		fn validator_set(
1151			new_validator_set: Vec<Self::AccountId>,
1152			id: u32,
1153			prune_up_tp: Option<u32>,
1154		) {
1155			let report = ValidatorSetReport::new_terminal(new_validator_set, id, prune_up_tp);
1156			// just store the report to be outgoing, it will be sent in the next on-init.
1157			OutgoingValidatorSet::<T>::put((report, T::MaxValidatorSetRetries::get()));
1158		}
1159	}
1160
1161	#[pallet::call]
1162	impl<T: Config> Pallet<T> {
1163		/// Called to indicate the start of a new session on the relay chain.
1164		#[pallet::call_index(0)]
1165		#[pallet::weight(
1166			// `LastSessionReportEndingIndex`: rw
1167			// `IncompleteSessionReport`: rw
1168			T::DbWeight::get().reads_writes(2, 2) + T::AHStakingInterface::weigh_on_relay_session_report(report)
1169		)]
1170		pub fn relay_session_report(
1171			origin: OriginFor<T>,
1172			report: SessionReport<T::AccountId>,
1173		) -> DispatchResultWithPostInfo {
1174			log!(debug, "Received session report: {}", report);
1175			T::RelayChainOrigin::ensure_origin_or_root(origin)?;
1176			let local_weight = T::DbWeight::get().reads_writes(2, 2);
1177
1178			match LastSessionReportEndingIndex::<T>::get() {
1179				None => {
1180					// first session report post genesis, okay.
1181				},
1182				Some(last) if report.end_index == last + 1 => {
1183					// incremental -- good
1184				},
1185				Some(last) if report.end_index > last + 1 => {
1186					// deposit a warning event, but proceed
1187					Self::deposit_event(Event::Unexpected(UnexpectedKind::SessionSkipped));
1188					log!(
1189						warn,
1190						"Session report end index is more than expected. last_index={:?}, report.index={:?}",
1191						last,
1192						report.end_index
1193					);
1194				},
1195				Some(past) => {
1196					log!(
1197						error,
1198						"Session report end index is not valid. last_index={:?}, report.index={:?}",
1199						past,
1200						report.end_index
1201					);
1202					Self::deposit_event(Event::Unexpected(UnexpectedKind::SessionAlreadyProcessed));
1203					IncompleteSessionReport::<T>::kill();
1204					return Ok(Some(local_weight).into());
1205				},
1206			}
1207
1208			Self::deposit_event(Event::SessionReportReceived {
1209				end_index: report.end_index,
1210				activation_timestamp: report.activation_timestamp,
1211				validator_points_counts: report.validator_points.len() as u32,
1212				leftover: report.leftover,
1213			});
1214
1215			// If we have anything previously buffered, then merge it.
1216			let maybe_new_session_report = match IncompleteSessionReport::<T>::take() {
1217				Some(old) => old.merge(report.clone()),
1218				None => Ok(report),
1219			};
1220
1221			if let Err(e) = maybe_new_session_report {
1222				Self::deposit_event(Event::Unexpected(e));
1223				debug_assert!(
1224					IncompleteSessionReport::<T>::get().is_none(),
1225					"we have ::take() it above, we don't want to keep the old data"
1226				);
1227				return Ok(().into());
1228			}
1229			let new_session_report = maybe_new_session_report.expect("checked above; qed");
1230
1231			if new_session_report.leftover {
1232				// this is still not final -- buffer it.
1233				IncompleteSessionReport::<T>::put(new_session_report);
1234				Ok(().into())
1235			} else {
1236				// this is final, report it.
1237				LastSessionReportEndingIndex::<T>::put(new_session_report.end_index);
1238
1239				let weight = T::AHStakingInterface::on_relay_session_report(new_session_report);
1240				Ok((Some(local_weight + weight)).into())
1241			}
1242		}
1243
1244		#[pallet::call_index(1)]
1245		#[pallet::weight(
1246			T::AHStakingInterface::weigh_on_new_offences(offences.len() as u32)
1247		)]
1248		pub fn relay_new_offence_paged(
1249			origin: OriginFor<T>,
1250			offences: Vec<(SessionIndex, Offence<T::AccountId>)>,
1251		) -> DispatchResultWithPostInfo {
1252			T::RelayChainOrigin::ensure_origin_or_root(origin)?;
1253			log!(info, "Received new page of {} offences", offences.len());
1254
1255			let mut offences_by_session =
1256				alloc::collections::BTreeMap::<SessionIndex, Vec<Offence<T::AccountId>>>::new();
1257			for (session_index, offence) in offences {
1258				offences_by_session.entry(session_index).or_default().push(offence);
1259			}
1260
1261			let mut weight: Weight = Default::default();
1262			for (slash_session, offences) in offences_by_session {
1263				Self::deposit_event(Event::OffenceReceived {
1264					slash_session,
1265					offences_count: offences.len() as u32,
1266				});
1267				let new_weight = T::AHStakingInterface::on_new_offences(slash_session, offences);
1268				weight.saturating_accrue(new_weight)
1269			}
1270
1271			Ok(Some(weight).into())
1272		}
1273
1274		/// Set session keys for a validator. Keys are validated on AssetHub and forwarded to RC.
1275		///
1276		/// On the first call, a deposit of `KeyDeposit` is held from the stash. Subsequent calls
1277		/// do not charge again. The deposit is released on `purge_keys`.
1278		///
1279		/// **Validation on AssetHub:**
1280		/// - Keys are decoded as `T::RelayChainSessionKeys` to ensure they match RC's expected
1281		///   format.
1282		/// - Ownership proof is validated using `OpaqueKeys::ownership_proof_is_valid`.
1283		///
1284		/// If validation passes, only the validated keys are sent to RC (with empty proof),
1285		/// since RC trusts AH's validation.
1286		///
1287		/// **Fees:**
1288		/// The actual cost of this call is higher than what the weight-based fee estimate shows.
1289		/// In addition to the local transaction weight fee, the stash account is charged an XCM
1290		/// fee (delivery + RC execution cost) via `XcmExecutor::charge_fees`. The relay chain
1291		/// uses `UnpaidExecution`, so the full remote cost is charged upfront on AssetHub.
1292		///
1293		/// When called via a staking proxy, the proxy pays the transaction weight fee,
1294		/// while the stash (delegating account) pays the XCM fee.
1295		///
1296		/// **Max Fee Limit:**
1297		/// Users can optionally specify `max_delivery_and_remote_execution_fee` to limit the
1298		/// delivery + RC execution fee. This does not include the local transaction weight fee. If
1299		/// the fee exceeds this limit, the operation fails with `FeesExceededMax`. Pass `None` for
1300		/// unlimited (no cap).
1301		///
1302		/// NOTE: unlike the current flow for new validators on RC (bond -> set_keys -> validate),
1303		/// users on Asset Hub MUST call bond and validate BEFORE calling set_keys. Attempting to
1304		/// set keys before declaring intent to validate will fail with NotValidator.
1305		#[pallet::call_index(10)]
1306		#[pallet::weight(T::WeightInfo::set_keys())]
1307		pub fn set_keys(
1308			origin: OriginFor<T>,
1309			keys: Vec<u8>,
1310			proof: Vec<u8>,
1311			max_delivery_and_remote_execution_fee: Option<BalanceOf<T>>,
1312		) -> DispatchResult {
1313			let stash = ensure_signed(origin)?;
1314
1315			// Only registered validators can set session keys
1316			ensure!(T::AHStakingInterface::is_validator(&stash), Error::<T>::NotValidator);
1317
1318			// Hold deposit for key storage.
1319			let deposit = T::KeyDeposit::get();
1320			if !deposit.is_zero() {
1321				let current_hold = T::Currency::balance_on_hold(&HoldReason::Keys.into(), &stash);
1322				if current_hold < deposit {
1323					// Top up if current hold is below the required deposit.
1324					T::Currency::set_on_hold(&HoldReason::Keys.into(), &stash, deposit)?;
1325				}
1326			}
1327
1328			// Validate keys: decode as RelayChainSessionKeys to ensure correct format
1329			let session_keys = T::RelayChainSessionKeys::decode(&mut &keys[..])
1330				.map_err(|_| Error::<T>::InvalidKeys)?;
1331
1332			// Validate ownership proof
1333			ensure!(
1334				session_keys.ownership_proof_is_valid(&stash.encode(), &proof),
1335				Error::<T>::InvalidProof
1336			);
1337
1338			// Forward validated keys to RC (no proof needed, already validated)
1339			let fees = T::SendToRelayChain::set_keys(
1340				stash.clone(),
1341				keys,
1342				max_delivery_and_remote_execution_fee,
1343			)
1344			.map_err(|e| match e {
1345				SendKeysError::Send(_) => Error::<T>::XcmSendFailed,
1346				SendKeysError::FeesExceededMax { .. } => Error::<T>::FeesExceededMax,
1347			})?;
1348			Self::deposit_event(Event::FeesPaid { who: stash.clone(), fees });
1349
1350			log::info!(target: LOG_TARGET, "Session keys validated and set for {stash:?}, forwarded to RC");
1351
1352			Ok(())
1353		}
1354
1355		/// Remove session keys for a validator and release the key deposit.
1356		///
1357		/// This purges the keys from the Relay Chain.
1358		///
1359		/// Unlike `set_keys`, this does not require the caller to be a registered validator.
1360		/// This is intentional: a validator who has chilled (stopped validating) should still
1361		/// be able to purge their session keys. This matches the behavior of the original
1362		/// `pallet-session::purge_keys` which allows anyone to call it.
1363		///
1364		/// The Relay Chain will reject the call with `NoKeys` error if the account has no
1365		/// keys set.
1366		///
1367		/// **Fees:**
1368		/// The actual cost of this call is higher than what the weight-based fee estimate shows.
1369		/// In addition to the local transaction weight fee, the caller is charged an XCM fee
1370		/// (delivery + RC execution cost) via `XcmExecutor::charge_fees`. The relay chain uses
1371		/// `UnpaidExecution`, so the full remote cost is charged upfront on AssetHub.
1372		///
1373		/// When called via a staking proxy, the proxy pays the transaction weight fee,
1374		/// while the delegating account pays the XCM fee.
1375		///
1376		/// **Max Fee Limit:**
1377		/// Users can optionally specify `max_delivery_and_remote_execution_fee` to limit the
1378		/// delivery + RC execution fee. This does not include the local transaction weight fee. If
1379		/// the fee exceeds this limit, the operation fails with `FeesExceededMax`. Pass `None` for
1380		/// unlimited (no cap).
1381		#[pallet::call_index(11)]
1382		#[pallet::weight(T::WeightInfo::purge_keys())]
1383		pub fn purge_keys(
1384			origin: OriginFor<T>,
1385			max_delivery_and_remote_execution_fee: Option<BalanceOf<T>>,
1386		) -> DispatchResult {
1387			let stash = ensure_signed(origin)?;
1388
1389			// Release the key deposit if one was held (no-op if nothing held).
1390			let _ = T::Currency::release_all(
1391				&HoldReason::Keys.into(),
1392				&stash,
1393				frame_support::traits::tokens::Precision::BestEffort,
1394			);
1395
1396			// Forward purge request to RC
1397			// Note: RC will fail with NoKeys if the account has no keys set
1398			let fees = T::SendToRelayChain::purge_keys(
1399				stash.clone(),
1400				max_delivery_and_remote_execution_fee,
1401			)
1402			.map_err(|e| match e {
1403				SendKeysError::Send(_) => Error::<T>::XcmSendFailed,
1404				SendKeysError::FeesExceededMax { .. } => Error::<T>::FeesExceededMax,
1405			})?;
1406			Self::deposit_event(Event::FeesPaid { who: stash.clone(), fees });
1407
1408			log::info!(target: LOG_TARGET, "Session keys purged for {stash:?}, forwarded to RC");
1409
1410			Ok(())
1411		}
1412	}
1413}