1#![cfg_attr(not(feature = "std"), no_std)]
55
56pub use pallet::*;
57
58#[cfg(test)]
59pub mod mock;
60
61#[cfg(feature = "runtime-benchmarks")]
62pub mod benchmarking;
63pub mod weights;
64
65pub use weights::WeightInfo;
66
67extern crate alloc;
68use alloc::{collections::BTreeMap, vec::Vec};
69use frame_support::{pallet_prelude::*, traits::RewardsReporter};
70use pallet_staking_async_rc_client::{self as rc_client};
71use sp_staking::{
72 offence::{OffenceDetails, OffenceSeverity},
73 SessionIndex,
74};
75
76pub type BalanceOf<T> = <T as Config>::CurrencyBalance;
78
79pub type OffenceDetailsOf<T> = OffenceDetails<
81 <T as frame_system::Config>::AccountId,
82 (
83 <T as frame_system::Config>::AccountId,
84 sp_staking::Exposure<<T as frame_system::Config>::AccountId, BalanceOf<T>>,
85 ),
86>;
87
88const LOG_TARGET: &str = "runtime::staking-async::ah-client";
89
90#[macro_export]
92macro_rules! log {
93 ($level:tt, $patter:expr $(, $values:expr)* $(,)?) => {
94 log::$level!(
95 target: $crate::LOG_TARGET,
96 concat!("[{:?}] ⬇️ ", $patter), <frame_system::Pallet<T>>::block_number() $(, $values)*
97 )
98 };
99}
100
101pub trait SendToAssetHub {
109 type AccountId;
111
112 fn relay_session_report(session_report: rc_client::SessionReport<Self::AccountId>);
114
115 fn relay_new_offence(
117 session_index: SessionIndex,
118 offences: Vec<rc_client::Offence<Self::AccountId>>,
119 );
120}
121
122#[cfg(feature = "std")]
124impl SendToAssetHub for () {
125 type AccountId = u64;
126
127 fn relay_session_report(_session_report: rc_client::SessionReport<Self::AccountId>) {
128 panic!("relay_session_report not implemented");
129 }
130
131 fn relay_new_offence(
132 _session_index: SessionIndex,
133 _offences: Vec<rc_client::Offence<Self::AccountId>>,
134 ) {
135 panic!("relay_new_offence not implemented");
136 }
137}
138
139pub trait SessionInterface {
141 type ValidatorId: Clone;
143
144 fn validators() -> Vec<Self::ValidatorId>;
145
146 fn prune_up_to(index: SessionIndex);
148
149 fn report_offence(offender: Self::ValidatorId, severity: OffenceSeverity);
153}
154
155impl<T: Config + pallet_session::Config + pallet_session::historical::Config> SessionInterface
156 for T
157{
158 type ValidatorId = <T as pallet_session::Config>::ValidatorId;
159
160 fn validators() -> Vec<Self::ValidatorId> {
161 pallet_session::Pallet::<T>::validators()
162 }
163
164 fn prune_up_to(index: SessionIndex) {
165 pallet_session::historical::Pallet::<T>::prune_up_to(index)
166 }
167 fn report_offence(offender: Self::ValidatorId, severity: OffenceSeverity) {
168 pallet_session::Pallet::<T>::report_offence(offender, severity)
169 }
170}
171
172#[derive(
174 Default,
175 DecodeWithMemTracking,
176 Encode,
177 Decode,
178 MaxEncodedLen,
179 TypeInfo,
180 Clone,
181 PartialEq,
182 Eq,
183 RuntimeDebug,
184 serde::Serialize,
185 serde::Deserialize,
186)]
187pub enum OperatingMode {
188 #[default]
196 Passive,
197
198 Buffered,
206
207 Active,
214}
215
216impl OperatingMode {
217 fn can_accept_validator_set(&self) -> bool {
218 matches!(self, OperatingMode::Active)
219 }
220}
221
222pub struct DefaultExposureOf<T>(core::marker::PhantomData<T>);
225
226impl<T: Config>
227 sp_runtime::traits::Convert<
228 T::AccountId,
229 Option<sp_staking::Exposure<T::AccountId, BalanceOf<T>>>,
230 > for DefaultExposureOf<T>
231{
232 fn convert(
233 validator: T::AccountId,
234 ) -> Option<sp_staking::Exposure<T::AccountId, BalanceOf<T>>> {
235 T::SessionInterface::validators()
236 .contains(&validator)
237 .then_some(Default::default())
238 }
239}
240
241#[derive(Encode, Decode, Clone, PartialEq, Eq, RuntimeDebug, TypeInfo, MaxEncodedLen)]
242pub struct BufferedOffence<AccountId> {
243 pub reporter: Option<AccountId>,
246 pub slash_fraction: sp_runtime::Perbill,
247}
248
249pub type BufferedOffencesMap<T> = BTreeMap<
251 SessionIndex,
252 BTreeMap<
253 <T as frame_system::Config>::AccountId,
254 BufferedOffence<<T as frame_system::Config>::AccountId>,
255 >,
256>;
257
258#[frame_support::pallet]
259pub mod pallet {
260 use crate::*;
261 use alloc::vec;
262 use frame_support::traits::{Hooks, UnixTime};
263 use frame_system::pallet_prelude::*;
264 use pallet_session::{historical, SessionManager};
265 use sp_runtime::{Perbill, Saturating};
266 use sp_staking::{
267 offence::{OffenceSeverity, OnOffenceHandler},
268 SessionIndex,
269 };
270
271 const STORAGE_VERSION: StorageVersion = StorageVersion::new(1);
272
273 #[pallet::config]
274 pub trait Config: frame_system::Config {
275 type CurrencyBalance: sp_runtime::traits::AtLeast32BitUnsigned
277 + codec::FullCodec
278 + DecodeWithMemTracking
279 + codec::HasCompact<Type: DecodeWithMemTracking>
280 + Copy
281 + MaybeSerializeDeserialize
282 + core::fmt::Debug
283 + Default
284 + From<u64>
285 + TypeInfo
286 + Send
287 + Sync
288 + MaxEncodedLen;
289
290 type AssetHubOrigin: EnsureOrigin<Self::RuntimeOrigin>;
292
293 type AdminOrigin: EnsureOrigin<Self::RuntimeOrigin>;
295
296 type SendToAssetHub: SendToAssetHub<AccountId = Self::AccountId>;
298
299 type MinimumValidatorSetSize: Get<u32>;
301
302 type UnixTime: UnixTime;
304
305 type PointsPerBlock: Get<u32>;
307
308 type MaxOffenceBatchSize: Get<u32>;
321
322 type SessionInterface: SessionInterface<ValidatorId = Self::AccountId>;
324
325 type Fallback: pallet_session::SessionManager<Self::AccountId>
332 + OnOffenceHandler<
333 Self::AccountId,
334 (Self::AccountId, sp_staking::Exposure<Self::AccountId, BalanceOf<Self>>),
335 Weight,
336 > + frame_support::traits::RewardsReporter<Self::AccountId>
337 + pallet_authorship::EventHandler<Self::AccountId, BlockNumberFor<Self>>;
338
339 type WeightInfo: WeightInfo;
341 }
342
343 #[pallet::pallet]
344 #[pallet::storage_version(STORAGE_VERSION)]
345 pub struct Pallet<T>(_);
346
347 #[pallet::storage]
351 #[pallet::unbounded]
352 pub type ValidatorSet<T: Config> = StorageValue<_, (u32, Vec<T::AccountId>), OptionQuery>;
353
354 #[pallet::storage]
356 #[pallet::unbounded]
357 pub type IncompleteValidatorSetReport<T: Config> =
358 StorageValue<_, rc_client::ValidatorSetReport<T::AccountId>, OptionQuery>;
359
360 #[pallet::storage]
365 pub type ValidatorPoints<T: Config> =
366 StorageMap<_, Twox64Concat, T::AccountId, u32, ValueQuery>;
367
368 #[pallet::storage]
373 pub type Mode<T: Config> = StorageValue<_, OperatingMode, ValueQuery>;
374
375 #[pallet::storage]
384 pub type NextSessionChangesValidators<T: Config> = StorageValue<_, u32, OptionQuery>;
385
386 #[pallet::storage]
391 pub type ValidatorSetAppliedAt<T: Config> = StorageValue<_, SessionIndex, OptionQuery>;
392
393 #[pallet::storage]
404 #[pallet::unbounded]
405 pub type BufferedOffences<T: Config> = StorageValue<_, BufferedOffencesMap<T>, ValueQuery>;
406
407 #[pallet::genesis_config]
408 #[derive(frame_support::DefaultNoBound, frame_support::DebugNoBound)]
409 pub struct GenesisConfig<T: Config> {
410 pub operating_mode: OperatingMode,
412 pub _marker: core::marker::PhantomData<T>,
413 }
414
415 #[pallet::genesis_build]
416 impl<T: Config> BuildGenesisConfig for GenesisConfig<T> {
417 fn build(&self) {
418 Mode::<T>::put(self.operating_mode.clone());
420 }
421 }
422
423 #[pallet::error]
424 pub enum Error<T> {
425 Blocked,
427 }
428
429 #[pallet::event]
430 #[pallet::generate_deposit(fn deposit_event)]
431 pub enum Event<T: Config> {
432 ValidatorSetReceived {
434 id: u32,
435 new_validator_set_count: u32,
436 prune_up_to: Option<SessionIndex>,
437 leftover: bool,
438 },
439 CouldNotMergeAndDropped,
444 SetTooSmallAndDropped,
447 Unexpected(UnexpectedKind),
450 }
451
452 #[derive(Clone, Encode, Decode, DecodeWithMemTracking, PartialEq, TypeInfo, RuntimeDebug)]
458 pub enum UnexpectedKind {
459 ReceivedValidatorSetWhilePassive,
461
462 UnexpectedModeTransition,
466 }
467
468 #[pallet::call]
469 impl<T: Config> Pallet<T> {
470 #[pallet::call_index(0)]
471 #[pallet::weight(
472 T::DbWeight::get().reads_writes(2, 1)
479 )]
480 pub fn validator_set(
481 origin: OriginFor<T>,
482 report: rc_client::ValidatorSetReport<T::AccountId>,
483 ) -> DispatchResult {
484 log!(debug, "Received new validator set report {}", report);
486 T::AssetHubOrigin::ensure_origin_or_root(origin)?;
487
488 let mode = Mode::<T>::get();
490 ensure!(mode.can_accept_validator_set(), Error::<T>::Blocked);
491
492 let maybe_merged_report = match IncompleteValidatorSetReport::<T>::take() {
493 Some(old) => old.merge(report.clone()),
494 None => Ok(report),
495 };
496
497 if maybe_merged_report.is_err() {
498 Self::deposit_event(Event::CouldNotMergeAndDropped);
499 debug_assert!(
500 IncompleteValidatorSetReport::<T>::get().is_none(),
501 "we have ::take() it above, we don't want to keep the old data"
502 );
503 return Ok(());
504 }
505
506 let report = maybe_merged_report.expect("checked above; qed");
507
508 if report.leftover {
509 Self::deposit_event(Event::ValidatorSetReceived {
511 id: report.id,
512 new_validator_set_count: report.new_validator_set.len() as u32,
513 prune_up_to: report.prune_up_to,
514 leftover: report.leftover,
515 });
516 IncompleteValidatorSetReport::<T>::put(report);
517 } else {
518 let rc_client::ValidatorSetReport {
520 id,
521 leftover,
522 mut new_validator_set,
523 prune_up_to,
524 } = report;
525
526 new_validator_set.sort();
528 new_validator_set.dedup();
529
530 if (new_validator_set.len() as u32) < T::MinimumValidatorSetSize::get() {
531 Self::deposit_event(Event::SetTooSmallAndDropped);
532 debug_assert!(
533 IncompleteValidatorSetReport::<T>::get().is_none(),
534 "we have ::take() it above, we don't want to keep the old data"
535 );
536 return Ok(());
537 }
538
539 Self::deposit_event(Event::ValidatorSetReceived {
540 id,
541 new_validator_set_count: new_validator_set.len() as u32,
542 prune_up_to,
543 leftover,
544 });
545
546 ValidatorSet::<T>::put((id, new_validator_set));
548 if let Some(index) = prune_up_to {
549 T::SessionInterface::prune_up_to(index);
550 }
551 }
552
553 Ok(())
554 }
555
556 #[pallet::call_index(1)]
558 #[pallet::weight(T::DbWeight::get().writes(1))]
559 pub fn set_mode(origin: OriginFor<T>, mode: OperatingMode) -> DispatchResult {
560 T::AdminOrigin::ensure_origin(origin)?;
561 Self::do_set_mode(mode);
562 Ok(())
563 }
564
565 #[pallet::call_index(2)]
567 #[pallet::weight(T::DbWeight::get().writes(1))]
568 pub fn force_on_migration_end(origin: OriginFor<T>) -> DispatchResult {
569 T::AdminOrigin::ensure_origin(origin)?;
570 Self::on_migration_end();
571 Ok(())
572 }
573 }
574
575 #[pallet::hooks]
576 impl<T: Config> Hooks<BlockNumberFor<T>> for Pallet<T> {
577 fn on_initialize(_n: BlockNumberFor<T>) -> Weight {
578 let mut weight = Weight::zero();
579
580 let mode = Mode::<T>::get();
581 weight = weight.saturating_add(T::DbWeight::get().reads(1));
582 if mode != OperatingMode::Active {
583 return weight;
584 }
585
586 let buffered_offences = BufferedOffences::<T>::get();
588 weight = weight.saturating_add(T::DbWeight::get().reads(1));
589 if buffered_offences.is_empty() {
590 return weight;
591 }
592
593 let processing_weight = Self::process_buffered_offences();
594 weight = weight.saturating_add(processing_weight);
595
596 weight
597 }
598 }
599
600 impl<T: Config>
601 historical::SessionManager<T::AccountId, sp_staking::Exposure<T::AccountId, BalanceOf<T>>>
602 for Pallet<T>
603 {
604 fn new_session(
605 new_index: sp_staking::SessionIndex,
606 ) -> Option<
607 Vec<(
608 <T as frame_system::Config>::AccountId,
609 sp_staking::Exposure<T::AccountId, BalanceOf<T>>,
610 )>,
611 > {
612 <Self as pallet_session::SessionManager<_>>::new_session(new_index)
613 .map(|v| v.into_iter().map(|v| (v, sp_staking::Exposure::default())).collect())
614 }
615
616 fn new_session_genesis(
617 new_index: SessionIndex,
618 ) -> Option<Vec<(T::AccountId, sp_staking::Exposure<T::AccountId, BalanceOf<T>>)>> {
619 if Mode::<T>::get() == OperatingMode::Passive {
620 T::Fallback::new_session_genesis(new_index).map(|validators| {
621 validators.into_iter().map(|v| (v, sp_staking::Exposure::default())).collect()
622 })
623 } else {
624 None
625 }
626 }
627
628 fn start_session(start_index: SessionIndex) {
629 <Self as pallet_session::SessionManager<_>>::start_session(start_index)
630 }
631
632 fn end_session(end_index: SessionIndex) {
633 <Self as pallet_session::SessionManager<_>>::end_session(end_index)
634 }
635 }
636
637 impl<T: Config> pallet_session::SessionManager<T::AccountId> for Pallet<T> {
638 fn new_session(session_index: u32) -> Option<Vec<T::AccountId>> {
639 match Mode::<T>::get() {
640 OperatingMode::Passive => T::Fallback::new_session(session_index),
641 OperatingMode::Buffered => None,
643 OperatingMode::Active => Self::do_new_session(),
644 }
645 }
646
647 fn start_session(session_index: u32) {
648 if Mode::<T>::get() == OperatingMode::Passive {
649 T::Fallback::start_session(session_index)
650 }
651 }
652
653 fn new_session_genesis(new_index: SessionIndex) -> Option<Vec<T::AccountId>> {
654 if Mode::<T>::get() == OperatingMode::Passive {
655 T::Fallback::new_session_genesis(new_index)
656 } else {
657 None
658 }
659 }
660
661 fn end_session(session_index: u32) {
662 match Mode::<T>::get() {
663 OperatingMode::Passive => T::Fallback::end_session(session_index),
664 OperatingMode::Buffered => (),
666 OperatingMode::Active => Self::do_end_session(session_index),
667 }
668 }
669 }
670
671 impl<T: Config>
672 OnOffenceHandler<
673 T::AccountId,
674 (T::AccountId, sp_staking::Exposure<T::AccountId, BalanceOf<T>>),
675 Weight,
676 > for Pallet<T>
677 {
678 fn on_offence(
679 offenders: &[OffenceDetails<
680 T::AccountId,
681 (T::AccountId, sp_staking::Exposure<T::AccountId, BalanceOf<T>>),
682 >],
683 slash_fraction: &[Perbill],
684 slash_session: SessionIndex,
685 ) -> Weight {
686 match Mode::<T>::get() {
687 OperatingMode::Passive => {
688 T::Fallback::on_offence(offenders, slash_fraction, slash_session)
690 },
691 OperatingMode::Buffered =>
692 Self::on_offence_buffered(offenders, slash_fraction, slash_session),
693 OperatingMode::Active =>
694 Self::on_offence_active(offenders, slash_fraction, slash_session),
695 }
696 }
697 }
698
699 impl<T: Config> RewardsReporter<T::AccountId> for Pallet<T> {
700 fn reward_by_ids(rewards: impl IntoIterator<Item = (T::AccountId, u32)>) {
701 match Mode::<T>::get() {
702 OperatingMode::Passive => T::Fallback::reward_by_ids(rewards),
703 OperatingMode::Buffered | OperatingMode::Active => Self::do_reward_by_ids(rewards),
704 }
705 }
706 }
707
708 impl<T: Config> pallet_authorship::EventHandler<T::AccountId, BlockNumberFor<T>> for Pallet<T> {
709 fn note_author(author: T::AccountId) {
710 match Mode::<T>::get() {
711 OperatingMode::Passive => T::Fallback::note_author(author),
712 OperatingMode::Buffered | OperatingMode::Active => Self::do_note_author(author),
713 }
714 }
715 }
716
717 impl<T: Config> Pallet<T> {
718 pub fn on_migration_start() {
727 debug_assert!(
728 Mode::<T>::get() == OperatingMode::Passive,
729 "we should only be called when in passive mode"
730 );
731 Self::do_set_mode(OperatingMode::Buffered);
732 }
733
734 pub fn on_migration_end() {
743 debug_assert!(
744 Mode::<T>::get() == OperatingMode::Buffered,
745 "we should only be called when in buffered mode"
746 );
747 Self::do_set_mode(OperatingMode::Active);
748
749 }
752
753 fn do_set_mode(new_mode: OperatingMode) {
754 let old_mode = Mode::<T>::get();
755 let unexpected = match new_mode {
756 OperatingMode::Passive => true,
758 OperatingMode::Buffered => old_mode != OperatingMode::Passive,
759 OperatingMode::Active => old_mode != OperatingMode::Buffered,
760 };
761
762 if unexpected {
764 log!(warn, "Unexpected mode transition from {:?} to {:?}", old_mode, new_mode);
765 Self::deposit_event(Event::Unexpected(UnexpectedKind::UnexpectedModeTransition));
766 }
767
768 Mode::<T>::put(new_mode);
770 }
771
772 fn do_new_session() -> Option<Vec<T::AccountId>> {
773 ValidatorSet::<T>::take().map(|(id, val_set)| {
774 NextSessionChangesValidators::<T>::put(id);
776 val_set
777 })
778 }
779
780 fn do_end_session(session_index: u32) {
781 use sp_runtime::SaturatedConversion;
782
783 let validator_points = ValidatorPoints::<T>::iter().drain().collect::<Vec<_>>();
784 let activation_timestamp = NextSessionChangesValidators::<T>::take().map(|id| {
785 ValidatorSetAppliedAt::<T>::put(session_index + 1);
787 (T::UnixTime::now().as_millis().saturated_into::<u64>(), id)
789 });
790
791 let session_report = pallet_staking_async_rc_client::SessionReport {
792 end_index: session_index,
793 validator_points,
794 activation_timestamp,
795 leftover: false,
796 };
797
798 T::SendToAssetHub::relay_session_report(session_report);
799 }
800
801 fn do_reward_by_ids(rewards: impl IntoIterator<Item = (T::AccountId, u32)>) {
802 for (validator_id, points) in rewards {
803 ValidatorPoints::<T>::mutate(validator_id, |balance| {
804 balance.saturating_accrue(points);
805 });
806 }
807 }
808
809 fn do_note_author(author: T::AccountId) {
810 ValidatorPoints::<T>::mutate(author, |points| {
811 points.saturating_accrue(T::PointsPerBlock::get());
812 });
813 }
814
815 pub(crate) fn process_buffered_offences() -> Weight {
817 let max_batch_size = T::MaxOffenceBatchSize::get() as usize;
818
819 let offences_sent = BufferedOffences::<T>::mutate(|buffered| {
821 let first_session_key = buffered.keys().next().copied()?;
822
823 let session_map = buffered.get_mut(&first_session_key)?;
824
825 let keys_to_drain: Vec<_> =
827 session_map.keys().take(max_batch_size).cloned().collect();
828
829 let offences_to_send: Vec<_> = keys_to_drain
830 .into_iter()
831 .filter_map(|key| {
832 session_map.remove(&key).map(|offence| rc_client::Offence {
833 offender: key,
834 reporters: offence.reporter.into_iter().collect(),
835 slash_fraction: offence.slash_fraction,
836 })
837 })
838 .collect();
839
840 if !offences_to_send.is_empty() {
841 if session_map.is_empty() {
843 buffered.remove(&first_session_key);
844 log!(debug, "Cleared all offences for session {}", first_session_key);
845 }
846
847 Some((first_session_key, offences_to_send))
848 } else {
849 None
850 }
851 });
852
853 if let Some((slash_session, offences_to_send)) = offences_sent {
854 log!(
855 info,
856 "Sending {} buffered offences for session {} to AssetHub",
857 offences_to_send.len(),
858 slash_session
859 );
860
861 let batch_size = offences_to_send.len();
862 T::SendToAssetHub::relay_new_offence(slash_session, offences_to_send);
863
864 T::WeightInfo::process_buffered_offences(batch_size as u32)
865 } else {
866 Weight::zero()
867 }
868 }
869
870 fn is_ongoing_offence(slash_session: SessionIndex) -> bool {
872 ValidatorSetAppliedAt::<T>::get()
873 .map(|start_session| slash_session >= start_session)
874 .unwrap_or(false)
875 }
876
877 fn on_offence_buffered(
879 offenders: &[OffenceDetailsOf<T>],
880 slash_fraction: &[Perbill],
881 slash_session: SessionIndex,
882 ) -> Weight {
883 let ongoing_offence = Self::is_ongoing_offence(slash_session);
884
885 let _: Vec<_> = offenders
886 .iter()
887 .cloned()
888 .zip(slash_fraction)
889 .map(|(offence, fraction)| {
890 if ongoing_offence {
891 T::SessionInterface::report_offence(
893 offence.offender.0.clone(),
894 OffenceSeverity(*fraction),
895 );
896 }
897
898 let (offender, _full_identification) = offence.offender;
899 let reporters = offence.reporters;
900
901 BufferedOffences::<T>::mutate(|buffered| {
904 let session_offences = buffered.entry(slash_session).or_default();
905 let entry = session_offences.entry(offender);
906
907 entry
908 .and_modify(|existing| {
909 if existing.slash_fraction < *fraction {
910 *existing = BufferedOffence {
911 reporter: reporters.first().cloned(),
912 slash_fraction: *fraction,
913 };
914 }
915 })
916 .or_insert(BufferedOffence {
917 reporter: reporters.first().cloned(),
918 slash_fraction: *fraction,
919 });
920 });
921
922 })
924 .collect();
925
926 Weight::zero()
927 }
928
929 fn on_offence_active(
931 offenders: &[OffenceDetailsOf<T>],
932 slash_fraction: &[Perbill],
933 slash_session: SessionIndex,
934 ) -> Weight {
935 let ongoing_offence = Self::is_ongoing_offence(slash_session);
936
937 let offenders_and_slashes_message: Vec<_> = offenders
938 .iter()
939 .cloned()
940 .zip(slash_fraction)
941 .map(|(offence, fraction)| {
942 if ongoing_offence {
943 T::SessionInterface::report_offence(
945 offence.offender.0.clone(),
946 OffenceSeverity(*fraction),
947 );
948 }
949
950 let (offender, _full_identification) = offence.offender;
951 let reporters = offence.reporters;
952
953 rc_client::Offence { offender, reporters, slash_fraction: *fraction }
956 })
957 .collect();
958
959 if !offenders_and_slashes_message.is_empty() {
961 log!(info, "sending offence report to AH");
962 T::SendToAssetHub::relay_new_offence(slash_session, offenders_and_slashes_message);
963 }
964
965 Weight::zero()
966 }
967 }
968}