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}