Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions prdoc/pr_11513.prdoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
title: Add issuance/budget traits
doc:
- audience: Runtime Dev
description: |-
Moves `EraPayout` trait from `pallet-staking` and `pallet-staking-async` to `sp-staking`,
eliminating duplicate definitions. Adds a new `budget` module with stake independent
traits for issuance computation (`IssuanceCurve`) and budget distribution
(`BudgetRecipient`, `BudgetRecipientList`). Also adds `StakerRewardCalculator` trait for
customizing validator incentive weights and staker reward splits.

No behavior changes. Existing `EraPayout` re-exports from both staking pallets are preserved.
crates:
- name: sp-staking
bump: minor
- name: pallet-staking
bump: patch
- name: pallet-staking-async
bump: patch
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor Author

@Ank4n Ank4n Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

its moved to sp-staking and re-exported from the crates where it was removed. So should not break anything, right? Or am I missing something?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe you're right but I don't think check-semver is too smart to catch it 😅 Now, it's not a mandatory job to merge, but will require some attention if (hopefully not) it doesn't make the cut for 2604. Do nothing or validate:false might be an option as well

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't wanna retrigger CI (it takes forever 😂 )
pallet_staking_async will bump to major in the following PRs. But I can keep an eye on this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

totally fine by me - let's all 🙏 that CI Gods are gentle with us and we can merge now finally 😄

23 changes: 1 addition & 22 deletions substrate/frame/staking-async/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -489,28 +489,7 @@ impl<Balance, const MAX: u32> NominationsQuota<Balance> for FixedNominationsQuot
}
}

/// Handler for determining how much of a balance should be paid out on the current era.
pub trait EraPayout<Balance> {
Comment thread
kianenigma marked this conversation as resolved.
/// Determine the payout for this era.
///
/// Returns the amount to be paid to stakers in this era, as well as whatever else should be
/// paid out ("the rest").
fn era_payout(
total_staked: Balance,
total_issuance: Balance,
era_duration_millis: u64,
) -> (Balance, Balance);
}

impl<Balance: Default> EraPayout<Balance> for () {
fn era_payout(
_total_staked: Balance,
_total_issuance: Balance,
_era_duration_millis: u64,
) -> (Balance, Balance) {
(Default::default(), Default::default())
}
}
pub use sp_staking::EraPayout;

/// Mode of era-forcing.
#[derive(
Expand Down
27 changes: 2 additions & 25 deletions substrate/frame/staking/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ use sp_staking::{
offence::{Offence, OffenceError, OffenceSeverity, ReportOffence},
EraIndex, ExposurePage, OnStakingUpdate, Page, PagedExposureMetadata, SessionIndex,
};
pub use sp_staking::{Exposure, IndividualExposure, StakerStatus};
pub use sp_staking::{EraPayout, Exposure, IndividualExposure, StakerStatus};
pub use weights::WeightInfo;

pub use pallet::{pallet::*, UseNominatorsAndValidatorsMap, UseValidatorsMap};
Expand Down Expand Up @@ -977,33 +977,10 @@ impl<AccountId> SessionInterface<AccountId> for () {
}
}

/// Handler for determining how much of a balance should be paid out on the current era.
pub trait EraPayout<Balance> {
/// Determine the payout for this era.
///
/// Returns the amount to be paid to stakers in this era, as well as whatever else should be
/// paid out ("the rest").
fn era_payout(
total_staked: Balance,
total_issuance: Balance,
era_duration_millis: u64,
) -> (Balance, Balance);
}

impl<Balance: Default> EraPayout<Balance> for () {
fn era_payout(
_total_staked: Balance,
_total_issuance: Balance,
_era_duration_millis: u64,
) -> (Balance, Balance) {
(Default::default(), Default::default())
}
}

/// Adaptor to turn a `PiecewiseLinear` curve definition into an `EraPayout` impl, used for
/// backwards compatibility.
pub struct ConvertCurve<T>(core::marker::PhantomData<T>);
impl<Balance, T> EraPayout<Balance> for ConvertCurve<T>
impl<Balance, T> sp_staking::EraPayout<Balance> for ConvertCurve<T>
where
Balance: AtLeast32BitUnsigned + Clone + Copy,
T: Get<&'static PiecewiseLinear<'static>>,
Expand Down
4 changes: 2 additions & 2 deletions substrate/frame/staking/src/pallet/impls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,14 @@ use sp_runtime::{
use sp_staking::{
currency_to_vote::CurrencyToVote,
offence::{OffenceDetails, OnOffenceHandler},
EraIndex, OnStakingUpdate, Page, SessionIndex, Stake,
EraIndex, EraPayout, OnStakingUpdate, Page, SessionIndex, Stake,
StakingAccount::{self, Controller, Stash},
StakingInterface,
};

use crate::{
asset, election_size_tracker::StaticTracker, log, slashing, weights::WeightInfo, ActiveEraInfo,
BalanceOf, EraInfo, EraPayout, Exposure, Forcing, IndividualExposure, LedgerIntegrityState,
BalanceOf, EraInfo, Exposure, Forcing, IndividualExposure, LedgerIntegrityState,
MaxNominationsOf, MaxWinnersOf, Nominations, NominationsQuota, PositiveImbalanceOf,
RewardDestination, SessionInterface, StakingLedger, UnlockChunk, ValidatorPrefs, STAKING_ID,
};
Expand Down
4 changes: 2 additions & 2 deletions substrate/frame/staking/src/pallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ mod impls;
pub use impls::*;

use crate::{
asset, slashing, weights::WeightInfo, AccountIdLookupOf, ActiveEraInfo, BalanceOf, EraPayout,
asset, slashing, weights::WeightInfo, AccountIdLookupOf, ActiveEraInfo, BalanceOf,
EraRewardPoints, Exposure, ExposurePage, Forcing, LedgerIntegrityState, MaxNominationsOf,
NegativeImbalanceOf, Nominations, NominationsQuota, PositiveImbalanceOf, RewardDestination,
SessionInterface, StakingLedger, UnappliedSlash, UnlockChunk, ValidatorPrefs,
Expand Down Expand Up @@ -231,7 +231,7 @@ pub mod pallet {
/// The payout for validators and the system for the current era.
/// See [Era payout](./index.html#era-payout).
#[pallet::no_default]
type EraPayout: EraPayout<BalanceOf<Self>>;
type EraPayout: sp_staking::EraPayout<BalanceOf<Self>>;

/// Something that can estimate the next session change, accurately or as a best effort
/// guess.
Expand Down
136 changes: 136 additions & 0 deletions substrate/primitives/staking/src/budget.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// This file is part of Substrate.

// 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.

//! Traits for inflation issuance and budget distribution.
//!
//! These traits define how new tokens are minted and distributed among budget recipients
//! (e.g. staker rewards, validator incentives).

use alloc::vec::Vec;
use sp_runtime::BoundedVec;

/// Maximum length of a budget key identifier.
pub const MAX_BUDGET_KEY_LEN: u32 = 32;

/// Identifier for a budget category in the inflation distribution system.
///
/// Each budget recipient (e.g. staker rewards, validator incentive) is identified
/// by a unique key. Keys are bounded to [`MAX_BUDGET_KEY_LEN`] bytes.
pub type BudgetKey = BoundedVec<u8, sp_core::ConstU32<MAX_BUDGET_KEY_LEN>>;

/// Computes new token issuance for a given time period.
///
/// Unlike [`super::EraPayout`], this trait does not depend on staking state. Issuance is
/// purely a function of total supply and elapsed time.
pub trait IssuanceCurve<Balance> {
/// Compute how much new tokens to mint for the given period.
fn issue(total_issuance: Balance, elapsed_millis: u64) -> Balance;
}

/// A recipient of inflation budget.
///
/// Pallets that want a share of inflation implement this trait, providing a unique key
/// and a pot account where minted funds are deposited.
pub trait BudgetRecipient<AccountId> {
/// Unique identifier for this budget category.
fn budget_key() -> BudgetKey;
/// The account that receives minted inflation funds.
fn pot_account() -> AccountId;
}

/// Aggregates multiple [`BudgetRecipient`]s into a list.
///
/// Implemented for tuples of `BudgetRecipient` types, allowing runtime configuration like:
/// ```ignore
/// type BudgetRecipients = (StakerRewardRecipient, ValidatorIncentiveRecipient);
/// ```
pub trait BudgetRecipientList<AccountId> {
/// Collect all registered recipients as `(key, account)` pairs.
fn recipients() -> Vec<(BudgetKey, AccountId)>;
}

impl<AccountId> BudgetRecipientList<AccountId> for () {
fn recipients() -> Vec<(BudgetKey, AccountId)> {
Vec::new()
}
}

#[impl_trait_for_tuples::impl_for_tuples(1, 10)]
#[tuple_types_custom_trait_bound(BudgetRecipient<AccountId>)]
impl<AccountId> BudgetRecipientList<AccountId> for Tuple {
fn recipients() -> Vec<(BudgetKey, AccountId)> {
let mut v = Vec::new();
for_tuples!( #( v.push((Tuple::budget_key(), Tuple::pot_account())); )* );
Comment thread
Ank4n marked this conversation as resolved.
debug_assert!(
{
let mut keys: Vec<_> = v.iter().map(|(k, _)| k.clone()).collect();
keys.sort();
keys.windows(2).all(|w| w[0] != w[1])
},
"Duplicate BudgetRecipient key detected"
);
v
}
}

#[cfg(test)]
mod tests {
use super::*;

struct RecipientA;
impl BudgetRecipient<u64> for RecipientA {
fn budget_key() -> BudgetKey {
BudgetKey::truncate_from(b"alpha".to_vec())
}
fn pot_account() -> u64 {
1
}
}

struct RecipientB;
impl BudgetRecipient<u64> for RecipientB {
fn budget_key() -> BudgetKey {
BudgetKey::truncate_from(b"beta".to_vec())
}
fn pot_account() -> u64 {
2
}
}

// Duplicate key: same as RecipientA.
struct RecipientDuplicate;
impl BudgetRecipient<u64> for RecipientDuplicate {
fn budget_key() -> BudgetKey {
BudgetKey::truncate_from(b"alpha".to_vec())
}
fn pot_account() -> u64 {
3
}
}

#[test]
fn unique_keys_work() {
let recipients = <(RecipientA, RecipientB) as BudgetRecipientList<u64>>::recipients();
assert_eq!(recipients.len(), 2);
}

#[test]
#[should_panic(expected = "Duplicate BudgetRecipient key detected")]
fn duplicate_keys_panics() {
let _ = <(RecipientA, RecipientDuplicate) as BudgetRecipientList<u64>>::recipients();
}
}
62 changes: 62 additions & 0 deletions substrate/primitives/staking/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ use sp_runtime::{
Debug, DispatchError, DispatchResult, Perbill, Saturating,
};

pub mod budget;
pub mod offence;

pub mod currency_to_vote;
Expand Down Expand Up @@ -726,6 +727,67 @@ pub trait DelegationMigrator {
fn force_kill_agent(agent: Agent<Self::AccountId>);
}

/// Handler for determining how much of a balance should be paid out on the current era.
///
/// [`budget::IssuanceCurve`] is the successor to this trait, decoupling issuance computation
/// from staking state.
pub trait EraPayout<Balance> {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will be deprecated in the following PR

/// Determine the payout for this era.
///
/// Returns the amount to be paid to stakers in this era, as well as whatever else should be
/// paid out ("the rest").
fn era_payout(
total_staked: Balance,
total_issuance: Balance,
era_duration_millis: u64,
) -> (Balance, Balance);
}

impl<Balance: Default> EraPayout<Balance> for () {
fn era_payout(
_total_staked: Balance,
_total_issuance: Balance,
_era_duration_millis: u64,
) -> (Balance, Balance) {
(Default::default(), Default::default())
}
}

/// Result of splitting a validator's staking reward between the validator and their nominators.
///
/// Produced by [`StakerRewardCalculator::calculate_staker_reward`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StakerRewardResult<Balance> {
/// Total payout for the validator (commission + proportional stake reward).
pub validator_payout: Balance,
/// Total payout for all nominators, to be split proportionally by the caller.
pub nominator_payout: Balance,
}

/// Handles two independent reward calculations:
///
/// 1. **Staker reward split** ([`Self::calculate_staker_reward`]) — determines how a validator's
/// staking reward is divided between the validator and their nominators.
///
/// 2. **Validator incentive weight** ([`Self::calculate_validator_incentive_weight`]) — determines
/// a validator's relative share of a separate validator incentive pot, based on self-stake. This
/// incentive pot is validator-only; nominators do not receive from it.
pub trait StakerRewardCalculator<Balance> {
/// Compute a weight for this validator's share of the validator incentive pot.
///
/// Called once per validator during era planning. All validators' weights are summed, and
/// each validator's incentive payout is proportional to `their_weight / total_weight`.
fn calculate_validator_incentive_weight(self_stake: Balance) -> Balance;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without any implementation yet, can you document how these two are meant to be used?

I think I figured it out last time by looking at the code, but have forgotten again, and not intuitive enough (= I am not smart enough) to reverse-engineer 🙈

I am not sure what is this weight + how it related to the validator_payout part of StakerRewardResult. It seems like our flow for validators and nominators are different, while they should not be?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

improved the doc, hopefully more now? 946ff6a

This trait combines staker reward split and incentive weight calculation. Could argue to split it as well, but a runtime can always create a custom impl that delegates to different strategies internally, so I think this is fine.

tldr for incentive weight: every validator gets reward proportional to their_weight / total_weight.


/// Split a validator's staking reward into validator and nominator portions.
fn calculate_staker_reward(
validator_total_reward: Balance,
validator_commission: Perbill,
validator_own_stake: Balance,
total_stake: Balance,
) -> StakerRewardResult<Balance>;
}

sp_core::generate_feature_enabled_macro!(runtime_benchmarks_enabled, feature = "runtime-benchmarks", $);
sp_core::generate_feature_enabled_macro!(std_or_benchmarks_enabled, any(feature = "std", feature = "runtime-benchmarks"), $);

Expand Down
Loading