pallet_moonbeam_orbiters/
lib.rs

1// Copyright 2019-2025 PureStake Inc.
2// This file is part of Moonbeam.
3
4// Moonbeam is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8
9// Moonbeam is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU General Public License for more details.
13
14// You should have received a copy of the GNU General Public License
15// along with Moonbeam.  If not, see <http://www.gnu.org/licenses/>.
16
17//! # Pallet moonbeam-orbiters
18//!
19//! This pallet allows authorized collators to share their block creation rights and rewards with
20//! multiple entities named "orbiters".
21//! Each authorized collator will define a group of orbiters, and each orbiter will replace the
22//! collator in turn with the other orbiters (rotation every `RotatePeriod` rounds).
23//!
24//! This pallet is designed to work with the nimbus consensus.
25//! In order not to impact the other pallets (notably nimbus and parachain-staking) this pallet
26//! simply redefines the lookup NimbusId-> AccountId, in order to replace the collator by its
27//! currently selected orbiter.
28
29#![cfg_attr(not(feature = "std"), no_std)]
30
31pub mod types;
32pub mod weights;
33
34#[cfg(any(test, feature = "runtime-benchmarks"))]
35mod benchmarks;
36#[cfg(test)]
37mod mock;
38#[cfg(test)]
39mod tests;
40
41pub use pallet::*;
42pub use types::*;
43pub use weights::WeightInfo;
44
45use frame_support::pallet;
46use nimbus_primitives::{AccountLookup, NimbusId};
47
48#[pallet]
49pub mod pallet {
50	use super::*;
51	use frame_support::pallet_prelude::*;
52	use frame_support::traits::{Currency, NamedReservableCurrency};
53	use frame_system::pallet_prelude::*;
54	use sp_runtime::traits::{CheckedSub, One, Saturating, StaticLookup, Zero};
55
56	#[pallet::pallet]
57	#[pallet::without_storage_info]
58	pub struct Pallet<T>(PhantomData<T>);
59
60	pub type BalanceOf<T> =
61		<<T as Config>::Currency as Currency<<T as frame_system::Config>::AccountId>>::Balance;
62
63	pub type ReserveIdentifierOf<T> = <<T as Config>::Currency as NamedReservableCurrency<
64		<T as frame_system::Config>::AccountId,
65	>>::ReserveIdentifier;
66
67	#[pallet::config]
68	pub trait Config: frame_system::Config<RuntimeEvent: From<Event<Self>>> {
69		/// A type to convert between AuthorId and AccountId. This pallet wrap the lookup to allow
70		/// orbiters authoring.
71		type AccountLookup: AccountLookup<Self::AccountId>;
72
73		/// Origin that is allowed to add a collator in orbiters program.
74		type AddCollatorOrigin: EnsureOrigin<Self::RuntimeOrigin>;
75
76		/// The currency type.
77		type Currency: NamedReservableCurrency<Self::AccountId>;
78
79		/// Origin that is allowed to remove a collator from orbiters program.
80		type DelCollatorOrigin: EnsureOrigin<Self::RuntimeOrigin>;
81
82		#[pallet::constant]
83		/// Maximum number of orbiters per collator.
84		type MaxPoolSize: Get<u32>;
85
86		#[pallet::constant]
87		/// Maximum number of round to keep on storage.
88		type MaxRoundArchive: Get<Self::RoundIndex>;
89
90		/// Reserve identifier for this pallet instance.
91		type OrbiterReserveIdentifier: Get<ReserveIdentifierOf<Self>>;
92
93		#[pallet::constant]
94		/// Number of rounds before changing the selected orbiter.
95		/// WARNING: when changing `RotatePeriod`, you need a migration code that sets
96		/// `ForceRotation` to true to avoid holes in `OrbiterPerRound`.
97		type RotatePeriod: Get<Self::RoundIndex>;
98
99		/// Round index type.
100		type RoundIndex: Parameter
101			+ Member
102			+ MaybeSerializeDeserialize
103			+ sp_std::fmt::Debug
104			+ Default
105			+ sp_runtime::traits::MaybeDisplay
106			+ sp_runtime::traits::AtLeast32Bit
107			+ Copy;
108
109		/// Weight information for extrinsics in this pallet.
110		type WeightInfo: WeightInfo;
111	}
112
113	#[pallet::storage]
114	#[pallet::getter(fn account_lookup_override)]
115	/// Account lookup override
116	pub type AccountLookupOverride<T: Config> =
117		StorageMap<_, Blake2_128Concat, T::AccountId, Option<T::AccountId>>;
118
119	#[pallet::storage]
120	#[pallet::getter(fn collators_pool)]
121	/// Current orbiters, with their "parent" collator
122	pub type CollatorsPool<T: Config> =
123		CountedStorageMap<_, Blake2_128Concat, T::AccountId, CollatorPoolInfo<T::AccountId>>;
124
125	#[pallet::storage]
126	/// Current round index
127	pub(crate) type CurrentRound<T: Config> = StorageValue<_, T::RoundIndex, ValueQuery>;
128
129	#[pallet::storage]
130	/// If true, it forces the rotation at the next round.
131	/// A use case: when changing RotatePeriod, you need a migration code that sets this value to
132	/// true to avoid holes in OrbiterPerRound.
133	pub(crate) type ForceRotation<T: Config> = StorageValue<_, bool, ValueQuery>;
134
135	#[pallet::storage]
136	#[pallet::getter(fn min_orbiter_deposit)]
137	/// Minimum deposit required to be registered as an orbiter
138	pub type MinOrbiterDeposit<T: Config> = StorageValue<_, BalanceOf<T>, OptionQuery>;
139
140	#[pallet::storage]
141	/// Store active orbiter per round and per parent collator
142	pub(crate) type OrbiterPerRound<T: Config> = StorageDoubleMap<
143		_,
144		Twox64Concat,
145		T::RoundIndex,
146		Blake2_128Concat,
147		T::AccountId,
148		T::AccountId,
149		OptionQuery,
150	>;
151
152	#[pallet::storage]
153	#[pallet::getter(fn orbiter)]
154	/// Check if account is an orbiter
155	pub type RegisteredOrbiter<T: Config> = StorageMap<_, Blake2_128Concat, T::AccountId, bool>;
156
157	#[pallet::genesis_config]
158	pub struct GenesisConfig<T: Config> {
159		pub min_orbiter_deposit: BalanceOf<T>,
160	}
161
162	impl<T: Config> Default for GenesisConfig<T> {
163		fn default() -> Self {
164			Self {
165				min_orbiter_deposit: One::one(),
166			}
167		}
168	}
169
170	#[pallet::genesis_build]
171	impl<T: Config> BuildGenesisConfig for GenesisConfig<T> {
172		fn build(&self) {
173			assert!(
174				self.min_orbiter_deposit > Zero::zero(),
175				"Minimal orbiter deposit should be greater than zero"
176			);
177			MinOrbiterDeposit::<T>::put(self.min_orbiter_deposit)
178		}
179	}
180
181	#[pallet::hooks]
182	impl<T: Config> Hooks<BlockNumberFor<T>> for Pallet<T> {
183		fn on_initialize(_: BlockNumberFor<T>) -> Weight {
184			// Prune old OrbiterPerRound entries
185			if let Some(round_to_prune) =
186				CurrentRound::<T>::get().checked_sub(&T::MaxRoundArchive::get())
187			{
188				// TODO: Find better limit.
189				// Is it sure to be cleared in a single block? In which case we can probably have
190				// a lower limit.
191				// Otherwise, we should still have a lower limit, and implement a multi-block clear
192				// by using the return value of clear_prefix for subsequent blocks.
193				let result = OrbiterPerRound::<T>::clear_prefix(round_to_prune, u32::MAX, None);
194				T::WeightInfo::on_initialize(result.unique)
195			} else {
196				T::DbWeight::get().reads(1)
197			}
198		}
199	}
200
201	/// An error that can occur while executing this pallet's extrinsics.
202	#[pallet::error]
203	pub enum Error<T> {
204		/// The collator is already added in orbiters program.
205		CollatorAlreadyAdded,
206		/// This collator is not in orbiters program.
207		CollatorNotFound,
208		/// There are already too many orbiters associated with this collator.
209		CollatorPoolTooLarge,
210		/// There are more collator pools than the number specified in the parameter.
211		CollatorsPoolCountTooLow,
212		/// The minimum deposit required to register as an orbiter has not yet been included in the
213		/// onchain storage
214		MinOrbiterDepositNotSet,
215		/// This orbiter is already associated with this collator.
216		OrbiterAlreadyInPool,
217		/// This orbiter has not made a deposit
218		OrbiterDepositNotFound,
219		/// This orbiter is not found
220		OrbiterNotFound,
221		/// The orbiter is still at least in one pool
222		OrbiterStillInAPool,
223	}
224
225	#[pallet::event]
226	#[pallet::generate_deposit(pub(crate) fn deposit_event)]
227	pub enum Event<T: Config> {
228		/// An orbiter join a collator pool
229		OrbiterJoinCollatorPool {
230			collator: T::AccountId,
231			orbiter: T::AccountId,
232		},
233		/// An orbiter leave a collator pool
234		OrbiterLeaveCollatorPool {
235			collator: T::AccountId,
236			orbiter: T::AccountId,
237		},
238		/// Paid the orbiter account the balance as liquid rewards.
239		OrbiterRewarded {
240			account: T::AccountId,
241			rewards: BalanceOf<T>,
242		},
243		OrbiterRotation {
244			collator: T::AccountId,
245			old_orbiter: Option<T::AccountId>,
246			new_orbiter: Option<T::AccountId>,
247		},
248		/// An orbiter has registered
249		OrbiterRegistered {
250			account: T::AccountId,
251			deposit: BalanceOf<T>,
252		},
253		/// An orbiter has unregistered
254		OrbiterUnregistered { account: T::AccountId },
255	}
256
257	#[pallet::call]
258	impl<T: Config> Pallet<T> {
259		/// Add an orbiter in a collator pool
260		#[pallet::call_index(0)]
261		#[pallet::weight(T::WeightInfo::collator_add_orbiter())]
262		pub fn collator_add_orbiter(
263			origin: OriginFor<T>,
264			orbiter: <T::Lookup as StaticLookup>::Source,
265		) -> DispatchResult {
266			let collator = ensure_signed(origin)?;
267			let orbiter = T::Lookup::lookup(orbiter)?;
268
269			let mut collator_pool =
270				CollatorsPool::<T>::get(&collator).ok_or(Error::<T>::CollatorNotFound)?;
271			let orbiters = collator_pool.get_orbiters();
272			ensure!(
273				(orbiters.len() as u32) < T::MaxPoolSize::get(),
274				Error::<T>::CollatorPoolTooLarge
275			);
276			if orbiters.iter().any(|orbiter_| orbiter_ == &orbiter) {
277				return Err(Error::<T>::OrbiterAlreadyInPool.into());
278			}
279
280			// Make sure the orbiter has made a deposit. It can be an old orbiter whose deposit
281			// is lower than the current minimum (if the minimum was lower in the past), so we just
282			// have to check that a deposit exists (which means checking that the deposit amount
283			// is not zero).
284			let orbiter_deposit =
285				T::Currency::reserved_balance_named(&T::OrbiterReserveIdentifier::get(), &orbiter);
286			ensure!(
287				orbiter_deposit > BalanceOf::<T>::zero(),
288				Error::<T>::OrbiterDepositNotFound
289			);
290
291			collator_pool.add_orbiter(orbiter.clone());
292			CollatorsPool::<T>::insert(&collator, collator_pool);
293
294			Self::deposit_event(Event::OrbiterJoinCollatorPool { collator, orbiter });
295
296			Ok(())
297		}
298
299		/// Remove an orbiter from the caller collator pool
300		#[pallet::call_index(1)]
301		#[pallet::weight(T::WeightInfo::collator_remove_orbiter())]
302		pub fn collator_remove_orbiter(
303			origin: OriginFor<T>,
304			orbiter: <T::Lookup as StaticLookup>::Source,
305		) -> DispatchResult {
306			let collator = ensure_signed(origin)?;
307			let orbiter = T::Lookup::lookup(orbiter)?;
308
309			Self::do_remove_orbiter_from_pool(collator, orbiter)
310		}
311
312		/// Remove the caller from the specified collator pool
313		#[pallet::call_index(2)]
314		#[pallet::weight(T::WeightInfo::orbiter_leave_collator_pool())]
315		pub fn orbiter_leave_collator_pool(
316			origin: OriginFor<T>,
317			collator: <T::Lookup as StaticLookup>::Source,
318		) -> DispatchResult {
319			let orbiter = ensure_signed(origin)?;
320			let collator = T::Lookup::lookup(collator)?;
321
322			Self::do_remove_orbiter_from_pool(collator, orbiter)
323		}
324
325		/// Registering as an orbiter
326		#[pallet::call_index(3)]
327		#[pallet::weight(T::WeightInfo::orbiter_register())]
328		pub fn orbiter_register(origin: OriginFor<T>) -> DispatchResult {
329			let orbiter = ensure_signed(origin)?;
330
331			if let Some(min_orbiter_deposit) = MinOrbiterDeposit::<T>::get() {
332				// The use of `ensure_reserved_named` allows to update the deposit amount in case a
333				// deposit has already been made.
334				T::Currency::ensure_reserved_named(
335					&T::OrbiterReserveIdentifier::get(),
336					&orbiter,
337					min_orbiter_deposit,
338				)?;
339				RegisteredOrbiter::<T>::insert(&orbiter, true);
340				Self::deposit_event(Event::OrbiterRegistered {
341					account: orbiter,
342					deposit: min_orbiter_deposit,
343				});
344				Ok(())
345			} else {
346				Err(Error::<T>::MinOrbiterDepositNotSet.into())
347			}
348		}
349
350		/// Deregistering from orbiters
351		#[pallet::call_index(4)]
352		#[pallet::weight(T::WeightInfo::orbiter_unregister(*collators_pool_count))]
353		pub fn orbiter_unregister(
354			origin: OriginFor<T>,
355			collators_pool_count: u32,
356		) -> DispatchResult {
357			let orbiter = ensure_signed(origin)?;
358
359			// We have to make sure that the `collators_pool_count` parameter is large enough,
360			// because its value is used to calculate the weight of this extrinsic
361			ensure!(
362				collators_pool_count >= CollatorsPool::<T>::count(),
363				Error::<T>::CollatorsPoolCountTooLow
364			);
365
366			// Ensure that the orbiter is not in any pool
367			ensure!(
368				!CollatorsPool::<T>::iter_values()
369					.any(|collator_pool| collator_pool.contains_orbiter(&orbiter)),
370				Error::<T>::OrbiterStillInAPool,
371			);
372
373			T::Currency::unreserve_all_named(&T::OrbiterReserveIdentifier::get(), &orbiter);
374			RegisteredOrbiter::<T>::remove(&orbiter);
375			Self::deposit_event(Event::OrbiterUnregistered { account: orbiter });
376
377			Ok(())
378		}
379
380		/// Add a collator to orbiters program.
381		#[pallet::call_index(5)]
382		#[pallet::weight(T::WeightInfo::add_collator())]
383		pub fn add_collator(
384			origin: OriginFor<T>,
385			collator: <T::Lookup as StaticLookup>::Source,
386		) -> DispatchResult {
387			T::AddCollatorOrigin::ensure_origin(origin)?;
388			let collator = T::Lookup::lookup(collator)?;
389
390			ensure!(
391				CollatorsPool::<T>::get(&collator).is_none(),
392				Error::<T>::CollatorAlreadyAdded
393			);
394
395			CollatorsPool::<T>::insert(collator, CollatorPoolInfo::default());
396
397			Ok(())
398		}
399
400		/// Remove a collator from orbiters program.
401		#[pallet::call_index(6)]
402		#[pallet::weight(T::WeightInfo::remove_collator())]
403		pub fn remove_collator(
404			origin: OriginFor<T>,
405			collator: <T::Lookup as StaticLookup>::Source,
406		) -> DispatchResult {
407			T::DelCollatorOrigin::ensure_origin(origin)?;
408			let collator = T::Lookup::lookup(collator)?;
409
410			// Remove the pool associated to this collator
411			let collator_pool =
412				CollatorsPool::<T>::take(&collator).ok_or(Error::<T>::CollatorNotFound)?;
413
414			// Remove all AccountLookupOverride entries related to this collator
415			for orbiter in collator_pool.get_orbiters() {
416				AccountLookupOverride::<T>::remove(&orbiter);
417			}
418			AccountLookupOverride::<T>::remove(&collator);
419
420			Ok(())
421		}
422	}
423
424	impl<T: Config> Pallet<T> {
425		fn do_remove_orbiter_from_pool(
426			collator: T::AccountId,
427			orbiter: T::AccountId,
428		) -> DispatchResult {
429			let mut collator_pool =
430				CollatorsPool::<T>::get(&collator).ok_or(Error::<T>::CollatorNotFound)?;
431
432			match collator_pool.remove_orbiter(&orbiter) {
433				RemoveOrbiterResult::OrbiterNotFound => {
434					return Err(Error::<T>::OrbiterNotFound.into())
435				}
436				RemoveOrbiterResult::OrbiterRemoved => {
437					Self::deposit_event(Event::OrbiterLeaveCollatorPool {
438						collator: collator.clone(),
439						orbiter,
440					});
441				}
442				RemoveOrbiterResult::OrbiterRemoveScheduled => (),
443			}
444
445			CollatorsPool::<T>::insert(collator, collator_pool);
446			Ok(())
447		}
448		fn on_rotate(round_index: T::RoundIndex) -> Weight {
449			let mut writes = 1;
450			// Update current orbiter for each pool and edit AccountLookupOverride accordingly.
451			CollatorsPool::<T>::translate::<CollatorPoolInfo<T::AccountId>, _>(
452				|collator, mut pool| {
453					let RotateOrbiterResult {
454						maybe_old_orbiter,
455						maybe_next_orbiter,
456					} = pool.rotate_orbiter();
457
458					// remove old orbiter, if any.
459					if let Some(CurrentOrbiter {
460						account_id: ref current_orbiter,
461						removed,
462					}) = maybe_old_orbiter
463					{
464						if removed {
465							Self::deposit_event(Event::OrbiterLeaveCollatorPool {
466								collator: collator.clone(),
467								orbiter: current_orbiter.clone(),
468							});
469						}
470						AccountLookupOverride::<T>::remove(current_orbiter.clone());
471						writes += 1;
472					}
473					if let Some(next_orbiter) = maybe_next_orbiter {
474						// Forbidding the collator to write blocks, it is now up to its orbiters to do it.
475						AccountLookupOverride::<T>::insert(
476							collator.clone(),
477							Option::<T::AccountId>::None,
478						);
479						writes += 1;
480
481						// Insert new current orbiter
482						AccountLookupOverride::<T>::insert(
483							next_orbiter.clone(),
484							Some(collator.clone()),
485						);
486						writes += 1;
487
488						let mut i = Zero::zero();
489						while i < T::RotatePeriod::get() {
490							OrbiterPerRound::<T>::insert(
491								round_index.saturating_add(i),
492								collator.clone(),
493								next_orbiter.clone(),
494							);
495							i += One::one();
496							writes += 1;
497						}
498
499						Self::deposit_event(Event::OrbiterRotation {
500							collator,
501							old_orbiter: maybe_old_orbiter.map(|orbiter| orbiter.account_id),
502							new_orbiter: Some(next_orbiter),
503						});
504					} else {
505						// If there is no more active orbiter, you have to remove the collator override.
506						AccountLookupOverride::<T>::remove(collator.clone());
507						writes += 1;
508						Self::deposit_event(Event::OrbiterRotation {
509							collator,
510							old_orbiter: maybe_old_orbiter.map(|orbiter| orbiter.account_id),
511							new_orbiter: None,
512						});
513					}
514					writes += 1;
515					Some(pool)
516				},
517			);
518			T::DbWeight::get().reads_writes(1, writes)
519		}
520		/// Notify this pallet that a new round begin
521		pub fn on_new_round(round_index: T::RoundIndex) -> Weight {
522			CurrentRound::<T>::put(round_index);
523
524			if ForceRotation::<T>::get() {
525				ForceRotation::<T>::put(false);
526				let _ = Self::on_rotate(round_index);
527				T::WeightInfo::on_new_round()
528			} else if round_index % T::RotatePeriod::get() == Zero::zero() {
529				let _ = Self::on_rotate(round_index);
530				T::WeightInfo::on_new_round()
531			} else {
532				T::DbWeight::get().writes(1)
533			}
534		}
535		/// Notify this pallet that a collator received rewards
536		pub fn distribute_rewards(
537			pay_for_round: T::RoundIndex,
538			collator: T::AccountId,
539			amount: BalanceOf<T>,
540		) -> Weight {
541			if let Some(orbiter) = OrbiterPerRound::<T>::take(pay_for_round, &collator) {
542				if T::Currency::deposit_into_existing(&orbiter, amount).is_ok() {
543					Self::deposit_event(Event::OrbiterRewarded {
544						account: orbiter,
545						rewards: amount,
546					});
547				}
548				T::WeightInfo::distribute_rewards()
549			} else {
550				// writes: take
551				T::DbWeight::get().writes(1)
552			}
553		}
554
555		/// Check if an account is a collator pool account with an
556		/// orbiter assigned for a given round
557		pub fn is_collator_pool_with_active_orbiter(
558			for_round: T::RoundIndex,
559			collator: T::AccountId,
560		) -> bool {
561			OrbiterPerRound::<T>::contains_key(for_round, &collator)
562		}
563	}
564}
565
566impl<T: Config> AccountLookup<T::AccountId> for Pallet<T> {
567	fn lookup_account(nimbus_id: &NimbusId) -> Option<T::AccountId> {
568		let account_id = T::AccountLookup::lookup_account(nimbus_id)?;
569		match AccountLookupOverride::<T>::get(&account_id) {
570			Some(override_) => override_,
571			None => Some(account_id),
572		}
573	}
574}