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

            
31
pub mod types;
32
pub mod weights;
33

            
34
#[cfg(any(test, feature = "runtime-benchmarks"))]
35
mod benchmarks;
36
#[cfg(test)]
37
mod mock;
38
#[cfg(test)]
39
mod tests;
40

            
41
pub use pallet::*;
42
pub use types::*;
43
pub use weights::WeightInfo;
44

            
45
use frame_support::pallet;
46
use nimbus_primitives::{AccountLookup, NimbusId};
47

            
48
#[pallet]
49
pub 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
2
		fn default() -> Self {
164
2
			Self {
165
2
				min_orbiter_deposit: One::one(),
166
2
			}
167
2
		}
168
	}
169

            
170
	#[pallet::genesis_build]
171
	impl<T: Config> BuildGenesisConfig for GenesisConfig<T> {
172
10
		fn build(&self) {
173
10
			assert!(
174
10
				self.min_orbiter_deposit > Zero::zero(),
175
				"Minimal orbiter deposit should be greater than zero"
176
			);
177
10
			MinOrbiterDeposit::<T>::put(self.min_orbiter_deposit)
178
10
		}
179
	}
180

            
181
	#[pallet::hooks]
182
	impl<T: Config> Hooks<BlockNumberFor<T>> for Pallet<T> {
183
68
		fn on_initialize(_: BlockNumberFor<T>) -> Weight {
184
			// Prune old OrbiterPerRound entries
185
4
			if let Some(round_to_prune) =
186
68
				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
4
				let result = OrbiterPerRound::<T>::clear_prefix(round_to_prune, u32::MAX, None);
194
4
				T::WeightInfo::on_initialize(result.unique)
195
			} else {
196
64
				T::DbWeight::get().reads(1)
197
			}
198
68
		}
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
11
		) -> DispatchResult {
266
11
			let collator = ensure_signed(origin)?;
267
11
			let orbiter = T::Lookup::lookup(orbiter)?;
268

            
269
10
			let mut collator_pool =
270
11
				CollatorsPool::<T>::get(&collator).ok_or(Error::<T>::CollatorNotFound)?;
271
10
			let orbiters = collator_pool.get_orbiters();
272
10
			ensure!(
273
10
				(orbiters.len() as u32) < T::MaxPoolSize::get(),
274
1
				Error::<T>::CollatorPoolTooLarge
275
			);
276
9
			if orbiters.iter().any(|orbiter_| orbiter_ == &orbiter) {
277
1
				return Err(Error::<T>::OrbiterAlreadyInPool.into());
278
8
			}
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
8
			let orbiter_deposit =
285
8
				T::Currency::reserved_balance_named(&T::OrbiterReserveIdentifier::get(), &orbiter);
286
8
			ensure!(
287
8
				orbiter_deposit > BalanceOf::<T>::zero(),
288
1
				Error::<T>::OrbiterDepositNotFound
289
			);
290

            
291
7
			collator_pool.add_orbiter(orbiter.clone());
292
7
			CollatorsPool::<T>::insert(&collator, collator_pool);
293

            
294
7
			Self::deposit_event(Event::OrbiterJoinCollatorPool { collator, orbiter });
295

            
296
7
			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
5
		) -> DispatchResult {
306
5
			let collator = ensure_signed(origin)?;
307
5
			let orbiter = T::Lookup::lookup(orbiter)?;
308

            
309
5
			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
11
		pub fn orbiter_register(origin: OriginFor<T>) -> DispatchResult {
329
11
			let orbiter = ensure_signed(origin)?;
330

            
331
11
			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
11
				T::Currency::ensure_reserved_named(
335
11
					&T::OrbiterReserveIdentifier::get(),
336
11
					&orbiter,
337
11
					min_orbiter_deposit,
338
1
				)?;
339
10
				RegisteredOrbiter::<T>::insert(&orbiter, true);
340
10
				Self::deposit_event(Event::OrbiterRegistered {
341
10
					account: orbiter,
342
10
					deposit: min_orbiter_deposit,
343
10
				});
344
10
				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
2
		) -> DispatchResult {
357
2
			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
2
			ensure!(
362
2
				collators_pool_count >= CollatorsPool::<T>::count(),
363
1
				Error::<T>::CollatorsPoolCountTooLow
364
			);
365

            
366
			// Ensure that the orbiter is not in any pool
367
1
			ensure!(
368
1
				!CollatorsPool::<T>::iter_values()
369
1
					.any(|collator_pool| collator_pool.contains_orbiter(&orbiter)),
370
				Error::<T>::OrbiterStillInAPool,
371
			);
372

            
373
1
			T::Currency::unreserve_all_named(&T::OrbiterReserveIdentifier::get(), &orbiter);
374
1
			RegisteredOrbiter::<T>::remove(&orbiter);
375
1
			Self::deposit_event(Event::OrbiterUnregistered { account: orbiter });
376

            
377
1
			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
7
		) -> DispatchResult {
387
7
			T::AddCollatorOrigin::ensure_origin(origin)?;
388
7
			let collator = T::Lookup::lookup(collator)?;
389

            
390
7
			ensure!(
391
7
				CollatorsPool::<T>::get(&collator).is_none(),
392
1
				Error::<T>::CollatorAlreadyAdded
393
			);
394

            
395
6
			CollatorsPool::<T>::insert(collator, CollatorPoolInfo::default());
396

            
397
6
			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
5
		fn do_remove_orbiter_from_pool(
426
5
			collator: T::AccountId,
427
5
			orbiter: T::AccountId,
428
5
		) -> DispatchResult {
429
4
			let mut collator_pool =
430
5
				CollatorsPool::<T>::get(&collator).ok_or(Error::<T>::CollatorNotFound)?;
431

            
432
4
			match collator_pool.remove_orbiter(&orbiter) {
433
				RemoveOrbiterResult::OrbiterNotFound => {
434
2
					return Err(Error::<T>::OrbiterNotFound.into())
435
				}
436
2
				RemoveOrbiterResult::OrbiterRemoved => {
437
2
					Self::deposit_event(Event::OrbiterLeaveCollatorPool {
438
2
						collator: collator.clone(),
439
2
						orbiter,
440
2
					});
441
2
				}
442
				RemoveOrbiterResult::OrbiterRemoveScheduled => (),
443
			}
444

            
445
2
			CollatorsPool::<T>::insert(collator, collator_pool);
446
2
			Ok(())
447
5
		}
448
53
		fn on_rotate(round_index: T::RoundIndex) -> Weight {
449
53
			let mut writes = 1;
450
			// Update current orbiter for each pool and edit AccountLookupOverride accordingly.
451
53
			CollatorsPool::<T>::translate::<CollatorPoolInfo<T::AccountId>, _>(
452
3
				|collator, mut pool| {
453
					let RotateOrbiterResult {
454
3
						maybe_old_orbiter,
455
3
						maybe_next_orbiter,
456
3
					} = pool.rotate_orbiter();
457

            
458
					// remove old orbiter, if any.
459
					if let Some(CurrentOrbiter {
460
2
						account_id: ref current_orbiter,
461
2
						removed,
462
3
					}) = maybe_old_orbiter
463
					{
464
2
						if removed {
465
							Self::deposit_event(Event::OrbiterLeaveCollatorPool {
466
								collator: collator.clone(),
467
								orbiter: current_orbiter.clone(),
468
							});
469
2
						}
470
2
						AccountLookupOverride::<T>::remove(current_orbiter.clone());
471
2
						writes += 1;
472
1
					}
473
3
					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
3
						AccountLookupOverride::<T>::insert(
476
3
							collator.clone(),
477
3
							Option::<T::AccountId>::None,
478
						);
479
3
						writes += 1;
480

            
481
						// Insert new current orbiter
482
3
						AccountLookupOverride::<T>::insert(
483
3
							next_orbiter.clone(),
484
3
							Some(collator.clone()),
485
						);
486
3
						writes += 1;
487

            
488
3
						let mut i = Zero::zero();
489
9
						while i < T::RotatePeriod::get() {
490
6
							OrbiterPerRound::<T>::insert(
491
6
								round_index.saturating_add(i),
492
6
								collator.clone(),
493
6
								next_orbiter.clone(),
494
6
							);
495
6
							i += One::one();
496
6
							writes += 1;
497
6
						}
498

            
499
3
						Self::deposit_event(Event::OrbiterRotation {
500
3
							collator,
501
3
							old_orbiter: maybe_old_orbiter.map(|orbiter| orbiter.account_id),
502
3
							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
3
					writes += 1;
515
3
					Some(pool)
516
3
				},
517
			);
518
53
			T::DbWeight::get().reads_writes(1, writes)
519
53
		}
520
		/// Notify this pallet that a new round begin
521
95
		pub fn on_new_round(round_index: T::RoundIndex) -> Weight {
522
95
			CurrentRound::<T>::put(round_index);
523

            
524
95
			if ForceRotation::<T>::get() {
525
				ForceRotation::<T>::put(false);
526
				let _ = Self::on_rotate(round_index);
527
				T::WeightInfo::on_new_round()
528
95
			} else if round_index % T::RotatePeriod::get() == Zero::zero() {
529
53
				let _ = Self::on_rotate(round_index);
530
53
				T::WeightInfo::on_new_round()
531
			} else {
532
42
				T::DbWeight::get().writes(1)
533
			}
534
95
		}
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
38
		pub fn is_collator_pool_with_active_orbiter(
558
38
			for_round: T::RoundIndex,
559
38
			collator: T::AccountId,
560
38
		) -> bool {
561
38
			OrbiterPerRound::<T>::contains_key(for_round, &collator)
562
38
		}
563
	}
564
}
565

            
566
impl<T: Config> AccountLookup<T::AccountId> for Pallet<T> {
567
29470
	fn lookup_account(nimbus_id: &NimbusId) -> Option<T::AccountId> {
568
29470
		let account_id = T::AccountLookup::lookup_account(nimbus_id)?;
569
29470
		match AccountLookupOverride::<T>::get(&account_id) {
570
			Some(override_) => override_,
571
29470
			None => Some(account_id),
572
		}
573
29470
	}
574
}