Skip to main content

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