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