// This file is part of Bizinikiwi. // Copyright (C) Parity Technologies (UK) Ltd. // SPDX-License-Identifier: Apache-2.0 // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //! Make periodic payment to members of a ranked collective according to rank. #![cfg_attr(not(feature = "std"), no_std)] use core::marker::PhantomData; use frame::{ prelude::*, traits::tokens::{GetSalary, Pay, PaymentStatus}, }; #[cfg(test)] mod tests; #[cfg(feature = "runtime-benchmarks")] mod benchmarking; pub mod weights; pub use pezpallet::*; pub use weights::WeightInfo; /// Payroll cycle. pub type Cycle = u32; /// The status of the pezpallet instance. #[derive(Encode, Decode, Eq, PartialEq, Clone, TypeInfo, MaxEncodedLen, RuntimeDebug)] pub struct StatusType { /// The index of the "current cycle" (i.e. the last cycle being processed). cycle_index: CycleIndex, /// The first block of the "current cycle" (i.e. the last cycle being processed). cycle_start: BlockNumber, /// The total budget available for all payments in the current cycle. budget: Balance, /// The total amount of the payments registered in the current cycle. total_registrations: Balance, /// The total amount of unregistered payments which have been made in the current cycle. total_unregistered_paid: Balance, } /// The state of a specific payment claim. #[derive(Encode, Decode, Eq, PartialEq, Clone, TypeInfo, MaxEncodedLen, RuntimeDebug)] pub enum ClaimState { /// No claim recorded. Nothing, /// Amount reserved when last active. Registered(Balance), /// Amount attempted to be paid when last active as well as the identity of the payment. Attempted { registered: Option, id: Id, amount: Balance }, } use ClaimState::*; /// The status of a single payee/claimant. #[derive(Encode, Decode, Eq, PartialEq, Clone, TypeInfo, MaxEncodedLen, RuntimeDebug)] pub struct ClaimantStatus { /// The most recent cycle in which the claimant was active. last_active: CycleIndex, /// The state of the payment/claim with in the above cycle. status: ClaimState, } #[frame::pezpallet] pub mod pezpallet { use super::*; #[pezpallet::pezpallet] pub struct Pezpallet(PhantomData<(T, I)>); #[pezpallet::config] pub trait Config: pezframe_system::Config { /// Weight information for extrinsics in this pezpallet. type WeightInfo: WeightInfo; /// The runtime event type. #[allow(deprecated)] type RuntimeEvent: From> + IsType<::RuntimeEvent>; /// Means by which we can make payments to accounts. This also defines the currency and the /// balance which we use to denote that currency. type Paymaster: Pay::AccountId, AssetKind = ()>; /// The current membership of payees. type Members: RankedMembers::AccountId>; /// The maximum payout to be made for a single period to an active member of the given rank. /// /// The benchmarks require that this be non-zero for some rank at most 255. type Salary: GetSalary< ::Rank, Self::AccountId, ::Balance, >; /// The number of blocks within a cycle which accounts have to register their intent to /// claim. /// /// The number of blocks between sequential payout cycles is the sum of this and /// `PayoutPeriod`. #[pezpallet::constant] type RegistrationPeriod: Get>; /// The number of blocks within a cycle which accounts have to claim the payout. /// /// The number of blocks between sequential payout cycles is the sum of this and /// `RegistrationPeriod`. #[pezpallet::constant] type PayoutPeriod: Get>; /// The total budget per cycle. /// /// This may change over the course of a cycle without any problem. #[pezpallet::constant] type Budget: Get>; } pub type CycleIndexOf = BlockNumberFor; pub type BalanceOf = <>::Paymaster as Pay>::Balance; pub type IdOf = <>::Paymaster as Pay>::Id; pub type StatusOf = StatusType, BlockNumberFor, BalanceOf>; pub type ClaimantStatusOf = ClaimantStatus, BalanceOf, IdOf>; /// The overall status of the system. #[pezpallet::storage] pub type Status, I: 'static = ()> = StorageValue<_, StatusOf, OptionQuery>; /// The status of a claimant. #[pezpallet::storage] pub type Claimant, I: 'static = ()> = StorageMap<_, Twox64Concat, T::AccountId, ClaimantStatusOf, OptionQuery>; #[pezpallet::event] #[pezpallet::generate_deposit(pub(super) fn deposit_event)] pub enum Event, I: 'static = ()> { /// A member is inducted into the payroll. Inducted { who: T::AccountId }, /// A member registered for a payout. Registered { who: T::AccountId, amount: BalanceOf }, /// A payment happened. Paid { who: T::AccountId, beneficiary: T::AccountId, amount: BalanceOf, id: ::Id, }, /// The next cycle begins. CycleStarted { index: CycleIndexOf }, /// A member swapped their account. Swapped { who: T::AccountId, new_who: T::AccountId }, } #[pezpallet::error] pub enum Error { /// The salary system has already been started. AlreadyStarted, /// The account is not a ranked member. NotMember, /// The account is already inducted. AlreadyInducted, // The account is not yet inducted into the system. NotInducted, /// The member does not have a current valid claim. NoClaim, /// The member's claim is zero. ClaimZero, /// Current cycle's registration period is over. TooLate, /// Current cycle's payment period is not yet begun. TooEarly, /// Cycle is not yet over. NotYet, /// The payout cycles have not yet started. NotStarted, /// There is no budget left for the payout. Bankrupt, /// There was some issue with the mechanism of payment. PayError, /// The payment has neither failed nor succeeded yet. Inconclusive, /// The cycle is after that in which the payment was made. NotCurrent, } #[pezpallet::call] impl, I: 'static> Pezpallet { /// Start the first payout cycle. /// /// - `origin`: A `Signed` origin of an account. #[pezpallet::weight(T::WeightInfo::init())] #[pezpallet::call_index(0)] pub fn init(origin: OriginFor) -> DispatchResultWithPostInfo { ensure_signed(origin)?; let now = pezframe_system::Pezpallet::::block_number(); ensure!(!Status::::exists(), Error::::AlreadyStarted); let status = StatusType { cycle_index: Zero::zero(), cycle_start: now, budget: T::Budget::get(), total_registrations: Zero::zero(), total_unregistered_paid: Zero::zero(), }; Status::::put(&status); Self::deposit_event(Event::::CycleStarted { index: status.cycle_index }); Ok(Pays::No.into()) } /// Move to next payout cycle, assuming that the present block is now within that cycle. /// /// - `origin`: A `Signed` origin of an account. #[pezpallet::weight(T::WeightInfo::bump())] #[pezpallet::call_index(1)] pub fn bump(origin: OriginFor) -> DispatchResultWithPostInfo { ensure_signed(origin)?; let now = pezframe_system::Pezpallet::::block_number(); let cycle_period = Self::cycle_period(); let mut status = Status::::get().ok_or(Error::::NotStarted)?; status.cycle_start.saturating_accrue(cycle_period); ensure!(now >= status.cycle_start, Error::::NotYet); status.cycle_index.saturating_inc(); status.budget = T::Budget::get(); status.total_registrations = Zero::zero(); status.total_unregistered_paid = Zero::zero(); Status::::put(&status); Self::deposit_event(Event::::CycleStarted { index: status.cycle_index }); Ok(Pays::No.into()) } /// Induct oneself into the payout system. #[pezpallet::weight(T::WeightInfo::induct())] #[pezpallet::call_index(2)] pub fn induct(origin: OriginFor) -> DispatchResultWithPostInfo { let who = ensure_signed(origin)?; let cycle_index = Status::::get().ok_or(Error::::NotStarted)?.cycle_index; T::Members::rank_of(&who).ok_or(Error::::NotMember)?; ensure!(!Claimant::::contains_key(&who), Error::::AlreadyInducted); Claimant::::insert( &who, ClaimantStatus { last_active: cycle_index, status: Nothing }, ); Self::deposit_event(Event::::Inducted { who }); Ok(Pays::No.into()) } /// Register for a payout. /// /// Will only work if we are in the first `RegistrationPeriod` blocks since the cycle /// started. /// /// - `origin`: A `Signed` origin of an account which is a member of `Members`. #[pezpallet::weight(T::WeightInfo::register())] #[pezpallet::call_index(3)] pub fn register(origin: OriginFor) -> DispatchResultWithPostInfo { let who = ensure_signed(origin)?; let rank = T::Members::rank_of(&who).ok_or(Error::::NotMember)?; let mut status = Status::::get().ok_or(Error::::NotStarted)?; let mut claimant = Claimant::::get(&who).ok_or(Error::::NotInducted)?; let now = pezframe_system::Pezpallet::::block_number(); ensure!( now < status.cycle_start + T::RegistrationPeriod::get(), Error::::TooLate ); ensure!(claimant.last_active < status.cycle_index, Error::::NoClaim); let payout = T::Salary::get_salary(rank, &who); ensure!(!payout.is_zero(), Error::::ClaimZero); claimant.last_active = status.cycle_index; claimant.status = Registered(payout); status.total_registrations.saturating_accrue(payout); Claimant::::insert(&who, &claimant); Status::::put(&status); Self::deposit_event(Event::::Registered { who, amount: payout }); Ok(Pays::No.into()) } /// Request a payout. /// /// Will only work if we are after the first `RegistrationPeriod` blocks since the cycle /// started but by no more than `PayoutPeriod` blocks. /// /// - `origin`: A `Signed` origin of an account which is a member of `Members`. #[pezpallet::weight(T::WeightInfo::payout())] #[pezpallet::call_index(4)] pub fn payout(origin: OriginFor) -> DispatchResultWithPostInfo { let who = ensure_signed(origin)?; Self::do_payout(who.clone(), who)?; Ok(Pays::No.into()) } /// Request a payout to a secondary account. /// /// Will only work if we are after the first `RegistrationPeriod` blocks since the cycle /// started but by no more than `PayoutPeriod` blocks. /// /// - `origin`: A `Signed` origin of an account which is a member of `Members`. /// - `beneficiary`: The account to receive payment. #[pezpallet::weight(T::WeightInfo::payout_other())] #[pezpallet::call_index(5)] pub fn payout_other( origin: OriginFor, beneficiary: T::AccountId, ) -> DispatchResultWithPostInfo { let who = ensure_signed(origin)?; Self::do_payout(who, beneficiary)?; Ok(Pays::No.into()) } /// Update a payment's status; if it failed, alter the state so the payment can be retried. /// /// This must be called within the same cycle as the failed payment. It will fail with /// `Event::NotCurrent` otherwise. /// /// - `origin`: A `Signed` origin of an account which is a member of `Members` who has /// received a payment this cycle. #[pezpallet::weight(T::WeightInfo::check_payment())] #[pezpallet::call_index(6)] pub fn check_payment(origin: OriginFor) -> DispatchResultWithPostInfo { let who = ensure_signed(origin)?; let mut status = Status::::get().ok_or(Error::::NotStarted)?; let mut claimant = Claimant::::get(&who).ok_or(Error::::NotInducted)?; ensure!(claimant.last_active == status.cycle_index, Error::::NotCurrent); let (id, registered, amount) = match claimant.status { Attempted { id, registered, amount } => (id, registered, amount), _ => return Err(Error::::NoClaim.into()), }; match T::Paymaster::check_payment(id) { PaymentStatus::Failure => { // Payment failed: we reset back to the status prior to payment. if let Some(amount) = registered { // Account registered; this makes it simple to roll back and allow retry. claimant.status = ClaimState::Registered(amount); } else { // Account didn't register; we set it to `Nothing` but must decrement // the `last_active` also to ensure a retry works. claimant.last_active.saturating_reduce(1u32.into()); claimant.status = ClaimState::Nothing; // Since it is not registered, we must walk back our counter for what has // been paid. status.total_unregistered_paid.saturating_reduce(amount); } }, PaymentStatus::Success => claimant.status = ClaimState::Nothing, _ => return Err(Error::::Inconclusive.into()), } Claimant::::insert(&who, &claimant); Status::::put(&status); Ok(Pays::No.into()) } } impl, I: 'static> Pezpallet { pub fn status() -> Option> { Status::::get() } pub fn last_active(who: &T::AccountId) -> Result, DispatchError> { Ok(Claimant::::get(&who).ok_or(Error::::NotInducted)?.last_active) } pub fn cycle_period() -> BlockNumberFor { T::RegistrationPeriod::get() + T::PayoutPeriod::get() } fn do_payout(who: T::AccountId, beneficiary: T::AccountId) -> DispatchResult { let mut status = Status::::get().ok_or(Error::::NotStarted)?; let mut claimant = Claimant::::get(&who).ok_or(Error::::NotInducted)?; let now = pezframe_system::Pezpallet::::block_number(); ensure!( now >= status.cycle_start + T::RegistrationPeriod::get(), Error::::TooEarly, ); let (payout, registered) = match claimant.status { Registered(unpaid) if claimant.last_active == status.cycle_index => { // Registered for this cycle. Pay accordingly. let payout = if status.total_registrations <= status.budget { // Can pay in full. unpaid } else { // Must be reduced pro-rata Perbill::from_rational(status.budget, status.total_registrations) .mul_floor(unpaid) }; (payout, Some(unpaid)) }, Nothing | Attempted { .. } | Registered(_) if claimant.last_active < status.cycle_index => { // Not registered for this cycle (or stale registration from previous cycle). // Pay from whatever is left. let rank = T::Members::rank_of(&who).ok_or(Error::::NotMember)?; let ideal_payout = T::Salary::get_salary(rank, &who); let pot = status .budget .saturating_sub(status.total_registrations) .saturating_sub(status.total_unregistered_paid); let payout = ideal_payout.min(pot); ensure!(!payout.is_zero(), Error::::ClaimZero); status.total_unregistered_paid.saturating_accrue(payout); (payout, None) }, _ => return Err(Error::::NoClaim.into()), }; claimant.last_active = status.cycle_index; let id = T::Paymaster::pay(&beneficiary, (), payout).map_err(|_| Error::::PayError)?; claimant.status = Attempted { registered, id, amount: payout }; Claimant::::insert(&who, &claimant); Status::::put(&status); Self::deposit_event(Event::::Paid { who, beneficiary, amount: payout, id }); Ok(()) } } } impl, I: 'static> RankedMembersSwapHandler::Rank> for Pezpallet { fn swapped( who: &T::AccountId, new_who: &T::AccountId, _rank: ::Rank, ) { if who == new_who { defensive!("Should not try to swap with self"); return; } if Claimant::::contains_key(new_who) { defensive!("Should not try to overwrite existing claimant"); return; } let Some(claimant) = Claimant::::take(who) else { defensive!("Claimant should exist when swapping"); return; }; Claimant::::insert(new_who, claimant); Self::deposit_event(Event::::Swapped { who: who.clone(), new_who: new_who.clone() }); } } #[cfg(feature = "runtime-benchmarks")] impl, I: 'static> pezpallet_ranked_collective::BenchmarkSetup<::AccountId> for Pezpallet { fn ensure_member(who: &::AccountId) { Self::init(pezframe_system::RawOrigin::Signed(who.clone()).into()).unwrap(); Self::induct(pezframe_system::RawOrigin::Signed(who.clone()).into()).unwrap(); } }