referrerpolicy=no-referrer-when-downgrade

pallet_salary/
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//! Make periodic payment to members of a ranked collective according to rank.
19
20#![cfg_attr(not(feature = "std"), no_std)]
21
22use core::marker::PhantomData;
23use frame::{
24	prelude::*,
25	traits::tokens::{GetSalary, Pay, PaymentStatus},
26};
27
28#[cfg(test)]
29mod tests;
30
31#[cfg(feature = "runtime-benchmarks")]
32mod benchmarking;
33pub mod weights;
34
35pub use pallet::*;
36pub use weights::WeightInfo;
37
38/// Payroll cycle.
39pub type Cycle = u32;
40
41/// The status of the pallet instance.
42#[derive(Encode, Decode, Eq, PartialEq, Clone, TypeInfo, MaxEncodedLen, RuntimeDebug)]
43pub struct StatusType<CycleIndex, BlockNumber, Balance> {
44	/// The index of the "current cycle" (i.e. the last cycle being processed).
45	cycle_index: CycleIndex,
46	/// The first block of the "current cycle" (i.e. the last cycle being processed).
47	cycle_start: BlockNumber,
48	/// The total budget available for all payments in the current cycle.
49	budget: Balance,
50	/// The total amount of the payments registered in the current cycle.
51	total_registrations: Balance,
52	/// The total amount of unregistered payments which have been made in the current cycle.
53	total_unregistered_paid: Balance,
54}
55
56/// The state of a specific payment claim.
57#[derive(Encode, Decode, Eq, PartialEq, Clone, TypeInfo, MaxEncodedLen, RuntimeDebug)]
58pub enum ClaimState<Balance, Id> {
59	/// No claim recorded.
60	Nothing,
61	/// Amount reserved when last active.
62	Registered(Balance),
63	/// Amount attempted to be paid when last active as well as the identity of the payment.
64	Attempted { registered: Option<Balance>, id: Id, amount: Balance },
65}
66
67use ClaimState::*;
68
69/// The status of a single payee/claimant.
70#[derive(Encode, Decode, Eq, PartialEq, Clone, TypeInfo, MaxEncodedLen, RuntimeDebug)]
71pub struct ClaimantStatus<CycleIndex, Balance, Id> {
72	/// The most recent cycle in which the claimant was active.
73	last_active: CycleIndex,
74	/// The state of the payment/claim with in the above cycle.
75	status: ClaimState<Balance, Id>,
76}
77
78#[frame::pallet]
79pub mod pallet {
80	use super::*;
81	#[pallet::pallet]
82	pub struct Pallet<T, I = ()>(PhantomData<(T, I)>);
83
84	#[pallet::config]
85	pub trait Config<I: 'static = ()>: frame_system::Config {
86		/// Weight information for extrinsics in this pallet.
87		type WeightInfo: WeightInfo;
88
89		/// The runtime event type.
90		#[allow(deprecated)]
91		type RuntimeEvent: From<Event<Self, I>>
92			+ IsType<<Self as frame_system::Config>::RuntimeEvent>;
93
94		/// Means by which we can make payments to accounts. This also defines the currency and the
95		/// balance which we use to denote that currency.
96		type Paymaster: Pay<Beneficiary = <Self as frame_system::Config>::AccountId, AssetKind = ()>;
97
98		/// The current membership of payees.
99		type Members: RankedMembers<AccountId = <Self as frame_system::Config>::AccountId>;
100
101		/// The maximum payout to be made for a single period to an active member of the given rank.
102		///
103		/// The benchmarks require that this be non-zero for some rank at most 255.
104		type Salary: GetSalary<
105			<Self::Members as RankedMembers>::Rank,
106			Self::AccountId,
107			<Self::Paymaster as Pay>::Balance,
108		>;
109
110		/// The number of blocks within a cycle which accounts have to register their intent to
111		/// claim.
112		///
113		/// The number of blocks between sequential payout cycles is the sum of this and
114		/// `PayoutPeriod`.
115		#[pallet::constant]
116		type RegistrationPeriod: Get<BlockNumberFor<Self>>;
117
118		/// The number of blocks within a cycle which accounts have to claim the payout.
119		///
120		/// The number of blocks between sequential payout cycles is the sum of this and
121		/// `RegistrationPeriod`.
122		#[pallet::constant]
123		type PayoutPeriod: Get<BlockNumberFor<Self>>;
124
125		/// The total budget per cycle.
126		///
127		/// This may change over the course of a cycle without any problem.
128		#[pallet::constant]
129		type Budget: Get<BalanceOf<Self, I>>;
130	}
131
132	pub type CycleIndexOf<T> = BlockNumberFor<T>;
133	pub type BalanceOf<T, I> = <<T as Config<I>>::Paymaster as Pay>::Balance;
134	pub type IdOf<T, I> = <<T as Config<I>>::Paymaster as Pay>::Id;
135	pub type StatusOf<T, I> = StatusType<CycleIndexOf<T>, BlockNumberFor<T>, BalanceOf<T, I>>;
136	pub type ClaimantStatusOf<T, I> = ClaimantStatus<CycleIndexOf<T>, BalanceOf<T, I>, IdOf<T, I>>;
137
138	/// The overall status of the system.
139	#[pallet::storage]
140	pub type Status<T: Config<I>, I: 'static = ()> = StorageValue<_, StatusOf<T, I>, OptionQuery>;
141
142	/// The status of a claimant.
143	#[pallet::storage]
144	pub type Claimant<T: Config<I>, I: 'static = ()> =
145		StorageMap<_, Twox64Concat, T::AccountId, ClaimantStatusOf<T, I>, OptionQuery>;
146
147	#[pallet::event]
148	#[pallet::generate_deposit(pub(super) fn deposit_event)]
149	pub enum Event<T: Config<I>, I: 'static = ()> {
150		/// A member is inducted into the payroll.
151		Inducted { who: T::AccountId },
152		/// A member registered for a payout.
153		Registered { who: T::AccountId, amount: BalanceOf<T, I> },
154		/// A payment happened.
155		Paid {
156			who: T::AccountId,
157			beneficiary: T::AccountId,
158			amount: BalanceOf<T, I>,
159			id: <T::Paymaster as Pay>::Id,
160		},
161		/// The next cycle begins.
162		CycleStarted { index: CycleIndexOf<T> },
163		/// A member swapped their account.
164		Swapped { who: T::AccountId, new_who: T::AccountId },
165	}
166
167	#[pallet::error]
168	pub enum Error<T, I = ()> {
169		/// The salary system has already been started.
170		AlreadyStarted,
171		/// The account is not a ranked member.
172		NotMember,
173		/// The account is already inducted.
174		AlreadyInducted,
175		// The account is not yet inducted into the system.
176		NotInducted,
177		/// The member does not have a current valid claim.
178		NoClaim,
179		/// The member's claim is zero.
180		ClaimZero,
181		/// Current cycle's registration period is over.
182		TooLate,
183		/// Current cycle's payment period is not yet begun.
184		TooEarly,
185		/// Cycle is not yet over.
186		NotYet,
187		/// The payout cycles have not yet started.
188		NotStarted,
189		/// There is no budget left for the payout.
190		Bankrupt,
191		/// There was some issue with the mechanism of payment.
192		PayError,
193		/// The payment has neither failed nor succeeded yet.
194		Inconclusive,
195		/// The cycle is after that in which the payment was made.
196		NotCurrent,
197	}
198
199	#[pallet::call]
200	impl<T: Config<I>, I: 'static> Pallet<T, I> {
201		/// Start the first payout cycle.
202		///
203		/// - `origin`: A `Signed` origin of an account.
204		#[pallet::weight(T::WeightInfo::init())]
205		#[pallet::call_index(0)]
206		pub fn init(origin: OriginFor<T>) -> DispatchResultWithPostInfo {
207			ensure_signed(origin)?;
208			let now = frame_system::Pallet::<T>::block_number();
209			ensure!(!Status::<T, I>::exists(), Error::<T, I>::AlreadyStarted);
210			let status = StatusType {
211				cycle_index: Zero::zero(),
212				cycle_start: now,
213				budget: T::Budget::get(),
214				total_registrations: Zero::zero(),
215				total_unregistered_paid: Zero::zero(),
216			};
217			Status::<T, I>::put(&status);
218
219			Self::deposit_event(Event::<T, I>::CycleStarted { index: status.cycle_index });
220			Ok(Pays::No.into())
221		}
222
223		/// Move to next payout cycle, assuming that the present block is now within that cycle.
224		///
225		/// - `origin`: A `Signed` origin of an account.
226		#[pallet::weight(T::WeightInfo::bump())]
227		#[pallet::call_index(1)]
228		pub fn bump(origin: OriginFor<T>) -> DispatchResultWithPostInfo {
229			ensure_signed(origin)?;
230			let now = frame_system::Pallet::<T>::block_number();
231			let cycle_period = Self::cycle_period();
232			let mut status = Status::<T, I>::get().ok_or(Error::<T, I>::NotStarted)?;
233			status.cycle_start.saturating_accrue(cycle_period);
234			ensure!(now >= status.cycle_start, Error::<T, I>::NotYet);
235			status.cycle_index.saturating_inc();
236			status.budget = T::Budget::get();
237			status.total_registrations = Zero::zero();
238			status.total_unregistered_paid = Zero::zero();
239			Status::<T, I>::put(&status);
240
241			Self::deposit_event(Event::<T, I>::CycleStarted { index: status.cycle_index });
242			Ok(Pays::No.into())
243		}
244
245		/// Induct oneself into the payout system.
246		#[pallet::weight(T::WeightInfo::induct())]
247		#[pallet::call_index(2)]
248		pub fn induct(origin: OriginFor<T>) -> DispatchResultWithPostInfo {
249			let who = ensure_signed(origin)?;
250			let cycle_index = Status::<T, I>::get().ok_or(Error::<T, I>::NotStarted)?.cycle_index;
251			T::Members::rank_of(&who).ok_or(Error::<T, I>::NotMember)?;
252			ensure!(!Claimant::<T, I>::contains_key(&who), Error::<T, I>::AlreadyInducted);
253
254			Claimant::<T, I>::insert(
255				&who,
256				ClaimantStatus { last_active: cycle_index, status: Nothing },
257			);
258
259			Self::deposit_event(Event::<T, I>::Inducted { who });
260			Ok(Pays::No.into())
261		}
262
263		/// Register for a payout.
264		///
265		/// Will only work if we are in the first `RegistrationPeriod` blocks since the cycle
266		/// started.
267		///
268		/// - `origin`: A `Signed` origin of an account which is a member of `Members`.
269		#[pallet::weight(T::WeightInfo::register())]
270		#[pallet::call_index(3)]
271		pub fn register(origin: OriginFor<T>) -> DispatchResultWithPostInfo {
272			let who = ensure_signed(origin)?;
273			let rank = T::Members::rank_of(&who).ok_or(Error::<T, I>::NotMember)?;
274			let mut status = Status::<T, I>::get().ok_or(Error::<T, I>::NotStarted)?;
275			let mut claimant = Claimant::<T, I>::get(&who).ok_or(Error::<T, I>::NotInducted)?;
276			let now = frame_system::Pallet::<T>::block_number();
277			ensure!(
278				now < status.cycle_start + T::RegistrationPeriod::get(),
279				Error::<T, I>::TooLate
280			);
281			ensure!(claimant.last_active < status.cycle_index, Error::<T, I>::NoClaim);
282			let payout = T::Salary::get_salary(rank, &who);
283			ensure!(!payout.is_zero(), Error::<T, I>::ClaimZero);
284			claimant.last_active = status.cycle_index;
285			claimant.status = Registered(payout);
286			status.total_registrations.saturating_accrue(payout);
287
288			Claimant::<T, I>::insert(&who, &claimant);
289			Status::<T, I>::put(&status);
290
291			Self::deposit_event(Event::<T, I>::Registered { who, amount: payout });
292			Ok(Pays::No.into())
293		}
294
295		/// Request a payout.
296		///
297		/// Will only work if we are after the first `RegistrationPeriod` blocks since the cycle
298		/// started but by no more than `PayoutPeriod` blocks.
299		///
300		/// - `origin`: A `Signed` origin of an account which is a member of `Members`.
301		#[pallet::weight(T::WeightInfo::payout())]
302		#[pallet::call_index(4)]
303		pub fn payout(origin: OriginFor<T>) -> DispatchResultWithPostInfo {
304			let who = ensure_signed(origin)?;
305			Self::do_payout(who.clone(), who)?;
306			Ok(Pays::No.into())
307		}
308
309		/// Request a payout to a secondary account.
310		///
311		/// Will only work if we are after the first `RegistrationPeriod` blocks since the cycle
312		/// started but by no more than `PayoutPeriod` blocks.
313		///
314		/// - `origin`: A `Signed` origin of an account which is a member of `Members`.
315		/// - `beneficiary`: The account to receive payment.
316		#[pallet::weight(T::WeightInfo::payout_other())]
317		#[pallet::call_index(5)]
318		pub fn payout_other(
319			origin: OriginFor<T>,
320			beneficiary: T::AccountId,
321		) -> DispatchResultWithPostInfo {
322			let who = ensure_signed(origin)?;
323			Self::do_payout(who, beneficiary)?;
324			Ok(Pays::No.into())
325		}
326
327		/// Update a payment's status; if it failed, alter the state so the payment can be retried.
328		///
329		/// This must be called within the same cycle as the failed payment. It will fail with
330		/// `Event::NotCurrent` otherwise.
331		///
332		/// - `origin`: A `Signed` origin of an account which is a member of `Members` who has
333		///   received a payment this cycle.
334		#[pallet::weight(T::WeightInfo::check_payment())]
335		#[pallet::call_index(6)]
336		pub fn check_payment(origin: OriginFor<T>) -> DispatchResultWithPostInfo {
337			let who = ensure_signed(origin)?;
338
339			let mut status = Status::<T, I>::get().ok_or(Error::<T, I>::NotStarted)?;
340			let mut claimant = Claimant::<T, I>::get(&who).ok_or(Error::<T, I>::NotInducted)?;
341			ensure!(claimant.last_active == status.cycle_index, Error::<T, I>::NotCurrent);
342			let (id, registered, amount) = match claimant.status {
343				Attempted { id, registered, amount } => (id, registered, amount),
344				_ => return Err(Error::<T, I>::NoClaim.into()),
345			};
346			match T::Paymaster::check_payment(id) {
347				PaymentStatus::Failure => {
348					// Payment failed: we reset back to the status prior to payment.
349					if let Some(amount) = registered {
350						// Account registered; this makes it simple to roll back and allow retry.
351						claimant.status = ClaimState::Registered(amount);
352					} else {
353						// Account didn't register; we set it to `Nothing` but must decrement
354						// the `last_active` also to ensure a retry works.
355						claimant.last_active.saturating_reduce(1u32.into());
356						claimant.status = ClaimState::Nothing;
357						// Since it is not registered, we must walk back our counter for what has
358						// been paid.
359						status.total_unregistered_paid.saturating_reduce(amount);
360					}
361				},
362				PaymentStatus::Success => claimant.status = ClaimState::Nothing,
363				_ => return Err(Error::<T, I>::Inconclusive.into()),
364			}
365			Claimant::<T, I>::insert(&who, &claimant);
366			Status::<T, I>::put(&status);
367
368			Ok(Pays::No.into())
369		}
370	}
371
372	impl<T: Config<I>, I: 'static> Pallet<T, I> {
373		pub fn status() -> Option<StatusOf<T, I>> {
374			Status::<T, I>::get()
375		}
376		pub fn last_active(who: &T::AccountId) -> Result<CycleIndexOf<T>, DispatchError> {
377			Ok(Claimant::<T, I>::get(&who).ok_or(Error::<T, I>::NotInducted)?.last_active)
378		}
379		pub fn cycle_period() -> BlockNumberFor<T> {
380			T::RegistrationPeriod::get() + T::PayoutPeriod::get()
381		}
382		fn do_payout(who: T::AccountId, beneficiary: T::AccountId) -> DispatchResult {
383			let mut status = Status::<T, I>::get().ok_or(Error::<T, I>::NotStarted)?;
384			let mut claimant = Claimant::<T, I>::get(&who).ok_or(Error::<T, I>::NotInducted)?;
385
386			let now = frame_system::Pallet::<T>::block_number();
387			ensure!(
388				now >= status.cycle_start + T::RegistrationPeriod::get(),
389				Error::<T, I>::TooEarly,
390			);
391
392			let (payout, registered) = match claimant.status {
393				Registered(unpaid) if claimant.last_active == status.cycle_index => {
394					// Registered for this cycle. Pay accordingly.
395					let payout = if status.total_registrations <= status.budget {
396						// Can pay in full.
397						unpaid
398					} else {
399						// Must be reduced pro-rata
400						Perbill::from_rational(status.budget, status.total_registrations)
401							.mul_floor(unpaid)
402					};
403					(payout, Some(unpaid))
404				},
405				Nothing | Attempted { .. } if claimant.last_active < status.cycle_index => {
406					// Not registered for this cycle. Pay from whatever is left.
407					let rank = T::Members::rank_of(&who).ok_or(Error::<T, I>::NotMember)?;
408					let ideal_payout = T::Salary::get_salary(rank, &who);
409
410					let pot = status
411						.budget
412						.saturating_sub(status.total_registrations)
413						.saturating_sub(status.total_unregistered_paid);
414
415					let payout = ideal_payout.min(pot);
416					ensure!(!payout.is_zero(), Error::<T, I>::ClaimZero);
417
418					status.total_unregistered_paid.saturating_accrue(payout);
419					(payout, None)
420				},
421				_ => return Err(Error::<T, I>::NoClaim.into()),
422			};
423
424			claimant.last_active = status.cycle_index;
425
426			let id =
427				T::Paymaster::pay(&beneficiary, (), payout).map_err(|_| Error::<T, I>::PayError)?;
428
429			claimant.status = Attempted { registered, id, amount: payout };
430
431			Claimant::<T, I>::insert(&who, &claimant);
432			Status::<T, I>::put(&status);
433
434			Self::deposit_event(Event::<T, I>::Paid { who, beneficiary, amount: payout, id });
435			Ok(())
436		}
437	}
438}
439
440impl<T: Config<I>, I: 'static>
441	RankedMembersSwapHandler<T::AccountId, <T::Members as RankedMembers>::Rank> for Pallet<T, I>
442{
443	fn swapped(
444		who: &T::AccountId,
445		new_who: &T::AccountId,
446		_rank: <T::Members as RankedMembers>::Rank,
447	) {
448		if who == new_who {
449			defensive!("Should not try to swap with self");
450			return;
451		}
452		if Claimant::<T, I>::contains_key(new_who) {
453			defensive!("Should not try to overwrite existing claimant");
454			return;
455		}
456
457		let Some(claimant) = Claimant::<T, I>::take(who) else {
458			defensive!("Claimant should exist when swapping");
459			return;
460		};
461
462		Claimant::<T, I>::insert(new_who, claimant);
463		Self::deposit_event(Event::<T, I>::Swapped { who: who.clone(), new_who: new_who.clone() });
464	}
465}
466
467#[cfg(feature = "runtime-benchmarks")]
468impl<T: Config<I>, I: 'static>
469	pallet_ranked_collective::BenchmarkSetup<<T as frame_system::Config>::AccountId> for Pallet<T, I>
470{
471	fn ensure_member(who: &<T as frame_system::Config>::AccountId) {
472		Self::init(frame_system::RawOrigin::Signed(who.clone()).into()).unwrap();
473		Self::induct(frame_system::RawOrigin::Signed(who.clone()).into()).unwrap();
474	}
475}