Skip to content

Commit

Permalink
Implements a % cap on staking rewards from era inflation (#1660)
Browse files Browse the repository at this point in the history
This PR implements an (optional) cap of the era inflation that is
allocated to staking rewards. The remaining is minted directly into the
[`RewardRemainder`](https://github.com/paritytech/polkadot-sdk/blob/fb0fd3e62445eb2dee2b2456a0c8574d1ecdcc73/substrate/frame/staking/src/pallet/mod.rs#L160)
account, which is the treasury pot account in Polkadot and Kusama.

The staking pallet now has a percent storage item, `MaxStakersRewards`,
which defines the max percentage of the era inflation that should be
allocated to staking rewards. The remaining era inflation (i.e.
`remaining = max_era_payout - staking_payout.min(staking_payout *
MaxStakersRewards))` is minted directly into the treasury.

The `MaxStakersRewards` can be set by a privileged origin through the
`set_staking_configs` extrinsic.

**To finish**
- [x] run benchmarks for westend-runtime

Replaces #1483
Closes #403

---------

Co-authored-by: command-bot <>
  • Loading branch information
gpestana authored Feb 15, 2024
1 parent 5fc7622 commit fde4447
Show file tree
Hide file tree
Showing 9 changed files with 427 additions and 300 deletions.
206 changes: 105 additions & 101 deletions polkadot/runtime/westend/src/weights/pallet_staking.rs

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions prdoc/pr_1660.prdoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
title: Implements a percentage cap on staking rewards from era inflation

doc:
- audience: Runtime Dev
description: |
The `pallet-staking` exposes a new perbill configuration, `MaxStakersRewards`, which caps the
amount of era inflation that is distributed to the stakers. The remainder of the era
inflation is minted directly into `T::RewardRemainder` account. This allows the runtime to be
configured to assign a minimum inflation value per era to a specific account (e.g. treasury).

crates:
- name: pallet-staking
1 change: 1 addition & 0 deletions substrate/frame/nomination-pools/test-staking/src/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ pub fn new_test_ext() -> sp_io::TestExternalities {
pallet_staking::ConfigOp::Noop,
pallet_staking::ConfigOp::Noop,
pallet_staking::ConfigOp::Noop,
pallet_staking::ConfigOp::Noop,
));
});

Expand Down
7 changes: 6 additions & 1 deletion substrate/frame/staking/src/benchmarking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -855,14 +855,16 @@ benchmarks! {
ConfigOp::Set(u32::MAX),
ConfigOp::Set(u32::MAX),
ConfigOp::Set(Percent::max_value()),
ConfigOp::Set(Perbill::max_value())
ConfigOp::Set(Perbill::max_value()),
ConfigOp::Set(Percent::max_value())
) verify {
assert_eq!(MinNominatorBond::<T>::get(), BalanceOf::<T>::max_value());
assert_eq!(MinValidatorBond::<T>::get(), BalanceOf::<T>::max_value());
assert_eq!(MaxNominatorsCount::<T>::get(), Some(u32::MAX));
assert_eq!(MaxValidatorsCount::<T>::get(), Some(u32::MAX));
assert_eq!(ChillThreshold::<T>::get(), Some(Percent::from_percent(100)));
assert_eq!(MinCommission::<T>::get(), Perbill::from_percent(100));
assert_eq!(MaxStakedRewards::<T>::get(), Some(Percent::from_percent(100)));
}

set_staking_configs_all_remove {
Expand All @@ -873,6 +875,7 @@ benchmarks! {
ConfigOp::Remove,
ConfigOp::Remove,
ConfigOp::Remove,
ConfigOp::Remove,
ConfigOp::Remove
) verify {
assert!(!MinNominatorBond::<T>::exists());
Expand All @@ -881,6 +884,7 @@ benchmarks! {
assert!(!MaxValidatorsCount::<T>::exists());
assert!(!ChillThreshold::<T>::exists());
assert!(!MinCommission::<T>::exists());
assert!(!MaxStakedRewards::<T>::exists());
}

chill_other {
Expand All @@ -904,6 +908,7 @@ benchmarks! {
ConfigOp::Set(0),
ConfigOp::Set(Percent::from_percent(0)),
ConfigOp::Set(Zero::zero()),
ConfigOp::Noop,
)?;

let caller = whitelisted_caller();
Expand Down
14 changes: 11 additions & 3 deletions substrate/frame/staking/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,12 @@
//! ```nocompile
//! remaining_payout = max_yearly_inflation * total_tokens / era_per_year - staker_payout
//! ```
//!
//! Note, however, that it is possible to set a cap on the total `staker_payout` for the era through
//! the `MaxStakersRewards` storage type. The `era_payout` implementor must ensure that the
//! `max_payout = remaining_payout + (staker_payout * max_stakers_rewards)`. The excess payout that
//! is not allocated for stakers is the era remaining reward.
//!
//! The remaining reward is send to the configurable end-point [`Config::RewardRemainder`].
//!
//! ### Reward Calculation
Expand Down Expand Up @@ -897,8 +903,10 @@ impl<Balance: Default> EraPayout<Balance> for () {
/// Adaptor to turn a `PiecewiseLinear` curve definition into an `EraPayout` impl, used for
/// backwards compatibility.
pub struct ConvertCurve<T>(sp_std::marker::PhantomData<T>);
impl<Balance: AtLeast32BitUnsigned + Clone, T: Get<&'static PiecewiseLinear<'static>>>
EraPayout<Balance> for ConvertCurve<T>
impl<Balance, T> EraPayout<Balance> for ConvertCurve<T>
where
Balance: AtLeast32BitUnsigned + Clone + Copy,
T: Get<&'static PiecewiseLinear<'static>>,
{
fn era_payout(
total_staked: Balance,
Expand All @@ -912,7 +920,7 @@ impl<Balance: AtLeast32BitUnsigned + Clone, T: Get<&'static PiecewiseLinear<'sta
// Duration of era; more than u64::MAX is rewarded as u64::MAX.
era_duration_millis,
);
let rest = max_payout.saturating_sub(validator_payout.clone());
let rest = max_payout.saturating_sub(validator_payout);
(validator_payout, rest)
}
}
Expand Down
11 changes: 10 additions & 1 deletion substrate/frame/staking/src/pallet/impls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ use frame_system::{pallet_prelude::BlockNumberFor, RawOrigin};
use pallet_session::historical;
use sp_runtime::{
traits::{Bounded, Convert, One, SaturatedConversion, Saturating, StaticLookup, Zero},
Perbill,
Perbill, Percent,
};
use sp_staking::{
currency_to_vote::CurrencyToVote,
Expand Down Expand Up @@ -507,9 +507,18 @@ impl<T: Config> Pallet<T> {
.saturated_into::<u64>();
let staked = Self::eras_total_stake(&active_era.index);
let issuance = T::Currency::total_issuance();

let (validator_payout, remainder) =
T::EraPayout::era_payout(staked, issuance, era_duration);

let total_payout = validator_payout.saturating_add(remainder);
let max_staked_rewards =
MaxStakedRewards::<T>::get().unwrap_or(Percent::from_percent(100));

// apply cap to validators payout and add difference to remainder.
let validator_payout = validator_payout.min(max_staked_rewards * total_payout);
let remainder = total_payout.saturating_sub(validator_payout);

Self::deposit_event(Event::<T>::EraPaid {
era_index: active_era.index,
validator_payout,
Expand Down
8 changes: 8 additions & 0 deletions substrate/frame/staking/src/pallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,12 @@ pub mod pallet {
#[pallet::getter(fn force_era)]
pub type ForceEra<T> = StorageValue<_, Forcing, ValueQuery>;

/// Maximum staked rewards, i.e. the percentage of the era inflation that
/// is used for stake rewards.
/// See [Era payout](./index.html#era-payout).
#[pallet::storage]
pub type MaxStakedRewards<T> = StorageValue<_, Percent, OptionQuery>;

/// The percentage of the slash that is distributed to reporters.
///
/// The rest of the slashed value is handled by the `Slash`.
Expand Down Expand Up @@ -1717,6 +1723,7 @@ pub mod pallet {
max_validator_count: ConfigOp<u32>,
chill_threshold: ConfigOp<Percent>,
min_commission: ConfigOp<Perbill>,
max_staked_rewards: ConfigOp<Percent>,
) -> DispatchResult {
ensure_root(origin)?;

Expand All @@ -1736,6 +1743,7 @@ pub mod pallet {
config_op_exp!(MaxValidatorsCount<T>, max_validator_count);
config_op_exp!(ChillThreshold<T>, chill_threshold);
config_op_exp!(MinCommission<T>, min_commission);
config_op_exp!(MaxStakedRewards<T>, max_staked_rewards);
Ok(())
}
/// Declare a `controller` to stop participating as either a validator or nominator.
Expand Down
88 changes: 84 additions & 4 deletions substrate/frame/staking/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ fn set_staking_configs_works() {
ConfigOp::Set(10),
ConfigOp::Set(20),
ConfigOp::Set(Percent::from_percent(75)),
ConfigOp::Set(Zero::zero()),
ConfigOp::Set(Zero::zero())
));
assert_eq!(MinNominatorBond::<Test>::get(), 1_500);
Expand All @@ -63,6 +64,7 @@ fn set_staking_configs_works() {
assert_eq!(MaxValidatorsCount::<Test>::get(), Some(20));
assert_eq!(ChillThreshold::<Test>::get(), Some(Percent::from_percent(75)));
assert_eq!(MinCommission::<Test>::get(), Perbill::from_percent(0));
assert_eq!(MaxStakedRewards::<Test>::get(), Some(Percent::from_percent(0)));

// noop does nothing
assert_storage_noop!(assert_ok!(Staking::set_staking_configs(
Expand All @@ -72,6 +74,7 @@ fn set_staking_configs_works() {
ConfigOp::Noop,
ConfigOp::Noop,
ConfigOp::Noop,
ConfigOp::Noop,
ConfigOp::Noop
)));

Expand All @@ -83,6 +86,7 @@ fn set_staking_configs_works() {
ConfigOp::Remove,
ConfigOp::Remove,
ConfigOp::Remove,
ConfigOp::Remove,
ConfigOp::Remove
));
assert_eq!(MinNominatorBond::<Test>::get(), 0);
Expand All @@ -91,6 +95,7 @@ fn set_staking_configs_works() {
assert_eq!(MaxValidatorsCount::<Test>::get(), None);
assert_eq!(ChillThreshold::<Test>::get(), None);
assert_eq!(MinCommission::<Test>::get(), Perbill::from_percent(0));
assert_eq!(MaxStakedRewards::<Test>::get(), None);
});
}

Expand Down Expand Up @@ -1739,6 +1744,74 @@ fn rebond_emits_right_value_in_event() {
});
}

#[test]
fn max_staked_rewards_default_works() {
ExtBuilder::default().build_and_execute(|| {
assert_eq!(<MaxStakedRewards<Test>>::get(), None);

let default_stakers_payout = current_total_payout_for_duration(reward_time_per_era());
assert!(default_stakers_payout > 0);
start_active_era(1);

// the final stakers reward is the same as the reward before applied the cap.
assert_eq!(ErasValidatorReward::<Test>::get(0).unwrap(), default_stakers_payout);

// which is the same behaviour if the `MaxStakedRewards` is set to 100%.
<MaxStakedRewards<Test>>::set(Some(Percent::from_parts(100)));

let default_stakers_payout = current_total_payout_for_duration(reward_time_per_era());
assert_eq!(ErasValidatorReward::<Test>::get(0).unwrap(), default_stakers_payout);
})
}

#[test]
fn max_staked_rewards_works() {
ExtBuilder::default().nominate(true).build_and_execute(|| {
let max_staked_rewards = 10;

// sets new max staked rewards through set_staking_configs.
assert_ok!(Staking::set_staking_configs(
RuntimeOrigin::root(),
ConfigOp::Noop,
ConfigOp::Noop,
ConfigOp::Noop,
ConfigOp::Noop,
ConfigOp::Noop,
ConfigOp::Noop,
ConfigOp::Set(Percent::from_percent(max_staked_rewards)),
));

assert_eq!(<MaxStakedRewards<Test>>::get(), Some(Percent::from_percent(10)));

// check validators account state.
assert_eq!(Session::validators().len(), 2);
assert!(Session::validators().contains(&11) & Session::validators().contains(&21));
// balance of the mock treasury account is 0
assert_eq!(RewardRemainderUnbalanced::get(), 0);

let max_stakers_payout = current_total_payout_for_duration(reward_time_per_era());

start_active_era(1);

let treasury_payout = RewardRemainderUnbalanced::get();
let validators_payout = ErasValidatorReward::<Test>::get(0).unwrap();
let total_payout = treasury_payout + validators_payout;

// max stakers payout (without max staked rewards cap applied) is larger than the final
// validator rewards. The final payment and remainder should be adjusted by redestributing
// the era inflation to apply the cap...
assert!(max_stakers_payout > validators_payout);

// .. which means that the final validator payout is 10% of the total payout..
assert_eq!(validators_payout, Percent::from_percent(max_staked_rewards) * total_payout);
// .. and the remainder 90% goes to the treasury.
assert_eq!(
treasury_payout,
Percent::from_percent(100 - max_staked_rewards) * (treasury_payout + validators_payout)
);
})
}

#[test]
fn reward_to_stake_works() {
ExtBuilder::default()
Expand Down Expand Up @@ -5543,7 +5616,8 @@ fn chill_other_works() {
ConfigOp::Remove,
ConfigOp::Remove,
ConfigOp::Remove,
ConfigOp::Remove
ConfigOp::Remove,
ConfigOp::Noop,
));

// Still can't chill these users
Expand All @@ -5564,7 +5638,8 @@ fn chill_other_works() {
ConfigOp::Set(10),
ConfigOp::Set(10),
ConfigOp::Noop,
ConfigOp::Noop
ConfigOp::Noop,
ConfigOp::Noop,
));

// Still can't chill these users
Expand All @@ -5585,7 +5660,8 @@ fn chill_other_works() {
ConfigOp::Remove,
ConfigOp::Remove,
ConfigOp::Noop,
ConfigOp::Noop
ConfigOp::Noop,
ConfigOp::Noop,
));

// Still can't chill these users
Expand All @@ -5606,7 +5682,8 @@ fn chill_other_works() {
ConfigOp::Set(10),
ConfigOp::Set(10),
ConfigOp::Set(Percent::from_percent(75)),
ConfigOp::Noop
ConfigOp::Noop,
ConfigOp::Noop,
));

// 16 people total because tests start with 2 active one
Expand Down Expand Up @@ -5652,6 +5729,7 @@ fn capped_stakers_works() {
ConfigOp::Set(max),
ConfigOp::Remove,
ConfigOp::Remove,
ConfigOp::Noop,
));

// can create `max - validator_count` validators
Expand Down Expand Up @@ -5722,6 +5800,7 @@ fn capped_stakers_works() {
ConfigOp::Remove,
ConfigOp::Noop,
ConfigOp::Noop,
ConfigOp::Noop,
));
assert_ok!(Staking::nominate(RuntimeOrigin::signed(last_nominator), vec![1]));
assert_ok!(Staking::validate(
Expand Down Expand Up @@ -5757,6 +5836,7 @@ fn min_commission_works() {
ConfigOp::Remove,
ConfigOp::Remove,
ConfigOp::Set(Perbill::from_percent(10)),
ConfigOp::Noop,
));

// can't make it less than 10 now
Expand Down
Loading

0 comments on commit fde4447

Please sign in to comment.