diff --git a/Cargo.lock b/Cargo.lock index daffd5904fe80..780aa38d4f86c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11885,6 +11885,7 @@ dependencies = [ "pallet-staking-async-ah-client", "pallet-staking-async-rc-client", "pallet-timestamp", + "pallet-vesting", "parity-scale-codec", "polkadot-sdk-frame", "scale-info", @@ -14166,6 +14167,7 @@ dependencies = [ "log", "pallet-bags-list", "pallet-balances", + "pallet-dap", "pallet-staking-async-rc-client", "parity-scale-codec", "rand 0.8.5", @@ -14173,6 +14175,7 @@ dependencies = [ "scale-info", "serde", "sp-application-crypto 30.0.0", + "sp-arithmetic 23.0.0", "sp-core 28.0.0", "sp-io 30.0.0", "sp-npos-elections", diff --git a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/lib.rs b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/lib.rs index 7a0fad900af84..d2a3a17ca21e9 100644 --- a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/lib.rs +++ b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/lib.rs @@ -96,7 +96,7 @@ use sp_version::RuntimeVersion; use testnet_parachains_constants::westend::{ consensus::*, currency::*, snowbridge::EthereumNetwork, time::*, }; -use westend_runtime_constants::time::DAYS as RC_DAYS; +use westend_runtime_constants::time::{DAYS as RC_DAYS, HOURS as RC_HOURS}; use xcm_config::{ ForeignAssetsConvertedConcreteId, LocationToAccountId, PoolAssetsConvertedConcreteId, PoolAssetsPalletLocation, TrustBackedAssetsConvertedConcreteId, @@ -1581,6 +1581,37 @@ parameter_types! { ); } +/// Provides the initial `LastIssuanceTimestamp` for DAP migration. +pub struct DapLastIssuanceTimestamp; +impl frame_support::traits::Get for DapLastIssuanceTimestamp { + fn get() -> u64 { + pallet_staking_async::ActiveEra::::get() + .and_then(|era| era.start) + .unwrap_or(0) + } +} + +/// Default budget: 85% staker rewards, 15% buffer, 0% validator incentive. +/// +/// Keys are read from `BudgetRecipients` registered in the runtime config. +pub struct DefaultDapBudget; +impl frame_support::traits::Get for DefaultDapBudget { + fn get() -> pallet_dap::BudgetAllocationMap { + use sp_runtime::Perbill; + use sp_staking::budget::BudgetRecipientList; + + let recipients = ::BudgetRecipients::recipients(); + // [dap, StakerRewardRecipient, ValidatorIncentiveRecipient] + let percentages = [Perbill::from_percent(15), Perbill::from_percent(85), Perbill::zero()]; + + let mut map = pallet_dap::BudgetAllocationMap::new(); + for ((key, _), perbill) in recipients.into_iter().zip(percentages) { + let _ = map.try_insert(key, perbill); + } + map + } +} + /// Migrations to apply on runtime upgrade. pub type Migrations = ( // v9420 @@ -1620,6 +1651,8 @@ pub type Migrations = ( // permanent pallet_xcm::migration::MigrateToLatestXcmVersion, cumulus_pallet_aura_ext::migration::MigrateV0ToV1, + // unreleased + pallet_dap::migrations::MigrateV1ToV2, ); /// Asset Hub Westend has some undecodable storage, delete it. @@ -2899,6 +2932,19 @@ fn ensure_key_ss58() { assert_eq!(acc, RootMigController::sorted_members()[0]); } +#[test] +fn issuance_cadence_smaller_than_era_length() { + use crate::staking::{RelaySessionDuration, SessionsPerEra}; + let era_length_ms = SessionsPerEra::get() as u64 * + RelaySessionDuration::get() as u64 * + RELAY_CHAIN_SLOT_DURATION_MILLIS as u64; + let cadence = ::IssuanceCadence::get(); + assert!( + cadence < era_length_ms, + "IssuanceCadence ({cadence}ms) must be smaller than era length ({era_length_ms}ms)" + ); +} + #[test] fn ensure_epmb_weights_sane() { use sp_io::TestExternalities; diff --git a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/staking.rs b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/staking.rs index 7525e009b9b77..4fe812c33fc32 100644 --- a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/staking.rs +++ b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/staking.rs @@ -228,29 +228,20 @@ impl pallet_bags_list::Config for Runtime { type WeightInfo = weights::pallet_bags_list::WeightInfo; } -pub struct EraPayout; -impl pallet_staking_async::EraPayout for EraPayout { - fn era_payout( - _total_staked: Balance, - _total_issuance: Balance, - era_duration_millis: u64, - ) -> (Balance, Balance) { +pub struct PolkadotIssuanceCurve; +impl sp_staking::budget::IssuanceCurve for PolkadotIssuanceCurve { + fn issue(_total_issuance: Balance, elapsed_millis: u64) -> Balance { const MILLISECONDS_PER_YEAR: u64 = (1000 * 3600 * 24 * 36525) / 100; - // A normal-sized era will have 1 / 365.25 here: - let relative_era_len = - FixedU128::from_rational(era_duration_millis.into(), MILLISECONDS_PER_YEAR.into()); + let relative_period = + FixedU128::from_rational(elapsed_millis.into(), MILLISECONDS_PER_YEAR.into()); // Fixed total TI that we use as baseline for the issuance. let fixed_total_issuance: i128 = 5_216_342_402_773_185_773; let fixed_inflation_rate = FixedU128::from_rational(8, 100); let yearly_emission = fixed_inflation_rate.saturating_mul_int(fixed_total_issuance); - let era_emission = relative_era_len.saturating_mul_int(yearly_emission); - // 15% to treasury, as per Polkadot ref 1139. - let to_treasury = FixedU128::from_rational(15, 100).saturating_mul_int(era_emission); - let to_stakers = era_emission.saturating_sub(to_treasury); - - (to_stakers.saturated_into(), to_treasury.saturated_into()) + let period_emission = relative_period.saturating_mul_int(yearly_emission); + period_emission.saturated_into() } } @@ -268,8 +259,8 @@ parameter_types! { pub const MaxControllersInDeprecationBatch: u32 = 751; // alias for 16, which is the max nominations per nominator in the runtime. pub const MaxNominations: u32 = ::LIMIT as u32; - pub const MaxEraDuration: u64 = RelaySessionDuration::get() as u64 * RELAY_CHAIN_SLOT_DURATION_MILLIS as u64 * SessionsPerEra::get() as u64; pub MaxPruningItems: u32 = 100; + pub const StakingPalletId: PalletId = PalletId(*b"py/stkng"); } impl pallet_staking_async::Config for Runtime { @@ -281,13 +272,15 @@ impl pallet_staking_async::Config for Runtime { type CurrencyToVote = sp_staking::currency_to_vote::SaturatingCurrencyToVote; type RewardRemainder = Dap; type Slash = Dap; + type UnclaimedRewardHandler = Dap; type Reward = (); + type GeneralPots = pallet_staking_async::Seed; + type EraPots = pallet_staking_async::Seed; type SessionsPerEra = SessionsPerEra; type BondingDuration = BondingDuration; type NominatorFastUnbondDuration = NominatorFastUnbondDuration; type SlashDeferDuration = SlashDeferDuration; type AdminOrigin = EitherOf, StakingAdmin>; - type EraPayout = EraPayout; type MaxExposurePageSize = MaxExposurePageSize; type ElectionProvider = MultiBlockElection; type VoterList = VoterList; @@ -300,8 +293,15 @@ impl pallet_staking_async::Config for Runtime { type EventListeners = (NominationPools, DelegatedStaking); type PlanningEraOffset = ConstU32<6>; type RcClientInterface = StakingRcClient; - type MaxEraDuration = MaxEraDuration; type MaxPruningItems = MaxPruningItems; + type StakerRewardCalculator = + pallet_staking_async::reward::DefaultStakerRewardCalculator; + /// Vest validator incentive rewards over 2 days (in relay chain blocks). + type VestingDuration = ConstU32<{ 2 * RC_DAYS }>; + /// Relay chain session length in RC blocks (1 hour on Westend). + type BlocksPerSession = ConstU32; + type ValidatorIncentivePayout = + pallet_staking_async::VestedIncentivePayout>; type WeightInfo = weights::pallet_staking_async::WeightInfo; } @@ -344,22 +344,30 @@ impl pallet_staking_async_rc_client::Config for Runtime { parameter_types! { pub const DapPalletId: frame_support::PalletId = frame_support::PalletId(*b"dap/buff"); - /// Minimum time (ms) between issuance drips. 60s = drip at most once per minute. + /// Drip inflation every 60 seconds. Must be smaller than era length. + /// When adding DAP to other runtimes, copy the `issuance_cadence_smaller_than_era_length` + /// test from this runtime. pub const IssuanceCadence: u64 = 60_000; - /// Safety ceiling (ms) for elapsed time in a single drip. Prevents over-minting after stalls. + /// Safety ceiling on elapsed time per drip: 10 minutes. + /// Prevents over-minting if blocks are delayed or chain stalls. pub const MaxElapsedPerDrip: u64 = 600_000; } impl pallet_dap::Config for Runtime { type Currency = Balances; type PalletId = DapPalletId; - /// Noop — DAP does not mint until budget drip is enabled. - type IssuanceCurve = (); - type BudgetRecipients = (pallet_dap::Pallet,); - type Time = pallet_timestamp::Pallet; + type IssuanceCurve = PolkadotIssuanceCurve; + type BudgetRecipients = ( + Dap, + pallet_staking_async::StakerRewardRecipient>, + pallet_staking_async::ValidatorIncentiveRecipient< + pallet_staking_async::Seed, + >, + ); + type Time = Timestamp; type IssuanceCadence = IssuanceCadence; type MaxElapsedPerDrip = MaxElapsedPerDrip; - type BudgetOrigin = frame_system::EnsureRoot; + type BudgetOrigin = EnsureRoot; type WeightInfo = (); } diff --git a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/weights/pallet_staking_async.rs b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/weights/pallet_staking_async.rs index 07c01c75aaf74..4698946c10a10 100644 --- a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/weights/pallet_staking_async.rs +++ b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/weights/pallet_staking_async.rs @@ -721,6 +721,14 @@ impl pallet_staking_async::WeightInfo for WeightInfo .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } + fn set_max_commission() -> Weight { + // TODO(ank4n): Run benchmarks + todo!() + } + fn set_validator_self_stake_incentive_config() -> Weight { + // TODO(ank4n): Run benchmarks + todo!() + } /// Storage: `System::Account` (r:1 w:0) /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) /// Storage: `Staking::VirtualStakers` (r:1 w:0) diff --git a/polkadot/runtime/westend/src/lib.rs b/polkadot/runtime/westend/src/lib.rs index c660a3da2f921..bbf65dc03c451 100644 --- a/polkadot/runtime/westend/src/lib.rs +++ b/polkadot/runtime/westend/src/lib.rs @@ -709,7 +709,7 @@ impl pallet_bags_list::Config for Runtime { } pub struct EraPayout; -impl pallet_staking::EraPayout for EraPayout { +impl sp_staking::EraPayout for EraPayout { fn era_payout( _total_staked: Balance, _total_issuance: Balance, diff --git a/polkadot/runtime/westend/src/tests.rs b/polkadot/runtime/westend/src/tests.rs index c2acd70b4bed3..0c7352292cf1c 100644 --- a/polkadot/runtime/westend/src/tests.rs +++ b/polkadot/runtime/westend/src/tests.rs @@ -27,10 +27,10 @@ use frame_support::{ WhitelistedStorageKeys, }, }; -use pallet_staking::EraPayout; use sp_core::{crypto::Ss58Codec, hexdisplay::HexDisplay}; use sp_keyring::Sr25519Keyring::{self, Alice}; use sp_runtime::generic::Era; +use sp_staking::EraPayout; use xcm_runtime_apis::conversions::LocationToAccountHelper; const MILLISECONDS_PER_HOUR: u64 = 60 * 60 * 1000; diff --git a/prdoc/pr_10844.prdoc b/prdoc/pr_10844.prdoc new file mode 100644 index 0000000000000..89c1deccb8b88 --- /dev/null +++ b/prdoc/pr_10844.prdoc @@ -0,0 +1,52 @@ +title: "Move era reward minting to DAP with era-based allocation in Staking" +doc: +- audience: Runtime Dev + description: |- + Rewires staking reward minting by introducing DAP as the central inflation engine. + + **DAP (`pallet-dap`)**: Becomes a generic inflation drip and distribution engine. Inflation + is minted per-block (configurable cadence) via `InflationCurve` and distributed across + registered `BudgetRecipient` implementors through a governance-updatable `BudgetAllocation` + map. New budget categories can be added without changing DAP code. + + **Staking (`pallet-staking-async`)**: At era boundaries, staking snapshots accumulated + reward pots (filled continuously by DAP) into era-specific accounts. Adds a validator + self-stake incentive: a sqrt-based weight function rewards validators for increasing their + own stake with governance-configurable thresholds (`OptimumSelfStake`, `HardCapSelfStake`, + `SelfStakeSlopeFactor`). Payouts can be immediate or vested via `ValidatorIncentivePayout`. + + **Incentive vesting batch conversion**: To avoid exhausting `MaxVestingSchedules` (typically + 28), validator incentive rewards are accumulated under a `HoldReason::IncentiveVesting` hold + and batch-converted to a vesting schedule every `BondingDuration` eras. At conversion, a + retroactive unlock fraction (`BondingDuration / vesting_eras`) is released as liquid, + and the remainder is vested over the remaining duration, where + `vesting_eras = VestingDuration / (BlocksPerSession * SessionsPerEra)`. New config type + `BlocksPerSession` (in relay chain blocks) is required alongside `VestingDuration`. + + **Primitives (`sp-staking`)**: New traits -- `InflationCurve`, `BudgetRecipient`, + `UnclaimedRewardSink`, `StakerRewardCalculator`. + +crates: +- name: pallet-dap + bump: major +- name: pallet-staking-async + bump: major +- name: sp-staking + bump: major +- name: pallet-nomination-pools + bump: patch +- name: pallet-nomination-pools-benchmarking + bump: patch +- name: pallet-nomination-pools-test-delegate-stake + bump: patch + validate: false +- name: frame-support + bump: minor +- name: pallet-staking + bump: patch +- name: pallet-vesting + bump: minor +- name: westend-runtime + bump: patch +- name: asset-hub-westend-runtime + bump: patch diff --git a/substrate/frame/dap/src/lib.rs b/substrate/frame/dap/src/lib.rs index f8bf6a73f458f..1e3698d71cf52 100644 --- a/substrate/frame/dap/src/lib.rs +++ b/substrate/frame/dap/src/lib.rs @@ -82,7 +82,7 @@ pub mod pallet { use frame_system::pallet_prelude::*; /// The in-code storage version. - const STORAGE_VERSION: StorageVersion = StorageVersion::new(1); + const STORAGE_VERSION: StorageVersion = StorageVersion::new(2); #[pallet::pallet] #[pallet::storage_version(STORAGE_VERSION)] diff --git a/substrate/frame/nomination-pools/test-delegate-stake/src/mock.rs b/substrate/frame/nomination-pools/test-delegate-stake/src/mock.rs index 51f3f2bbab606..5c27d8e923e60 100644 --- a/substrate/frame/nomination-pools/test-delegate-stake/src/mock.rs +++ b/substrate/frame/nomination-pools/test-delegate-stake/src/mock.rs @@ -91,19 +91,6 @@ pallet_staking_reward_curve::build! { parameter_types! { pub const RewardCurve: &'static sp_runtime::curve::PiecewiseLinear<'static> = &I_NPOS; pub static BondingDuration: u32 = 3; - pub static EraPayout: (Balance, Balance) = (1000, 100); -} - -/// A simple EraPayout implementation for testing that returns fixed values. -pub struct TestEraPayout; -impl pallet_staking_async::EraPayout for TestEraPayout { - fn era_payout( - _total_staked: Balance, - _total_issuance: Balance, - _era_duration_millis: u64, - ) -> (Balance, Balance) { - EraPayout::get() - } } /// A mock RcClientInterface for tests that don't need actual session/validator set management. @@ -126,13 +113,17 @@ impl pallet_staking_async::Config for Runtime { type Currency = Balances; type AdminOrigin = frame_system::EnsureRoot; type BondingDuration = BondingDuration; - type EraPayout = TestEraPayout; type ElectionProvider = frame_election_provider_support::NoElection<(AccountId, BlockNumber, Staking, (), ())>; type VoterList = VoterList; type TargetList = pallet_staking_async::UseValidatorsMap; type EventListeners = (Pools, DelegatedStaking); type RcClientInterface = MockRcClient; + type VestingDuration = ConstU64<0>; + type BlocksPerSession = ConstU64<1>; + type ValidatorIncentivePayout = pallet_staking_async::ImmediateIncentivePayout; + type StakerRewardCalculator = + pallet_staking_async::reward::DefaultStakerRewardCalculator; } parameter_types! { diff --git a/substrate/frame/staking-async/Cargo.toml b/substrate/frame/staking-async/Cargo.toml index a852d8e87571b..3b60998b39122 100644 --- a/substrate/frame/staking-async/Cargo.toml +++ b/substrate/frame/staking-async/Cargo.toml @@ -26,6 +26,7 @@ rand_chacha = { workspace = true } scale-info = { features = ["derive", "serde"], workspace = true } serde = { features = ["alloc", "derive"], workspace = true } sp-application-crypto = { features = ["serde"], workspace = true } +sp-arithmetic = { workspace = true } sp-core = { workspace = true } sp-io = { workspace = true } sp-npos-elections = { workspace = true } @@ -42,6 +43,7 @@ frame-benchmarking = { workspace = true, default-features = true } frame-support = { features = ["experimental"], workspace = true, default-features = true } pallet-bags-list = { workspace = true, default-features = true } pallet-balances = { workspace = true, default-features = true } +pallet-dap = { workspace = true, default-features = true } rand_chacha = { workspace = true, default-features = true } sp-tracing = { workspace = true, default-features = true } substrate-test-utils = { workspace = true } @@ -64,6 +66,7 @@ std = [ "scale-info/std", "serde/std", "sp-application-crypto/std", + "sp-arithmetic/std", "sp-core/std", "sp-core/std", "sp-io/std", @@ -79,6 +82,7 @@ runtime-benchmarks = [ "frame-system/runtime-benchmarks", "pallet-bags-list/runtime-benchmarks", "pallet-balances/runtime-benchmarks", + "pallet-dap/runtime-benchmarks", "pallet-staking-async-rc-client/runtime-benchmarks", "sp-runtime/runtime-benchmarks", "sp-staking/runtime-benchmarks", @@ -89,6 +93,7 @@ try-runtime = [ "frame-system/try-runtime", "pallet-bags-list/try-runtime", "pallet-balances/try-runtime", + "pallet-dap/try-runtime", "pallet-staking-async-rc-client/try-runtime", "sp-runtime/try-runtime", ] diff --git a/substrate/frame/staking-async/integration-tests/Cargo.toml b/substrate/frame/staking-async/integration-tests/Cargo.toml index 8e295bebcd699..c6df5e626c959 100644 --- a/substrate/frame/staking-async/integration-tests/Cargo.toml +++ b/substrate/frame/staking-async/integration-tests/Cargo.toml @@ -39,6 +39,7 @@ pallet-dap = { workspace = true, default-features = true } pallet-election-provider-multi-block = { workspace = true, default-features = true } pallet-staking-async = { workspace = true, default-features = true } pallet-staking-async-rc-client = { workspace = true, default-features = true } +pallet-vesting = { workspace = true, default-features = true } # pallets we need in the RC pallet-authorship = { workspace = true, default-features = true } @@ -64,6 +65,7 @@ try-runtime = [ "pallet-staking-async-rc-client/try-runtime", "pallet-staking-async/try-runtime", + "pallet-vesting/try-runtime", "frame-election-provider-support/try-runtime", "frame-support/try-runtime", diff --git a/substrate/frame/staking-async/integration-tests/src/ah/mock.rs b/substrate/frame/staking-async/integration-tests/src/ah/mock.rs index 95e1fc69f4295..6ce4518f825c9 100644 --- a/substrate/frame/staking-async/integration-tests/src/ah/mock.rs +++ b/substrate/frame/staking-async/integration-tests/src/ah/mock.rs @@ -27,17 +27,20 @@ use frame_support::{ }; use pallet_election_provider_multi_block as multi_block; use pallet_election_provider_multi_block::{Event as ElectionEvent, Phase}; -use pallet_staking_async::{ActiveEra, CurrentEra, Forcing}; +use pallet_staking_async::{ + ActiveEra, CurrentEra, Forcing, GeneralPotAccountProvider, GeneralPotType, SequentialTest, +}; use pallet_staking_async_rc_client::{ OutgoingValidatorSet, SendKeysError, SendOperationError, SessionReport, ValidatorSetReport, }; -use sp_staking::SessionIndex; +use sp_staking::{budget::BudgetRecipient, SessionIndex}; use xcm::latest::{prelude::*, Asset, AssetId, Assets, Fungibility, Junction, Location}; use xcm_builder::{FungibleAdapter, IsConcrete}; use xcm_executor::{ traits::{ConvertLocation, FeeManager, FeeReason, TransactAsset}, AssetsInHolding, }; + pub const LOG_TARGET: &str = "ahm-test"; construct_runtime! { @@ -59,6 +62,7 @@ construct_runtime! { MultiBlockUnsigned: multi_block::unsigned, Dap: pallet_dap, + Vesting: pallet_vesting, } } @@ -83,6 +87,8 @@ pub fn roll_next() { RcClient::on_initialize(next), DispatchClass::Mandatory, ); + // Drip inflation into budget recipients. + Dap::on_initialize(next); let mut meter = NextPollWeight::take() .map(WeightMeter::with_limit) @@ -457,13 +463,14 @@ impl pallet_staking_async::Config for Runtime { type ElectionProvider = MultiBlock; - type EraPayout = (); type EventListeners = (); type Reward = (); type RewardRemainder = (); + type GeneralPots = pallet_staking_async::SequentialTest; + type EraPots = pallet_staking_async::SequentialTest; type Slash = Dap; + type UnclaimedRewardHandler = Dap; type SlashDeferDuration = SlashDeferredDuration; - type MaxEraDuration = (); type MaxPruningItems = MaxPruningItems; type HistoryDepth = ConstU32<7>; @@ -478,6 +485,12 @@ impl pallet_staking_async::Config for Runtime { type TargetList = pallet_staking_async::UseValidatorsMap; type RcClientInterface = RcClient; + type StakerRewardCalculator = + pallet_staking_async::reward::DefaultStakerRewardCalculator; + + type VestingDuration = ConstU64<600>; + type BlocksPerSession = ConstU64<10>; + type ValidatorIncentivePayout = pallet_staking_async::VestedIncentivePayout; type WeightInfo = super::weights::StakingAsyncWeightInfo; } @@ -511,26 +524,103 @@ parameter_types! { pub const DapPalletId: frame_support::PalletId = frame_support::PalletId(*b"dap/buff"); pub const DapIssuanceCadence: u64 = 60_000; pub const DapMaxElapsedPerDrip: u64 = 600_000; - pub static MockTime: u64 = 0; } +/// Mock time provider backed by block number (1 block = 6000ms). +pub struct MockTime; impl frame_support::traits::Time for MockTime { type Moment = u64; fn now() -> u64 { - Self::get() + (System::block_number() as u64) * 6_000 + } +} + +pub struct TestIssuanceCurve; +impl sp_staking::budget::IssuanceCurve for TestIssuanceCurve { + fn issue(_total_issuance: Balance, elapsed_millis: u64) -> Balance { + // 1 token per millisecond elapsed + if elapsed_millis == 0 { + 10_000 + } else { + elapsed_millis as Balance + } + } +} + +pub fn general_staker_pot() -> AccountId { + SequentialTest::general_pot_account(GeneralPotType::StakerRewards) +} + +pub fn general_incentive_pot() -> AccountId { + SequentialTest::general_pot_account(GeneralPotType::ValidatorIncentive) +} + +pub fn staker_reward_key() -> sp_staking::budget::BudgetKey { + as BudgetRecipient>::budget_key() +} + +pub fn validator_incentive_key() -> sp_staking::budget::BudgetKey { + as BudgetRecipient< + AccountId, + >>::budget_key() +} + +pub fn buffer_key() -> sp_staking::budget::BudgetKey { + >::budget_key() +} + +/// Build a DAP budget allocation map from `(key, percent)` pairs. +pub fn build_budget( + entries: &[(sp_staking::budget::BudgetKey, u32)], +) -> pallet_dap::BudgetAllocationMap { + let mut budget = BoundedBTreeMap::new(); + for (key, pct) in entries { + budget.try_insert(key.clone(), Perbill::from_percent(*pct)).unwrap(); } + budget +} + +/// Build the default 50/50 staker/buffer budget used by most tests. +pub fn default_budget() -> pallet_dap::BudgetAllocationMap { + build_budget(&[(staker_reward_key(), 50), (buffer_key(), 50)]) +} + +parameter_types! { + pub const TestIssuanceCadence: u64 = 0; // drip every block + pub const TestMaxElapsedPerDrip: u64 = 600_000; // 10 minutes } impl pallet_dap::Config for Runtime { type Currency = Balances; type PalletId = DapPalletId; - type IssuanceCurve = (); - type BudgetRecipients = (pallet_dap::Pallet,); + type IssuanceCurve = TestIssuanceCurve; + type BudgetRecipients = ( + Dap, + pallet_staking_async::StakerRewardRecipient, + pallet_staking_async::ValidatorIncentiveRecipient, + ); type Time = MockTime; - type IssuanceCadence = DapIssuanceCadence; - type MaxElapsedPerDrip = DapMaxElapsedPerDrip; - type BudgetOrigin = frame_system::EnsureRoot; + type IssuanceCadence = TestIssuanceCadence; + type MaxElapsedPerDrip = TestMaxElapsedPerDrip; + type BudgetOrigin = EnsureRoot; + type WeightInfo = (); +} + +parameter_types! { + pub const MinVestedTransfer: Balance = 1; + pub UnvestedFundsAllowedWithdrawReasons: WithdrawReasons = + WithdrawReasons::except(WithdrawReasons::TRANSFER | WithdrawReasons::RESERVE); +} + +impl pallet_vesting::Config for Runtime { + type RuntimeEvent = RuntimeEvent; + type Currency = Balances; + type BlockNumberToBalance = frame_support::sp_runtime::traits::ConvertInto; + type MinVestedTransfer = MinVestedTransfer; type WeightInfo = (); + type UnvestedFundsAllowedWithdrawReasons = UnvestedFundsAllowedWithdrawReasons; + type BlockNumberProvider = System; + const MAX_VESTING_SCHEDULES: u32 = 28; } parameter_types! { @@ -826,6 +916,9 @@ impl ExtBuilder { use frame_support::sp_runtime::traits::AccountIdConversion; let dap_buffer: AccountId = DapPalletId::get().into_account_truncating(); balances.push((dap_buffer, 1)); + // Fund general pot accounts with ED so they stay alive. + balances.push((general_staker_pot(), 1)); + balances.push((general_incentive_pot(), 1)); pallet_balances::GenesisConfig:: { balances, ..Default::default() } .assimilate_storage(&mut t) @@ -844,6 +937,14 @@ impl ExtBuilder { let mut state: TestState = t.into(); state.execute_with(|| { + // Set budget allocation: 50% staker rewards, 50% buffer (must sum to 100%). + pallet_dap::BudgetAllocation::::put(default_budget()); + // Initialize DAP's LastIssuanceTimestamp. + pallet_dap::LastIssuanceTimestamp::::put( + ::now(), + ); + // Disable legacy minting from era 0. + pallet_staking_async::DisableLegacyMintingEra::::put(0u32); // initialises events roll_next(); }); @@ -893,6 +994,114 @@ pub(crate) fn election_events_since_last_call() -> Vec> { all.into_iter().skip(seen).collect() } +/// Read the incentive hold balance for an account. +pub(crate) fn incentive_held(who: AccountId) -> Balance { + use frame_support::traits::fungible::hold::Inspect as HoldInspect; + >::balance_on_hold( + &pallet_staking_async::HoldReason::IncentiveVesting.into(), + &who, + ) +} + +/// Fill all vesting schedule slots for an account with dummy schedules. +pub(crate) fn fill_vesting_slots(who: AccountId) { + use frame_support::traits::fungible::Mutate; + use pallet_vesting::VestingInfo; + + let funder: AccountId = 9999; + Balances::mint_into(&funder, 1_000_000).unwrap(); + + let max_schedules = ::MAX_VESTING_SCHEDULES; + let existing = Vesting::vesting(who).map_or(0, |v| v.len() as u32); + + for i in existing..max_schedules { + let schedule = VestingInfo::new(100, 1, System::block_number() + 1000 + i as u64); + frame_support::assert_ok!(Vesting::force_vested_transfer( + RuntimeOrigin::root(), + funder, + who, + schedule, + )); + } +} + +/// Advance eras by sending session reports until `target_era` is reached. +/// +/// Unlike `roll_until_next_active`, this does not assert the validator set composition, +/// so it works even when the active set changes (e.g. after unbonding a validator). +pub(crate) fn advance_eras_until(target_era: sp_staking::EraIndex) { + use pallet_staking_async::session_rotation::Rotator; + + let mut iterations = 0; + while Rotator::::active_era() < target_era { + iterations += 1; + assert!(iterations < 200, "advance_eras_until: stuck after 200 iterations"); + + let planning = CurrentEra::::get().unwrap(); + let active = Rotator::::active_era(); + + if planning > active { + // Election started. Roll blocks until it completes. + if !OutgoingValidatorSet::::exists() { + roll_next(); + continue; + } + + // Election done, outgoing set ready. Send session to export it. + let end_index = + pallet_staking_async_rc_client::LastSessionReportEndingIndex::::get() + .unwrap_or_default() + + 1; + assert_ok!(pallet_staking_async_rc_client::Pallet::::relay_session_report( + RuntimeOrigin::root(), + SessionReport { + end_index, + activation_timestamp: None, + leftover: false, + validator_points: vec![], + }, + )); + roll_next(); + + // Outgoing set should be exported now. Send activation. + if !OutgoingValidatorSet::::exists() { + let end_index = + pallet_staking_async_rc_client::LastSessionReportEndingIndex::::get() + .unwrap_or_default() + + 1; + assert_ok!( + pallet_staking_async_rc_client::Pallet::::relay_session_report( + RuntimeOrigin::root(), + SessionReport { + end_index, + activation_timestamp: Some((planning as u64 * 1000, planning as u32,)), + leftover: false, + validator_points: vec![], + }, + ) + ); + roll_next(); + } + } else { + // No election in progress. Send a session report to trigger one. + let end_index = + pallet_staking_async_rc_client::LastSessionReportEndingIndex::::get() + .unwrap_or_default() + + 1; + assert_ok!(pallet_staking_async_rc_client::Pallet::::relay_session_report( + RuntimeOrigin::root(), + SessionReport { + end_index, + activation_timestamp: None, + leftover: false, + validator_points: vec![], + }, + )); + roll_next(); + } + } +} + pub(crate) enum AssertSessionType { /// A new election is planned in the starting session and result is exported immediately ElectionWithImmediateExport, diff --git a/substrate/frame/staking-async/integration-tests/src/ah/test.rs b/substrate/frame/staking-async/integration-tests/src/ah/test.rs index 80c6878e9cbe8..eea815b5194e7 100644 --- a/substrate/frame/staking-async/integration-tests/src/ah/test.rs +++ b/substrate/frame/staking-async/integration-tests/src/ah/test.rs @@ -18,7 +18,10 @@ use crate::{ah::mock::*, rc, shared}; use frame::prelude::Perbill; use frame_election_provider_support::Weight; -use frame_support::{assert_ok, hypothetically, traits::fungible::hold::Inspect as HoldInspect}; +use frame_support::{ + assert_ok, hypothetically, + traits::fungible::{hold::Inspect as HoldInspect, Inspect as FunInspect}, +}; use pallet_election_provider_multi_block::{ unsigned::miner::OffchainWorkerMiner, verifier::Event as VerifierEvent, CurrentPhase, ElectionScore, Event as ElectionEvent, Phase, @@ -791,14 +794,23 @@ fn on_offence_current_era_instant_apply() { ] ); - // DAP verification: slashed funds (50 + 50 + 50 = 150) should go to buffer + // DAP verification: slashed funds (50 + 50 + 50 = 150) should go to buffer. let final_dap_balance = Balances::free_balance(&dap_buffer); let final_total_issuance = Balances::total_issuance(); - // DAP buffer should have received all slashed funds - assert_eq!(final_dap_balance, initial_dap_balance + 150); - // Total issuance should be preserved (funds not burned) - assert_eq!(final_total_issuance, initial_total_issuance); + // 2 roll_next() calls above = 2 blocks of DAP drip. + // Each block = 6000ms elapsed, inflation = 6000 tokens minted. + // 50% budget to buffer = 3000 per block, so 6000 from drip over 2 blocks. + // Plus 150 from slashes (50 per offender * 3 slash events). + let expected_drip_to_buffer = 2 * 3000; + let expected_slash_to_buffer = 150; + assert_eq!( + final_dap_balance - initial_dap_balance, + expected_drip_to_buffer + expected_slash_to_buffer, + ); + // Total issuance increases by exactly the inflation minted (slashes are transfers, + // not burns). 2 blocks * 6000 tokens/block = 12000. + assert_eq!(final_total_issuance, initial_total_issuance + 2 * 6000); }); } @@ -2110,3 +2122,299 @@ mod session_keys { }); } } + +/// End-to-end test: validator incentive vesting batch conversion with pallet-vesting. +/// +/// Verifies that the full hold → release → self-transfer → vesting schedule flow works +/// with unmocked pallet-vesting. +#[test] +fn incentive_vesting_e2e_with_real_pallet_vesting() { + ExtBuilder::default().local_queue().build().execute_with(|| { + // GIVEN: Set up validator incentive config and include it in the DAP budget. + // BondingDuration = 3, VestingDuration = 600 blocks, BlocksPerSession = 10, + // SessionsPerEra = 6 → blocks_per_era = 60, vesting_eras = 600/60 = 10. + assert_ok!(Staking::set_validator_self_stake_incentive_config( + RuntimeOrigin::root(), + staking_async::ConfigOp::Set(30), // OptimumSelfStake + staking_async::ConfigOp::Set(1000), // HardCapSelfStake + staking_async::ConfigOp::Set(Perbill::from_rational(1u32, 2u32)), // SelfStakeSlopeFactor + )); + + // Add validator incentive to the budget (40% staker, 10% incentive, 50% buffer). + pallet_dap::BudgetAllocation::::put(build_budget(&[ + (staker_reward_key(), 40), + (validator_incentive_key(), 10), + (buffer_key(), 50), + ])); + + // Advance to era 1 — election happens, validator weights are set. + let active_validators = roll_until_next_active(0); + assert!(!active_validators.is_empty()); + let validator = active_validators[0]; + + // Send session reports with reward points so payout_stakers has something to pay. + let send_points = |end_index: sp_staking::SessionIndex| { + assert_ok!(rc_client::Pallet::::relay_session_report( + RuntimeOrigin::root(), + rc_client::SessionReport { + end_index, + activation_timestamp: None, + leftover: false, + validator_points: active_validators.iter().map(|v| (*v, 100)).collect(), + }, + )); + roll_next(); + }; + + // Advance to era 2 with reward points for era 1. + // Era 1 starts at session 7. Send session reports with points during era 1. + send_points(7); + roll_until_next_active(8); + + // Advance to era 3 with reward points for era 2. + send_points(13); + roll_until_next_active(14); + + // Advance to era 4 with reward points for era 3. + send_points(19); + roll_until_next_active(20); + + let _ = staking_events_since_last_call(); + + // Verify no vesting schedules exist for the validator yet. + let schedules = Vesting::vesting(validator); + assert!( + schedules.is_none() || schedules.unwrap().is_empty(), + "No vesting schedule should exist before payout" + ); + + // WHEN: Pay out eras 1 and 2 (accumulate incentive under hold). + assert_ok!(Staking::payout_stakers(RuntimeOrigin::signed(1), validator, 1,)); + assert_ok!(Staking::payout_stakers(RuntimeOrigin::signed(1), validator, 2,)); + + // Check that incentive is held (accumulated from eras 1 and 2). + let held_before = >::balance_on_hold( + &staking_async::HoldReason::IncentiveVesting.into(), + &validator, + ); + // 2 eras of incentive at 3900 per era = 7800. + assert_eq!(held_before, 7800, "Incentive should be held after eras 1 and 2"); + + // Pay out era 3 — batch boundary triggers conversion. + assert_ok!(Staking::payout_stakers(RuntimeOrigin::signed(1), validator, 3,)); + + let events = staking_events_since_last_call(); + + // THEN: Hold should be fully released after conversion. + let held_after = >::balance_on_hold( + &staking_async::HoldReason::IncentiveVesting.into(), + &validator, + ); + assert_eq!(held_after, 0, "Hold should be fully released after batch conversion"); + + // A real vesting schedule was created via pallet-vesting. + let vesting_schedules = Vesting::vesting(validator); + assert!( + vesting_schedules.is_some() && !vesting_schedules.as_ref().unwrap().is_empty(), + "A vesting schedule should exist after batch conversion" + ); + + // Verify the conversion event was emitted. + let converted = events.iter().find(|e| { + matches!( + e, + StakingEvent::IncentiveVestingConverted { validator_stash, .. } + if *validator_stash == validator + ) + }); + assert!( + converted.is_some(), + "IncentiveVestingConverted event should be emitted. Events: {:?}", + events + ); + + // Verify the vesting schedule has the expected liquid/vested split: + // Retroactive unlock = BondingDuration / vesting_eras = 3/10 = 30%. + if let Some(StakingEvent::IncentiveVestingConverted { liquid, vested, .. }) = converted { + let total = liquid + vested; + assert!(total > 0, "Incentive amount should be non-zero"); + let expected_liquid = Perbill::from_rational(3u32, 10u32).mul_floor(total); + assert_eq!(*liquid, expected_liquid, "30% should be released liquid"); + assert_eq!(*vested, total - expected_liquid, "70% should be vested"); + } + }); +} + +/// Helper: set up incentive config and budget, advance to target era, accumulate incentive +/// under hold by paying out intermediate eras. Returns (validator, held_amount). +fn setup_held_incentive() -> (AccountId, Balance) { + assert_ok!(Staking::set_validator_self_stake_incentive_config( + RuntimeOrigin::root(), + staking_async::ConfigOp::Set(30), + staking_async::ConfigOp::Set(1000), + staking_async::ConfigOp::Set(Perbill::from_rational(1u32, 2u32)), + )); + pallet_dap::BudgetAllocation::::put(build_budget(&[ + (staker_reward_key(), 40), + (validator_incentive_key(), 10), + (buffer_key(), 50), + ])); + + let active_validators = roll_until_next_active(0); + let validator = active_validators[0]; + + let send_points = |end_index: sp_staking::SessionIndex| { + assert_ok!(rc_client::Pallet::::relay_session_report( + RuntimeOrigin::root(), + rc_client::SessionReport { + end_index, + activation_timestamp: None, + leftover: false, + validator_points: active_validators.iter().map(|v| (*v, 100)).collect(), + }, + )); + roll_next(); + }; + + // Advance through eras 1 and 2 with reward points. + send_points(7); + roll_until_next_active(8); + send_points(13); + roll_until_next_active(14); + + let _ = staking_events_since_last_call(); + + // Pay out era 1 — incentive held (not a batch boundary). + assert_ok!(Staking::payout_stakers(RuntimeOrigin::signed(1), validator, 1)); + + let held = incentive_held(validator); + assert!(held > 0, "Incentive should be held after payout"); + + (validator, held) +} + +/// Tests voluntary exit (unbond + withdraw) settles incentive hold via real pallet-vesting, +/// and blocks withdrawal when vesting slots are full. +#[test] +fn withdraw_unbonded_settles_incentive_hold_via_vesting() { + use frame_support::assert_noop; + + ExtBuilder::default().local_queue().build().execute_with(|| { + let (validator, held) = setup_held_incentive(); + + // Unbond everything. + let active = Staking::ledger(sp_staking::StakingAccount::Stash(validator)).unwrap().active; + assert_ok!(Staking::unbond(RuntimeOrigin::signed(validator), active)); + + // Advance past bonding duration without asserting validator set composition + // (unbonding changes the elected set). + let target_era = Rotator::::active_era() + BondingDuration::get() + 1; + advance_eras_until(target_era); + + let vesting_before: Balance = + Vesting::vesting(validator).unwrap_or_default().iter().map(|s| s.locked()).sum(); + + // -- Scenario 1: withdraw succeeds and creates a real vesting schedule. + hypothetically!({ + assert_ok!(Staking::withdraw_unbonded(RuntimeOrigin::signed(validator), 0)); + + assert!( + Staking::ledger(sp_staking::StakingAccount::Stash(validator)).is_err(), + "Ledger should be gone" + ); + assert_eq!(incentive_held(validator), 0, "Hold should be settled"); + + // On exit, the full held amount is vested + let vesting_after: Balance = + Vesting::vesting(validator).unwrap_or_default().iter().map(|s| s.locked()).sum(); + let new_vesting = vesting_after - vesting_before; + assert_eq!(new_vesting, held, "Full held amount should be vested on exit"); + }); + + // -- Scenario 2: withdraw blocked when vesting slots are full. + fill_vesting_slots(validator); + + assert_noop!( + Staking::withdraw_unbonded(RuntimeOrigin::signed(validator), 0), + staking_async::Error::::IncentiveVestingPending + ); + assert_eq!(incentive_held(validator), held, "Hold should persist"); + }); +} + +/// Tests payee change with active incentive hold and force exit scenarios. +/// +/// Covers: payee migration (Account→Account, Account→Stash), payee→None blocked, +/// force_unstake with vesting conversion, force_unstake with full vesting slots. +#[test] +fn incentive_hold_payee_change_and_exit_scenarios() { + ExtBuilder::default().local_queue().build().execute_with(|| { + let (validator, held) = setup_held_incentive(); + + // -- Scenario 1: set_payee to Account(other) migrates the hold. + let other: AccountId = 9998; + { + use frame_support::traits::fungible::Mutate; + Balances::mint_into(&other, 100).unwrap(); + } + + hypothetically!({ + assert_ok!(Staking::set_payee( + RuntimeOrigin::signed(validator), + staking_async::RewardDestination::Account(other), + )); + assert_eq!(incentive_held(validator), 0, "Hold should move off validator"); + assert_eq!(incentive_held(other), held, "Hold should be on new account"); + }); + + // -- Scenario 2: set_payee to None blocked while hold exists. + hypothetically!({ + assert!( + Staking::set_payee( + RuntimeOrigin::signed(validator), + staking_async::RewardDestination::None, + ) + .is_err(), + "Payee change to None should be blocked while incentive held" + ); + assert_eq!(incentive_held(validator), held, "Hold unchanged"); + }); + + // -- Scenario 3: force_unstake with vesting slots available → converts to vesting. + hypothetically!({ + let balance_before = >::total_balance(&validator); + assert_ok!(Staking::force_unstake(RuntimeOrigin::root(), validator, 0)); + + assert_eq!(incentive_held(validator), 0, "Hold should be cleared"); + assert!( + Staking::ledger(sp_staking::StakingAccount::Stash(validator)).is_err(), + "Ledger should be gone" + ); + // Vesting schedule created (conversion succeeded since slots were available). + let schedules = Vesting::vesting(validator); + assert!( + schedules.is_some() && !schedules.as_ref().unwrap().is_empty(), + "Vesting schedule should exist after force exit with available slots" + ); + assert!( + >::total_balance(&validator) >= balance_before, + ); + }); + + // -- Scenario 4: force_unstake with full vesting slots → releases as liquid. + fill_vesting_slots(validator); + let balance_before = >::total_balance(&validator); + + assert_ok!(Staking::force_unstake(RuntimeOrigin::root(), validator, 0)); + + assert_eq!(incentive_held(validator), 0, "Hold should be released"); + assert!( + Staking::ledger(sp_staking::StakingAccount::Stash(validator)).is_err(), + "Ledger should be gone" + ); + assert!( + >::total_balance(&validator) >= balance_before, + "Balance preserved (incentive released as liquid)" + ); + }); +} diff --git a/substrate/frame/staking-async/integration-tests/src/ah/weights.rs b/substrate/frame/staking-async/integration-tests/src/ah/weights.rs index 18431b5cb65d7..9a72fece9daa9 100644 --- a/substrate/frame/staking-async/integration-tests/src/ah/weights.rs +++ b/substrate/frame/staking-async/integration-tests/src/ah/weights.rs @@ -115,22 +115,22 @@ impl pallet_election_provider_multi_block_unsigned::WeightInfo for MultiBlockEle pub struct StakingAsyncWeightInfo; impl pallet_staking_async::WeightInfo for StakingAsyncWeightInfo { fn bond() -> Weight { - unreachable!() + Default::default() } fn bond_extra() -> Weight { unreachable!() } fn unbond() -> Weight { - unreachable!() + Default::default() } fn withdraw_unbonded_update() -> Weight { - unreachable!() + Default::default() } fn withdraw_unbonded_kill() -> Weight { - unreachable!() + Default::default() } fn validate() -> Weight { - unreachable!() + Default::default() } fn kick(_: u32) -> Weight { unreachable!() @@ -139,10 +139,10 @@ impl pallet_staking_async::WeightInfo for StakingAsyncWeightInfo { unreachable!() } fn chill() -> Weight { - unreachable!() + Default::default() } fn set_payee() -> Weight { - unreachable!() + Default::default() } fn update_payee() -> Weight { unreachable!() @@ -172,7 +172,7 @@ impl pallet_staking_async::WeightInfo for StakingAsyncWeightInfo { unreachable!() } fn payout_stakers_alive_staked(_: u32) -> Weight { - unreachable!() + Default::default() } fn rebond(_: u32) -> Weight { unreachable!() @@ -195,6 +195,12 @@ impl pallet_staking_async::WeightInfo for StakingAsyncWeightInfo { fn set_min_commission() -> Weight { unreachable!() } + fn set_max_commission() -> Weight { + unreachable!() + } + fn set_validator_self_stake_incentive_config() -> Weight { + Default::default() + } fn restore_ledger() -> Weight { unreachable!() } diff --git a/substrate/frame/staking-async/runtimes/parachain/src/staking.rs b/substrate/frame/staking-async/runtimes/parachain/src/staking.rs index 1296fc73781e8..81d559ceaddba 100644 --- a/substrate/frame/staking-async/runtimes/parachain/src/staking.rs +++ b/substrate/frame/staking-async/runtimes/parachain/src/staking.rs @@ -382,29 +382,24 @@ impl pallet_bags_list::Config for Runtime { type MaxAutoRebagPerBlock = (); } -pub struct EraPayout; -impl pallet_staking_async::EraPayout for EraPayout { - fn era_payout( - _total_staked: Balance, - _total_issuance: Balance, - era_duration_millis: u64, - ) -> (Balance, Balance) { +/// Polkadot inflation curve: 8% annual on a fixed baseline issuance. +/// +/// Returns the total inflation for the given elapsed time. The split between stakers, +/// validator incentive, and buffer is handled by the DAP budget allocation map. +pub struct PolkadotIssuanceCurve; +impl sp_staking::budget::IssuanceCurve for PolkadotIssuanceCurve { + fn issue(_total_issuance: Balance, elapsed_millis: u64) -> Balance { const MILLISECONDS_PER_YEAR: u64 = (1000 * 3600 * 24 * 36525) / 100; - // A normal-sized era will have 1 / 365.25 here: - let relative_era_len = - FixedU128::from_rational(era_duration_millis.into(), MILLISECONDS_PER_YEAR.into()); + let relative_period = + FixedU128::from_rational(elapsed_millis.into(), MILLISECONDS_PER_YEAR.into()); // Fixed total TI that we use as baseline for the issuance. let fixed_total_issuance: i128 = 5_216_342_402_773_185_773; let fixed_inflation_rate = FixedU128::from_rational(8, 100); let yearly_emission = fixed_inflation_rate.saturating_mul_int(fixed_total_issuance); - let era_emission = relative_era_len.saturating_mul_int(yearly_emission); - // 15% to treasury, as per Polkadot ref 1139. - let to_treasury = FixedU128::from_rational(15, 100).saturating_mul_int(era_emission); - let to_stakers = era_emission.saturating_sub(to_treasury); - - (to_stakers.saturated_into(), to_treasury.saturated_into()) + let period_emission = relative_period.saturating_mul_int(yearly_emission); + period_emission.saturated_into() } } @@ -424,11 +419,8 @@ parameter_types! { // of nominators. pub const MaxControllersInDeprecationBatch: u32 = 751; pub const MaxNominations: u32 = ::LIMIT as u32; - // Note: In WAH, this should be set closer to the ideal era duration to trigger capping more - // frequently. On Kusama and Polkadot, a higher value like 7 × ideal_era_duration is more - // appropriate. - pub const MaxEraDuration: u64 = RelaySessionDuration::get() as u64 * RELAY_CHAIN_SLOT_DURATION_MILLIS as u64 * SessionsPerEra::get() as u64; pub MaxPruningItems: u32 = 100; + pub const StakingPalletId: PalletId = PalletId(*b"py/stkng"); } impl pallet_staking_async::Config for Runtime { @@ -440,13 +432,15 @@ impl pallet_staking_async::Config for Runtime { type CurrencyToVote = sp_staking::currency_to_vote::SaturatingCurrencyToVote; type RewardRemainder = (); type Slash = Dap; + type UnclaimedRewardHandler = Dap; type Reward = (); type SessionsPerEra = SessionsPerEra; type BondingDuration = BondingDuration; type SlashDeferDuration = SlashDeferDuration; type NominatorFastUnbondDuration = NominatorFastUnbondDuration; type AdminOrigin = EitherOf, StakingAdmin>; - type EraPayout = EraPayout; + type GeneralPots = pallet_staking_async::Seed; + type EraPots = pallet_staking_async::Seed; type MaxExposurePageSize = MaxExposurePageSize; type ElectionProvider = MultiBlockElection; type VoterList = VoterList; @@ -458,11 +452,19 @@ impl pallet_staking_async::Config for Runtime { type MaxControllersInDeprecationBatch = MaxControllersInDeprecationBatch; type EventListeners = (NominationPools, DelegatedStaking); type WeightInfo = pallet_staking_async::weights::SubstrateWeight; - type MaxEraDuration = MaxEraDuration; type MaxPruningItems = MaxPruningItems; type PlanningEraOffset = pallet_staking_async::PlanningEraOffsetOf>; type RcClientInterface = StakingRcClient; + type StakerRewardCalculator = + pallet_staking_async::reward::DefaultStakerRewardCalculator; + /// Vest validator self-stake incentive rewards over approximately one year of relay-chain + /// blocks. + type VestingDuration = ConstU32<{ 365 * DAYS }>; + /// Relay chain session length in RC blocks (1 hour on Westend). + type BlocksPerSession = ConstU32<{ 1 * HOURS }>; + type ValidatorIncentivePayout = + pallet_staking_async::VestedIncentivePayout>; } // Relay chain session keys matching Westend configuration. @@ -492,20 +494,29 @@ impl pallet_staking_async_rc_client::Config for Runtime { } parameter_types! { - pub const DapPalletId: frame_support::PalletId = frame_support::PalletId(*b"dap/buff"); - pub const DapIssuanceCadence: u64 = 60_000; - pub const DapMaxElapsedPerDrip: u64 = 600_000; + pub const DapPalletId: PalletId = PalletId(*b"dap/buff"); + /// Drip inflation every 60 seconds. + pub const IssuanceCadence: u64 = 60_000; + /// Safety ceiling on elapsed time per drip: 10 minutes. + /// Prevents over-minting if blocks are delayed or chain stalls. + pub const MaxElapsedPerDrip: u64 = 600_000; } impl pallet_dap::Config for Runtime { type Currency = Balances; type PalletId = DapPalletId; - type IssuanceCurve = (); - type BudgetRecipients = (pallet_dap::Pallet,); - type Time = pallet_timestamp::Pallet; - type IssuanceCadence = DapIssuanceCadence; - type MaxElapsedPerDrip = DapMaxElapsedPerDrip; - type BudgetOrigin = frame_system::EnsureRoot; + type IssuanceCurve = PolkadotIssuanceCurve; + type BudgetRecipients = ( + Dap, + pallet_staking_async::StakerRewardRecipient>, + pallet_staking_async::ValidatorIncentiveRecipient< + pallet_staking_async::Seed, + >, + ); + type Time = Timestamp; + type IssuanceCadence = IssuanceCadence; + type MaxElapsedPerDrip = MaxElapsedPerDrip; + type BudgetOrigin = EitherOf, StakingAdmin>; type WeightInfo = (); } diff --git a/substrate/frame/staking-async/src/asset.rs b/substrate/frame/staking-async/src/asset.rs index 591ffc06eec5d..0484c4c98092f 100644 --- a/substrate/frame/staking-async/src/asset.rs +++ b/substrate/frame/staking-async/src/asset.rs @@ -80,6 +80,22 @@ pub fn kill_stake(who: &T::AccountId) -> DispatchResult { T::Currency::release_all(&HoldReason::Staking.into(), who, Precision::BestEffort).map(|_| ()) } +/// Balance held under [`HoldReason::IncentiveVesting`] for `who`. +pub fn incentive_held(who: &T::AccountId) -> BalanceOf { + T::Currency::balance_on_hold(&HoldReason::IncentiveVesting.into(), who) +} + +/// Place a hold on `amount` under [`HoldReason::IncentiveVesting`]. +pub fn hold_incentive(who: &T::AccountId, amount: BalanceOf) -> DispatchResult { + T::Currency::hold(&HoldReason::IncentiveVesting.into(), who, amount) +} + +/// Release all funds held under [`HoldReason::IncentiveVesting`] for `who`. +pub fn release_incentive_hold(who: &T::AccountId) -> BalanceOf { + T::Currency::release_all(&HoldReason::IncentiveVesting.into(), who, Precision::BestEffort) + .unwrap_or_default() +} + /// Slash the value from `who`. /// /// A negative imbalance is returned which can be resolved to deposit the slashed value. diff --git a/substrate/frame/staking-async/src/benchmarking.rs b/substrate/frame/staking-async/src/benchmarking.rs index da0e14c554225..fd9ca80c20e94 100644 --- a/substrate/frame/staking-async/src/benchmarking.rs +++ b/substrate/frame/staking-async/src/benchmarking.rs @@ -40,7 +40,7 @@ use sp_runtime::{ traits::{Bounded, One, StaticLookup, Zero}, Perbill, Percent, Saturating, }; -use sp_staking::currency_to_vote::CurrencyToVote; +use sp_staking::{currency_to_vote::CurrencyToVote, StakerRewardCalculator}; use testing_utils::*; const SEED: u32 = 0; @@ -118,11 +118,32 @@ pub(crate) fn create_validator_with_nominators( ErasRewardPoints::::insert(planned_era, reward); - // Create reward pool - let total_payout = asset::existential_deposit::() - .saturating_mul(upper_bound.into()) - .saturating_mul(1000u32.into()); - >::insert(planned_era, total_payout); + // Fund general pots and snapshot into era pots. + let ed = asset::existential_deposit::(); + let reward_amount = ed.saturating_mul(1000u32.into()); + let incentive_amount = ed.saturating_mul(100u32.into()); + let general_staker_pot = + T::GeneralPots::general_pot_account(crate::GeneralPotType::StakerRewards); + let general_incentive_pot = + T::GeneralPots::general_pot_account(crate::GeneralPotType::ValidatorIncentive); + let _ = asset::mint_creating::(&general_staker_pot, reward_amount); + let _ = asset::mint_creating::(&general_incentive_pot, incentive_amount); + + let allocation = crate::reward::EraRewardManager::::snapshot_era_rewards(planned_era); + >::insert(planned_era, allocation.staker_rewards); + + // Configure validator incentive so the benchmark exercises the incentive transfer path. + let validator_bond = asset::staked::(&v_stash); + OptimumSelfStake::::put(validator_bond); + HardCapSelfStake::::put(validator_bond.saturating_mul(2u32.into())); + SelfStakeSlopeFactor::::put(Perbill::from_percent(50)); + + // Set per-validator weight and era totals so incentive payout is exercised. + let validator_weight = + T::StakerRewardCalculator::calculate_validator_incentive_weight(validator_bond); + ErasValidatorIncentive::::insert(planned_era, &v_stash, validator_weight); + Eras::::add_total_validator_weight(planned_era, validator_weight); + Eras::::set_validator_incentive_allocation(planned_era, allocation.validator_incentive); Ok((v_stash, nominators, planned_era)) } @@ -939,6 +960,31 @@ mod benchmarks { assert_eq!(MinCommission::::get(), Perbill::from_percent(100)); } + #[benchmark] + fn set_max_commission() { + let max_commission = Perbill::max_value(); + + #[extrinsic_call] + _(RawOrigin::Root, max_commission); + + assert_eq!(MaxCommission::::get(), Perbill::from_percent(100)); + } + + #[benchmark] + fn set_validator_self_stake_incentive_config() { + #[extrinsic_call] + _( + RawOrigin::Root, + ConfigOp::Set(30_000u32.into()), + ConfigOp::Set(100_000u32.into()), + ConfigOp::Set(Perbill::from_percent(50)), + ); + + assert_eq!(OptimumSelfStake::::get(), 30_000u32.into()); + assert_eq!(HardCapSelfStake::::get(), 100_000u32.into()); + assert_eq!(SelfStakeSlopeFactor::::get(), Perbill::from_percent(50)); + } + #[benchmark] fn restore_ledger() -> Result<(), BenchmarkError> { let (stash, controller) = create_stash_controller::(0, 100, RewardDestination::Staked)?; diff --git a/substrate/frame/staking-async/src/lib.rs b/substrate/frame/staking-async/src/lib.rs index 9aac50b542639..e709a5de68d04 100644 --- a/substrate/frame/staking-async/src/lib.rs +++ b/substrate/frame/staking-async/src/lib.rs @@ -191,6 +191,7 @@ pub mod asset; pub mod election_size_tracker; pub mod ledger; mod pallet; +pub mod reward; pub mod session_rotation; pub mod slashing; pub mod weights; @@ -214,7 +215,7 @@ use sp_runtime::{ BoundedBTreeMap, Debug, Perbill, Saturating, }; use sp_staking::{EraIndex, ExposurePage, PagedExposureMetadata, SessionIndex}; -pub use sp_staking::{Exposure, IndividualExposure, StakerStatus}; +pub use sp_staking::{EraPayout, Exposure, IndividualExposure, StakerStatus}; pub use weights::WeightInfo; // public exports @@ -224,6 +225,241 @@ pub use pallet::{pallet::*, UseNominatorsAndValidatorsMap, UseValidatorsMap}; pub(crate) const STAKING_ID: LockIdentifier = *b"staking "; pub(crate) const LOG_TARGET: &str = "runtime::staking-async"; +/// Identifies different types of era pot accounts for reward distribution. +/// +/// Each era can have multiple pot accounts for different reward purposes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, TypeInfo)] +pub enum EraPotType { + /// Pot for staker rewards (nominators + validators). + StakerRewards, + /// Pot for validator self-stake incentive. + ValidatorSelfStake, +} + +/// Trait for generating era pot account IDs. +/// +/// Implementors define how to generate account IDs for era reward pots. +/// This trait is local to staking-async and used primarily for testing flexibility. +pub trait EraPotAccountProvider { + /// Generate an era pot account ID for the given era and pot type. + /// + /// # Parameters + /// - `era`: The era index + /// - `pot_type`: The type of reward pot + /// + /// # Returns + /// The account ID for this era's reward pot of the specified type + fn era_pot_account(era: EraIndex, pot_type: EraPotType) -> AccountId; +} + +/// Seed-based pot account provider for production use. +/// +/// Generates deterministic account IDs using a seed (typically a pallet ID). +pub struct Seed(core::marker::PhantomData); + +impl EraPotAccountProvider for Seed +where + AccountId: codec::FullCodec, + S: Get, +{ + fn era_pot_account(era: EraIndex, pot_type: EraPotType) -> AccountId { + use sp_runtime::traits::AccountIdConversion; + S::get().into_sub_account_truncating((era, pot_type)) + } +} + +/// Sequential pot account provider for testing. +/// +/// Generates simple sequential account IDs for predictable testing. +/// Base 100_000: era 0 → 100000, 100001; era 1 → 100010, 100011, etc. +#[cfg(feature = "std")] +pub struct SequentialTest; + +#[cfg(feature = "std")] +impl EraPotAccountProvider for SequentialTest +where + AccountId: From, +{ + fn era_pot_account(era: EraIndex, pot_type: EraPotType) -> AccountId { + let pot_type_offset = match pot_type { + EraPotType::StakerRewards => 0, + EraPotType::ValidatorSelfStake => 1, + }; + AccountId::from(100_000 + (era as u64 * 10) + pot_type_offset) + } +} + +#[cfg(feature = "std")] +impl GeneralPotAccountProvider for SequentialTest +where + AccountId: From, +{ + fn general_pot_account(pot_type: GeneralPotType) -> AccountId { + let offset = match pot_type { + GeneralPotType::StakerRewards => 0, + GeneralPotType::ValidatorIncentive => 1, + }; + AccountId::from(200_000 + offset) + } +} + +/// Identifies the two general (non-era-specific) reward pots. +/// +/// DAP drips inflation into these accounts continuously. At era boundaries, +/// staking snapshots the balances and transfers them to era-specific pots. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, TypeInfo)] +pub enum GeneralPotType { + /// General pot for staker rewards. + StakerRewards, + /// General pot for validator self-stake incentive. + ValidatorIncentive, +} + +/// Trait that provides general (non-era-specific) pot accounts. +/// +/// These pots receive continuous inflation drips from DAP. At era boundaries, +/// staking snapshots their balances into era-specific pots. +/// +/// This is a separate trait rather than PalletId derivation because small AccountId types +/// (e.g., u64 in tests) don't have enough bytes for PalletId sub-account derivation +/// to produce unique accounts. +pub trait GeneralPotAccountProvider { + fn general_pot_account(pot_type: GeneralPotType) -> AccountId; +} + +/// PalletId-based implementation of [`GeneralPotAccountProvider`]. +impl GeneralPotAccountProvider for Seed +where + AccountId: codec::FullCodec, + S: Get, +{ + fn general_pot_account(pot_type: GeneralPotType) -> AccountId { + use sp_runtime::traits::AccountIdConversion; + S::get().into_sub_account_truncating(pot_type) + } +} + +/// Budget recipient for staker rewards. +/// +/// Exposes the general staker reward pot so DAP can drip inflation into it. +/// `G` implements [`GeneralPotAccountProvider`] to derive the pot account. +pub struct StakerRewardRecipient(core::marker::PhantomData); + +impl sp_staking::budget::BudgetRecipient for StakerRewardRecipient +where + G: GeneralPotAccountProvider, +{ + fn budget_key() -> sp_staking::budget::BudgetKey { + sp_staking::budget::BudgetKey::truncate_from(b"staker_rewards".to_vec()) + } + + fn pot_account() -> AccountId { + G::general_pot_account(GeneralPotType::StakerRewards) + } +} + +/// Budget recipient for validator self-stake incentive. +/// +/// Exposes the general validator incentive pot so DAP can drip inflation into it. +/// `G` implements [`GeneralPotAccountProvider`] to derive the pot account. +pub struct ValidatorIncentiveRecipient(core::marker::PhantomData); + +impl sp_staking::budget::BudgetRecipient for ValidatorIncentiveRecipient +where + G: GeneralPotAccountProvider, +{ + fn budget_key() -> sp_staking::budget::BudgetKey { + sp_staking::budget::BudgetKey::truncate_from(b"validator_incentive".to_vec()) + } + + fn pot_account() -> AccountId { + G::general_pot_account(GeneralPotType::ValidatorIncentive) + } +} + +/// Handles paying out the validator self-stake incentive reward. +/// +/// Implementations define how the incentive is delivered to the validator. The pallet +/// may call this with `source == dest` (self-transfer) when converting accumulated +/// incentive holds into a vesting schedule. +/// +/// ## Implementations +/// +/// - [`ImmediateIncentivePayout`]: liquid transfer (no vesting). +/// - [`VestedIncentivePayout`]: creates a linear vesting schedule via `pallet-vesting`. +pub trait ValidatorIncentivePayout { + /// Pay `amount` from `source` to `dest`. + /// + /// `vesting_duration` is the number of blocks over which to vest the payment. May be + /// called with `source == dest` for self-transfer patterns. + fn payout( + source: &AccountId, + dest: &AccountId, + amount: Balance, + vesting_duration: BlockNumber, + ) -> Result; +} + +/// Pays validator incentive immediately as liquid funds (no vesting). +pub struct ImmediateIncentivePayout(core::marker::PhantomData); + +impl + ValidatorIncentivePayout for ImmediateIncentivePayout +where + AccountId: Eq, + Currency: frame_support::traits::fungible::Mutate, + Balance: Copy + sp_runtime::traits::Zero, +{ + fn payout( + source: &AccountId, + dest: &AccountId, + amount: Balance, + _vesting_duration: BlockNumber, + ) -> Result { + Currency::transfer( + source, + dest, + amount, + frame_support::traits::tokens::Preservation::Expendable, + )?; + Ok(amount) + } +} + +/// Pays validator incentive via a linear vesting schedule. +/// +/// Incentive pay is accumulated under [`HoldReason::IncentiveVesting`] for the first +/// [`Config::BondingDuration`] eras (the accumulation period). At the end of each +/// accumulation period, the hold is released: a retroactive fraction is paid as liquid, +/// and the remainder is placed under a vesting schedule that unlocks per-block over the +/// remaining vesting duration. +/// +/// The pallet calls this as a self-transfer (`source == dest`): the `Currency::transfer` +/// is a no-op and only the vesting lock is created. +/// +/// Type parameter: +/// - `Vesting`: a [`frame_support::traits::tokens::VestedPayout`] implementor (e.g. +/// `pallet_vesting::Pallet`). +pub struct VestedIncentivePayout(core::marker::PhantomData); + +impl + ValidatorIncentivePayout for VestedIncentivePayout +where + Balance: Copy + sp_runtime::traits::Zero, + Vesting: + frame_support::traits::tokens::VestedPayout, +{ + fn payout( + source: &AccountId, + dest: &AccountId, + amount: Balance, + vesting_duration: BlockNumber, + ) -> Result { + Vesting::vested_transfer(source, dest, amount, vesting_duration, None)?; + Ok(amount) + } +} + // syntactic sugar for logging. #[macro_export] macro_rules! log { @@ -489,8 +725,6 @@ impl NominationsQuota for FixedNominationsQuot } } -pub use sp_staking::EraPayout; - /// Mode of era-forcing. #[derive( Copy, diff --git a/substrate/frame/staking-async/src/mock.rs b/substrate/frame/staking-async/src/mock.rs index ddce568c74720..0d1411442b56f 100644 --- a/substrate/frame/staking-async/src/mock.rs +++ b/substrate/frame/staking-async/src/mock.rs @@ -39,7 +39,9 @@ use sp_io; use sp_npos_elections::BalancingConfig; use sp_runtime::{traits::Zero, BuildStorage, Weight}; use sp_staking::{ - currency_to_vote::SaturatingCurrencyToVote, OnStakingUpdate, SessionIndex, StakingAccount, + budget::{BudgetRecipient, IssuanceCurve}, + currency_to_vote::SaturatingCurrencyToVote, + OnStakingUpdate, SessionIndex, StakingAccount, }; use std::collections::BTreeMap; @@ -52,6 +54,7 @@ frame_support::construct_runtime!( Balances: pallet_balances, Staking: pallet_staking_async, VoterBagsList: pallet_bags_list::, + Dap: pallet_dap, } ); @@ -81,6 +84,8 @@ parameter_types! { pub static ExistentialDeposit: Balance = 1; pub static SlashDeferDuration: EraIndex = 0; pub static MaxControllersInDeprecationBatch: u32 = 5900; + pub static VestingDurationBlocks: BlockNumber = 0; + pub static BlocksPerSession: BlockNumber = 0; pub static BondingDuration: EraIndex = 3; pub static NominatorFastUnbondDuration: EraIndex = 2; pub static HistoryDepth: u32 = 80; @@ -105,12 +110,48 @@ impl frame_system::Config for Test { } #[derive_impl(pallet_balances::config_preludes::TestDefaultConfig)] impl pallet_balances::Config for Test { - type MaxLocks = frame_support::traits::ConstU32<1024>; + type MaxLocks = ConstU32<1024>; type Balance = u128; type ExistentialDeposit = ExistentialDeposit; type AccountStore = System; } +parameter_types! { + pub const DapPalletId: frame_support::PalletId = frame_support::PalletId(*b"dap/buff"); + pub const TestIssuanceCadence: u64 = 0; // drip every block + pub const TestMaxElapsedPerDrip: u64 = 600_000; // 10 minutes +} + +/// Mock time provider backed by session_mock::Timestamp. +pub struct MockTime; +impl frame_support::traits::Time for MockTime { + type Moment = u64; + fn now() -> u64 { + session_mock::Timestamp::get() + } +} + +pub fn general_staker_pot() -> AccountId { + SequentialTest::general_pot_account(GeneralPotType::StakerRewards) +} + +pub fn general_incentive_pot() -> AccountId { + SequentialTest::general_pot_account(GeneralPotType::ValidatorIncentive) +} + +impl pallet_dap::Config for Test { + type Currency = Balances; + type PalletId = DapPalletId; + type IssuanceCurve = OneTokenPerMillisecond; + type BudgetRecipients = + (Dap, StakerRewardRecipient, ValidatorIncentiveRecipient); + type Time = MockTime; + type IssuanceCadence = TestIssuanceCadence; + type MaxElapsedPerDrip = TestMaxElapsedPerDrip; + type BudgetOrigin = EnsureRoot; + type WeightInfo = (); +} + parameter_types! { pub static RewardRemainderUnbalanced: u128 = 0; } @@ -410,53 +451,81 @@ ord_parameter_types! { parameter_types! { pub static RemainderRatio: Perbill = Perbill::from_percent(50); - pub static MaxEraDuration: u64 = time_per_era() * 7; pub const MaxPruningItems: u32 = 100; + pub const StakingPalletId: frame_support::PalletId = frame_support::PalletId(*b"py/stkng"); } + pub struct OneTokenPerMillisecond; -impl EraPayout for OneTokenPerMillisecond { - fn era_payout( - _total_staked: Balance, - _total_issuance: Balance, - era_duration_millis: u64, - ) -> (Balance, Balance) { - let total = era_duration_millis as Balance; - let remainder = RemainderRatio::get() * total; - let stakers = total - remainder; - (stakers, remainder) +impl IssuanceCurve for OneTokenPerMillisecond { + fn issue(_total_issuance: Balance, elapsed_millis: u64) -> Balance { + // Return 1 token per millisecond elapsed + elapsed_millis as Balance } } -impl crate::pallet::pallet::Config for Test { - type RuntimeHoldReason = RuntimeHoldReason; +pub(crate) fn staker_reward_key() -> sp_staking::budget::BudgetKey { + as BudgetRecipient>::budget_key() +} + +pub(crate) fn validator_incentive_key() -> sp_staking::budget::BudgetKey { + as BudgetRecipient>::budget_key() +} + +pub(crate) fn buffer_key() -> sp_staking::budget::BudgetKey { + >::budget_key() +} + +/// Build a DAP budget allocation map from `(key, percent)` pairs. +pub(crate) fn build_budget( + entries: &[(sp_staking::budget::BudgetKey, u32)], +) -> pallet_dap::BudgetAllocationMap { + let mut budget = BoundedBTreeMap::new(); + for (key, pct) in entries { + budget.try_insert(key.clone(), Perbill::from_percent(*pct)).unwrap(); + } + budget +} + +/// Build the default 50/50 staker/buffer budget used by most tests. +pub(crate) fn default_budget() -> pallet_dap::BudgetAllocationMap { + build_budget(&[(staker_reward_key(), 50), (buffer_key(), 50)]) +} + +impl Config for Test { type OldCurrency = Balances; type Currency = Balances; + type RuntimeHoldReason = RuntimeHoldReason; + type CurrencyBalance = Balance; + type CurrencyToVote = SaturatingCurrencyToVote; + type ElectionProvider = TestElectionProvider; + type NominationsQuota = WeightedNominationsQuota<16>; + type HistoryDepth = HistoryDepth; type RewardRemainder = RewardRemainderMock; + type Slash = Dap; + type UnclaimedRewardHandler = Dap; type Reward = MockReward; type SessionsPerEra = SessionsPerEra; + type PlanningEraOffset = PlanningEraOffset; + type BondingDuration = BondingDuration; + type NominatorFastUnbondDuration = NominatorFastUnbondDuration; type SlashDeferDuration = SlashDeferDuration; type AdminOrigin = EitherOfDiverse, EnsureSignedBy>; - type EraPayout = OneTokenPerMillisecond; + type GeneralPots = SequentialTest; + type EraPots = SequentialTest; type MaxExposurePageSize = MaxExposurePageSize; type MaxValidatorSet = MaxValidatorSet; - type ElectionProvider = TestElectionProvider; type VoterList = VoterBagsList; type TargetList = UseValidatorsMap; - type NominationsQuota = WeightedNominationsQuota<16>; type MaxUnlockingChunks = MaxUnlockingChunks; - type HistoryDepth = HistoryDepth; - type BondingDuration = BondingDuration; - type NominatorFastUnbondDuration = NominatorFastUnbondDuration; type MaxControllersInDeprecationBatch = MaxControllersInDeprecationBatch; type EventListeners = EventListenerMock; - type MaxEraDuration = MaxEraDuration; type MaxPruningItems = MaxPruningItems; - type PlanningEraOffset = PlanningEraOffset; - type Filter = MockedRestrictList; type RcClientInterface = session_mock::Session; - type CurrencyBalance = Balance; - type CurrencyToVote = SaturatingCurrencyToVote; - type Slash = (); + type Filter = MockedRestrictList; + type StakerRewardCalculator = reward::DefaultStakerRewardCalculator; + type VestingDuration = VestingDurationBlocks; + type BlocksPerSession = BlocksPerSession; + type ValidatorIncentivePayout = ImmediateIncentivePayout; type WeightInfo = (); } @@ -627,6 +696,11 @@ impl ExtBuilder { MaxWinnersPerPage::set(max); self } + + /// Should NEVER be used in normal tests! + /// + /// This disables try_state invariant checks, which can hide real bugs in your test setup. + /// Only use this when intentionally testing corruption scenarios or recovery mechanisms. pub(crate) fn try_state(self, enable: bool) -> Self { SkipTryStateCheck::set(!enable); self @@ -709,6 +783,30 @@ impl ExtBuilder { ext.execute_with(|| { crate::AreNominatorsSlashable::::put(nominators_slashable); + // Disable legacy minting from era 0 in tests to catch missing era pots early + crate::DisableLegacyMintingEra::::put(0); + // Set budget allocation: 50% stakers, 50% buffer (must sum to 100%). + pallet_dap::BudgetAllocation::::put(default_budget()); + // Initialize DAP's LastIssuanceTimestamp + pallet_dap::LastIssuanceTimestamp::::put(INIT_TIMESTAMP); + // Fund general pot accounts with ED so they stay alive across era snapshots. + let ed = ExistentialDeposit::get(); + >::mint_into( + &general_staker_pot(), + ed, + ) + .expect("mint general staker pot"); + >::mint_into( + &general_incentive_pot(), + ed, + ) + .expect("mint general incentive pot"); + // Fund DAP buffer account with ED. + let dap_buffer = as sp_staking::budget::BudgetRecipient< + AccountId, + >>::pot_account(); + >::mint_into(&dap_buffer, ed) + .expect("mint dap buffer"); session_mock::Session::roll_until_active_era(1); RewardRemainderUnbalanced::set(0); if self.flush_events { @@ -765,22 +863,18 @@ pub(crate) fn bond_virtual_nominator( } pub(crate) fn validator_payout_for(duration: u64) -> Balance { - let (payout, _rest) = ::EraPayout::era_payout( - pallet_staking_async::ErasTotalStake::::get(active_era()), - pallet_balances::TotalIssuance::::get(), - duration, - ); + let total_inflation = + OneTokenPerMillisecond::issue(pallet_balances::TotalIssuance::::get(), duration); + // Apply budget allocation to get staker portion + let budget = pallet_dap::BudgetAllocation::::get(); + let staker_pct = budget.get(&staker_reward_key()).copied().unwrap_or(Perbill::zero()); + let payout = staker_pct.mul_floor(total_inflation); assert!(payout > 0); payout } pub(crate) fn total_payout_for(duration: u64) -> Balance { - let (payout, rest) = ::EraPayout::era_payout( - pallet_staking_async::ErasTotalStake::::get(active_era()), - pallet_balances::TotalIssuance::::get(), - duration, - ); - payout + rest + OneTokenPerMillisecond::issue(pallet_balances::TotalIssuance::::get(), duration) } /// Time it takes to finish a session. @@ -863,6 +957,18 @@ pub(crate) fn make_all_reward_payment(era: EraIndex) { } } +/// Configures mock for vesting-based incentive tests. +/// +/// With default `SessionsPerEra = 3` and `BondingDuration = 3`: +/// - `blocks_per_era = blocks_per_session * 3` +/// - `vesting_eras = vesting_blocks / blocks_per_era` +/// - Batch conversion triggers at eras 3, 6, 9, ... +/// - Retroactive unlock fraction = `BondingDuration / vesting_eras` +pub(crate) fn setup_vesting_params(vesting_blocks: BlockNumber, blocks_per_session: BlockNumber) { + VestingDurationBlocks::set(vesting_blocks); + BlocksPerSession::set(blocks_per_session); +} + pub(crate) fn bond_controller_stash(controller: AccountId, stash: AccountId) -> Result<(), String> { >::get(&stash).map_or(Ok(()), |_| Err("stash already bonded"))?; >::get(&controller).map_or(Ok(()), |_| Err("controller already bonded"))?; diff --git a/substrate/frame/staking-async/src/pallet/impls.rs b/substrate/frame/staking-async/src/pallet/impls.rs index 07314b0087565..1dbe29a7c71be 100644 --- a/substrate/frame/staking-async/src/pallet/impls.rs +++ b/substrate/frame/staking-async/src/pallet/impls.rs @@ -24,8 +24,9 @@ use crate::{ session_rotation::{self, Eras, Rotator}, slashing::OffenceRecord, weights::WeightInfo, - BalanceOf, Exposure, Forcing, LedgerIntegrityState, MaxNominationsOf, Nominations, - NominationsQuota, PositiveImbalanceOf, RewardDestination, SnapshotStatus, StakingLedger, + BalanceOf, EraPotAccountProvider, EraPotType, Exposure, Forcing, LedgerIntegrityState, + MaxNominationsOf, Nominations, NominationsQuota, PagedExposure, PositiveImbalanceOf, + RewardDestination, SnapshotStatus, StakingLedger, ValidatorIncentivePayout as _, ValidatorPrefs, STAKING_ID, }; use alloc::{boxed::Box, vec, vec::Vec}; @@ -38,8 +39,8 @@ use frame_support::{ dispatch::WithPostDispatchInfo, pallet_prelude::*, traits::{ - Defensive, DefensiveSaturating, Get, Imbalance, InspectLockableCurrency, LockableCurrency, - OnUnbalanced, + fungible::Mutate as FunMutate, tokens::Preservation, Defensive, DefensiveSaturating, Get, + Imbalance, InspectLockableCurrency, LockableCurrency, OnUnbalanced, }, weights::Weight, StorageDoubleMap, @@ -48,17 +49,18 @@ use frame_system::{pallet_prelude::BlockNumberFor, RawOrigin}; use pallet_staking_async_rc_client::{self as rc_client}; use sp_runtime::{ traits::{CheckedAdd, Saturating, StaticLookup, Zero}, - ArithmeticError, DispatchResult, Perbill, + ArithmeticError, DispatchResult, Perbill, SaturatedConversion, }; use sp_staking::{ currency_to_vote::CurrencyToVote, - EraIndex, OnStakingUpdate, Page, SessionIndex, Stake, + EraIndex, OnStakingUpdate, Page, SessionIndex, Stake, StakerRewardCalculator, StakingAccount::{self, Controller, Stash}, StakingInterface, }; use super::pallet::*; +use crate::reward::EraRewardManager; #[cfg(feature = "try-runtime")] use frame_support::ensure; #[cfg(any(test, feature = "try-runtime"))] @@ -355,7 +357,7 @@ impl Pallet { ); // Note: if era has no reward to be claimed, era may be future. - let era_payout = Eras::::get_validators_reward(era).ok_or_else(|| { + let era_payout = Eras::::get_stakers_reward(era).ok_or_else(|| { Error::::InvalidEraToReward .with_weight(T::WeightInfo::payout_stakers_alive_staked(0)) })?; @@ -410,67 +412,187 @@ impl Pallet { Perbill::from_rational(validator_reward_points, total_reward_points); // This is how much validator + nominators are entitled to. - let validator_total_payout = validator_total_reward_part * era_payout; + // Use mul_floor to ensure we round down and never exceed era_payout + let validator_total_payout = validator_total_reward_part.mul_floor(era_payout); let validator_commission = Eras::::get_validator_commission(era, &ledger.stash); - // total commission validator takes across all nominator pages - let validator_total_commission_payout = validator_commission * validator_total_payout; - - let validator_leftover_payout = - validator_total_payout.defensive_saturating_sub(validator_total_commission_payout); - // Now let's calculate how this is split to the validator. - let validator_exposure_part = Perbill::from_rational(exposure.own(), exposure.total()); - let validator_staking_payout = validator_exposure_part * validator_leftover_payout; + + // Use the StakerRewardCalculator trait to calculate reward distribution + let reward_split = T::StakerRewardCalculator::calculate_staker_reward( + validator_total_payout, + validator_commission, + exposure.own(), + exposure.total(), + ); + let page_stake_part = Perbill::from_rational(exposure.page_total(), exposure.total()); - // validator commission is paid out in fraction across pages proportional to the page stake. - let validator_commission_payout = page_stake_part * validator_total_commission_payout; + + // Validator's share from staker rewards (proportional to their stake) + let validator_staker_payout_for_page = + page_stake_part.mul_floor(reward_split.validator_payout); + + // Separately pay validator incentive bonus from validator incentive pot + let _validator_incentive_paid = + Self::pay_validator_incentive_for_page(era, &stash, page_stake_part); + + let next_page = Eras::::get_next_claimable_page(era, &stash); + + // On batch-boundary eras, convert accumulated incentive holds to vesting + // once all pages for this validator have been claimed. + let bonding_duration = T::BondingDuration::get(); + if bonding_duration > 0 && + era.is_multiple_of(bonding_duration) && + !T::VestingDuration::get().is_zero() && + next_page.is_none() + { + Self::maybe_convert_incentive_to_vesting(&stash); + } Self::deposit_event(Event::::PayoutStarted { era_index: era, validator_stash: stash.clone(), page, - next: Eras::::get_next_claimable_page(era, &stash), + next: next_page, }); - let mut total_imbalance = PositiveImbalanceOf::::zero(); - // We can now make total validator payout: - if let Some((imbalance, dest)) = - Self::make_payout(&stash, validator_staking_payout + validator_commission_payout) - { - Self::deposit_event(Event::::Rewarded { stash, dest, amount: imbalance.peek() }); - total_imbalance.subsume(imbalance); - } - // Track the number of payout ops to nominators. Note: // `WeightInfo::payout_stakers_alive_staked` always assumes at least a validator is paid // out, so we do not need to count their payout op. + + // Check if this era has a staker rewards pot + let nominator_payout_count: u32 = if EraRewardManager::::has_staker_rewards_pot(era) { + // Transfer from staker rewards pot + Self::payout_from_provider( + era, + &stash, + validator_staker_payout_for_page, + &exposure, + reward_split.nominator_payout, + ) + } else { + // LEGACY: Only used to support old (History Depth) eras which are already finalised + // before reward provider impl. + + // Check if legacy minting is disabled for this era + if let Some(disable_era) = DisableLegacyMintingEra::::get() { + if era >= disable_era { + // This should never happen in production. It indicates a bug where an era + // pot wasn't created when it should have been. + defensive!("Era has no reward pot but legacy minting is disabled!"); + + return Err(Error::::LegacyMintingDisabled.into()); + } + } + + Self::payout_legacy_mint( + &stash, + validator_staker_payout_for_page, + &exposure, + reward_split.nominator_payout, + ) + }; + + debug_assert!(nominator_payout_count <= T::MaxExposurePageSize::get()); + + Ok(Some(T::WeightInfo::payout_stakers_alive_staked(nominator_payout_count)).into()) + } + + /// Payout validator and nominators from reward provider pot (new eras). + /// + /// Returns the number of nominator payouts made. + fn payout_from_provider( + era: EraIndex, + stash: &T::AccountId, + validator_payout: BalanceOf, + exposure: &PagedExposure>, + total_nominator_payout: BalanceOf, + ) -> u32 { let mut nominator_payout_count: u32 = 0; - // Lets now calculate how this is split to the nominators. - // Reward only the clipped exposures. Note this is not necessarily sorted. + // Payout validator + if let Some((amount, dest)) = Self::make_payout_from_provider(era, &stash, validator_payout) + { + Self::deposit_event(Event::::Rewarded { stash: stash.clone(), dest, amount }); + } + + // Payout nominators + // Calculate each nominator's reward based on their share of total nominator stake + let total_nominator_stake = exposure.total().saturating_sub(exposure.own()); for nominator in exposure.others().iter() { - let nominator_exposure_part = Perbill::from_rational(nominator.value, exposure.total()); + // Calculate this nominator's share of total nominator payout + let nominator_exposure_part = + Perbill::from_rational(nominator.value, total_nominator_stake); + // Use mul_floor to ensure we round down and never exceed total_nominator_payout + let nominator_reward: BalanceOf = + nominator_exposure_part.mul_floor(total_nominator_payout); + + if let Some((amount, dest)) = + Self::make_payout_from_provider(era, &nominator.who, nominator_reward) + { + nominator_payout_count.saturating_inc(); + Self::deposit_event(Event::::Rewarded { + stash: nominator.who.clone(), + dest, + amount, + }); + } + } + + nominator_payout_count + } + /// Payout validator and nominators using legacy minting. + /// + /// For eras without reward provider pots (pre-upgrade), this mints rewards on-demand. + /// + /// Returns the number of nominator payouts made. + fn payout_legacy_mint( + stash: &T::AccountId, + validator_payout: BalanceOf, + exposure: &PagedExposure>, + total_nominator_payout: BalanceOf, + ) -> u32 { + let mut nominator_payout_count: u32 = 0; + let mut total_imbalance = PositiveImbalanceOf::::zero(); + + // Payout validator + if let Some((imbalance, dest)) = Self::make_payout_legacy(&stash, validator_payout) { + Self::deposit_event(Event::::Rewarded { + stash: stash.clone(), + dest, + amount: imbalance.peek(), + }); + total_imbalance.subsume(imbalance); + } + + // Payout nominators + // Calculate each nominator's reward based on their share of total nominator stake + let total_nominator_stake = exposure.total().saturating_sub(exposure.own()); + for nominator in exposure.others().iter() { + // Calculate this nominator's share of total nominator payout + let nominator_exposure_part = + Perbill::from_rational(nominator.value, total_nominator_stake); + // Use mul_floor to ensure we round down and never exceed total_nominator_payout let nominator_reward: BalanceOf = - nominator_exposure_part * validator_leftover_payout; - // We can now make nominator payout: - if let Some((imbalance, dest)) = Self::make_payout(&nominator.who, nominator_reward) { - // Note: this logic does not count payouts for `RewardDestination::None`. - nominator_payout_count += 1; - let e = Event::::Rewarded { + nominator_exposure_part.mul_floor(total_nominator_payout); + + if let Some((imbalance, dest)) = + Self::make_payout_legacy(&nominator.who, nominator_reward) + { + nominator_payout_count.saturating_inc(); + Self::deposit_event(Event::::Rewarded { stash: nominator.who.clone(), dest, amount: imbalance.peek(), - }; - Self::deposit_event(e); + }); total_imbalance.subsume(imbalance); } } + // Pass accumulated imbalances to reward handler T::Reward::on_unbalanced(total_imbalance); - debug_assert!(nominator_payout_count <= T::MaxExposurePageSize::get()); - Ok(Some(T::WeightInfo::payout_stakers_alive_staked(nominator_payout_count)).into()) + nominator_payout_count } /// Chill a stash account. @@ -482,9 +604,93 @@ impl Pallet { } } - /// Actually make a payment to a staker. This uses the currency's reward function - /// to pay the right payee for the given staker account. - fn make_payout( + /// Determine the payout account from a reward destination. + /// + /// Returns the account that should receive the payout based on the reward destination. + /// Returns None if the destination is `RewardDestination::None` or if the controller + /// cannot be found for deprecated `RewardDestination::Controller`. + fn payout_account_for_dest( + stash: &T::AccountId, + dest: &RewardDestination, + ) -> Option { + match dest { + RewardDestination::Stash => Some(stash.clone()), + RewardDestination::Staked => Some(stash.clone()), + RewardDestination::Account(ref dest_account) => Some(dest_account.clone()), + RewardDestination::None => None, + #[allow(deprecated)] + RewardDestination::Controller => Self::bonded(stash), + } + } + + /// Look up the current payee for `stash` and resolve it to a concrete account. + /// + /// Returns `None` if payee is missing, is `None`, or resolves to no account. + fn resolve_payout_account(stash: &T::AccountId) -> Option { + Self::payee(Stash(stash.clone())) + .and_then(|dest| Self::payout_account_for_dest(stash, &dest)) + } + + /// Make a payment to a staker from an era reward pot. + /// + /// Transfers rewards from the era-specific pot to the appropriate destination. + fn make_payout_from_provider( + era: EraIndex, + stash: &T::AccountId, + amount: BalanceOf, + ) -> Option<(BalanceOf, RewardDestination)> { + if amount.is_zero() { + return None; + } + + let dest = match Self::payee(Stash(stash.clone())) { + Some(d) => d, + None => { + defensive!("Staker missing payee"); + Self::deposit_event(Event::::Unexpected( + UnexpectedKind::ValidatorMissingPayee { era }, + )); + return None; + }, + }; + + let payout_account = Self::payout_account_for_dest(stash, &dest)?; + + let staker_rewards_pot = T::EraPots::era_pot_account(era, crate::EraPotType::StakerRewards); + if let Err(e) = T::Currency::transfer( + &staker_rewards_pot, + &payout_account, + amount, + Preservation::Expendable, + ) { + log!( + error, + "Failed to transfer reward from staker rewards pot for era {:?}, stash {:?}: {:?}", + era, + stash, + e + ); + return None; + } + + // For Staked destination, update ledger + if matches!(dest, RewardDestination::Staked) { + if let Ok(mut ledger) = Self::ledger(Stash(stash.clone())) { + ledger.active += amount; + ledger.total += amount; + let _ = ledger + .update() + .defensive_proof("ledger fetched from storage, so it exists; qed."); + } + } + + Some((amount, dest)) + } + + /// Legacy: make a payment to a staker by minting. + /// + /// For eras without reward provider pots (pre-upgrade), this mints rewards on-demand. + fn make_payout_legacy( stash: &T::AccountId, amount: BalanceOf, ) -> Option<(PositiveImbalanceOf, RewardDestination)> { @@ -514,24 +720,323 @@ impl Pallet { RewardDestination::None => None, #[allow(deprecated)] RewardDestination::Controller => Self::bonded(stash) - .map(|controller| { - defensive!("Paying out controller as reward destination which is deprecated and should be migrated."); - // This should never happen once payees with a `Controller` variant have been migrated. - // But if it does, just pay the controller account. - asset::mint_creating::(&controller, amount) - }), + .map(|controller| { + defensive!("Paying out controller as reward destination which is deprecated and should be migrated."); + // This should never happen once payees with a `Controller` variant have been migrated. + // But if it does, just pay the controller account. + asset::mint_creating::(&controller, amount) + }), }; maybe_imbalance.map(|imbalance| (imbalance, dest)) } + /// Calculate validator incentive amount for a single page. + /// + /// Computes the validator's share of the incentive pot based on their weight, + /// then pro-rates it for this specific page. + /// + /// Returns the calculated amount, or None if no incentive is due. + fn calculate_validator_incentive_for_page( + era: EraIndex, + stash: &T::AccountId, + page_stake_part: Perbill, + ) -> Option> { + let era_incentive_budget = Eras::::get_validator_incentive_allocation(era); + if era_incentive_budget.is_zero() { + return None; + } + + let (validator_weight, total_weight) = match ( + ErasValidatorIncentive::::get(era, stash), + ErasTotalValidatorWeight::::get(era), + ) { + (Some(w), t) => (w, t), + _ => return None, + }; + + if total_weight.is_zero() { + log!(warn, "Total validator weight is zero but pot allocation exists for era {}", era); + Self::deposit_event(Event::::Unexpected( + UnexpectedKind::ValidatorIncentiveWeightMismatch { era }, + )); + return None; + } + + if validator_weight.is_zero() { + return None; + } + + let validator_weight_part = Perbill::from_rational(validator_weight, total_weight); + let validator_total_incentive = validator_weight_part.mul_floor(era_incentive_budget); + let validator_incentive_for_page = page_stake_part.mul_floor(validator_total_incentive); + + if validator_incentive_for_page.is_zero() { + return None; + } + + Some(validator_incentive_for_page) + } + + /// Transfer validator incentive from the era pot to the validator's account, placing + /// the amount under an [`HoldReason::IncentiveVesting`] hold. + /// + /// Incentives are accumulated under hold and batch-converted to a vesting schedule + /// every [`Config::BondingDuration`] eras via [`Self::maybe_convert_incentive_to_vesting`]. + /// + /// If [`Config::VestingDuration`] is zero, the incentive is paid as liquid immediately + /// (no hold, no vesting). + /// Note: Unlike staker rewards, incentives are never auto-staked for + /// `RewardDestination::Staked` — they are held and then vested. + /// + /// Returns the amount transferred if successful, 0 otherwise. + fn transfer_validator_incentive( + era: EraIndex, + stash: &T::AccountId, + amount: BalanceOf, + ) -> BalanceOf { + let (dest, payout_account) = match Self::payee(Stash(stash.clone())) { + Some(d) if !matches!(d, RewardDestination::None) => { + match Self::payout_account_for_dest(stash, &d) { + Some(account) => (d, account), + None => { + defensive!("Unable to determine payout account for destination"); + return Zero::zero(); + }, + } + }, + _ => { + defensive!("Validator missing payee"); + Self::deposit_event(Event::::Unexpected( + UnexpectedKind::ValidatorMissingPayee { era }, + )); + return Zero::zero(); + }, + }; + + let validator_incentive_pot_account = + T::EraPots::era_pot_account(era, EraPotType::ValidatorSelfStake); + + let vesting_duration = T::VestingDuration::get(); + + // If vesting duration is zero, pay liquid immediately (no hold needed). + if vesting_duration.is_zero() { + match T::Currency::transfer( + &validator_incentive_pot_account, + &payout_account, + amount, + Preservation::Expendable, + ) { + Ok(_) => { + Self::deposit_event(Event::::ValidatorIncentivePaid { + era, + validator_stash: stash.clone(), + dest, + amount, + }); + return amount; + }, + Err(e) => { + log!(warn, "Failed to transfer liquid incentive: {:?}", e); + defensive!("Validator incentive liquid transfer failed"); + Self::deposit_event(Event::::Unexpected( + UnexpectedKind::ValidatorIncentiveTransferFailed { era }, + )); + return Zero::zero(); + }, + } + } + + // Transfer from era pot to the payout account, then hold. + if let Err(e) = T::Currency::transfer( + &validator_incentive_pot_account, + &payout_account, + amount, + Preservation::Expendable, + ) { + log!(warn, "Failed to transfer incentive from era pot: {:?}", e); + defensive!("Validator incentive transfer from era pot failed"); + Self::deposit_event(Event::::Unexpected( + UnexpectedKind::ValidatorIncentiveTransferFailed { era }, + )); + return Zero::zero(); + } + + // Hold the transferred amount. The hold accumulates across eras and is + // batch-converted to a vesting schedule when era % BondingDuration == 0. + if let Err(e) = asset::hold_incentive::(&payout_account, amount) { + // This should not fail since we just transferred the amount in. If it does, + // transfer back to the era pot rather than silently paying liquid. + log!(error, "Failed to hold incentive for {:?}: {:?}", payout_account, e); + defensive!("Incentive hold failed after transfer"); + let _ = T::Currency::transfer( + &payout_account, + &validator_incentive_pot_account, + amount, + Preservation::Expendable, + ); + return Zero::zero(); + } + + Self::deposit_event(Event::::ValidatorIncentiveHeld { + era, + validator_stash: stash.clone(), + dest, + amount, + }); + + amount + } + + /// Attempt to convert accumulated incentive hold into a vesting schedule. + /// + /// Called at batch boundaries (`era % BondingDuration == 0`) and on staking exit. + /// Derives `vesting_eras = VestingDuration / BlocksPerEra`, computes the retroactive + /// unlock fraction (`BondingDuration / vesting_eras`), and creates a vesting schedule + /// for the remainder via a self-transfer. + /// + /// If vesting schedule creation fails (e.g., slots full), the hold is re-applied. + /// + /// Returns `true` if the hold was fully cleared, `false` if it persists (re-held). + fn maybe_convert_incentive_to_vesting(stash: &T::AccountId) -> bool { + let payout_account = match Self::resolve_payout_account(stash) { + Some(account) => account, + None => { + defensive!("Unable to determine payout account for vesting conversion"); + return false; + }, + }; + + let held = asset::incentive_held::(&payout_account); + if held.is_zero() { + return true; + } + + let bonding_duration = T::BondingDuration::get(); + let vesting_duration = T::VestingDuration::get(); + let blocks_per_era = + T::BlocksPerSession::get().saturating_mul(T::SessionsPerEra::get().into()); + + // Derive vesting duration in eras from block-denominated config values. + let vesting_eras: u32 = if blocks_per_era.is_zero() { + 0u32 + } else { + (vesting_duration / blocks_per_era).saturated_into::() + }; + + // If vesting eras is zero or bonding duration exceeds it, release everything liquid. + if vesting_eras == 0 || bonding_duration >= vesting_eras { + let released = asset::release_incentive_hold::(&payout_account); + Self::deposit_event(Event::::IncentiveVestingConverted { + validator_stash: stash.clone(), + liquid: released, + vested: Zero::zero(), + }); + return true; + } + + // Retroactive unlock: the incentive was earned over BondingDuration eras but held, + // so that fraction is already "vested" and released as liquid. + let already_unlocked = + Perbill::from_rational(bonding_duration, vesting_eras).mul_floor(held); + let remaining = held.saturating_sub(already_unlocked); + + // Remaining fraction of the vesting duration in blocks. + let remaining_duration_blocks = + Perbill::from_rational(vesting_eras.saturating_sub(bonding_duration), vesting_eras) + .mul_floor(vesting_duration); + + // Release the entire hold. + asset::release_incentive_hold::(&payout_account); + + if remaining.is_zero() { + Self::deposit_event(Event::::IncentiveVestingConverted { + validator_stash: stash.clone(), + liquid: held, + vested: Zero::zero(), + }); + return true; + } + + // Self-transfer: the funds are already in payout_account. `vested_transfer` does + // a no-op Currency::transfer for self-transfers, then adds the vesting lock. + match T::ValidatorIncentivePayout::payout( + &payout_account, + &payout_account, + remaining, + remaining_duration_blocks, + ) { + Ok(_) => { + Self::deposit_event(Event::::IncentiveVestingConverted { + validator_stash: stash.clone(), + liquid: already_unlocked, + vested: remaining, + }); + true + }, + Err(e) => { + // Vesting slots full or amount below MinVestedTransfer. + // Re-apply the hold; conversion will be retried next batch interval. + log!( + warn, + "Deferred incentive vesting conversion for {:?}: {:?}", + payout_account, + e + ); + if let Err(hold_err) = asset::hold_incentive::(&payout_account, held) { + log!(error, "Failed to re-hold incentive: {:?}", hold_err); + defensive!("Re-hold after failed vesting conversion should succeed"); + } + false + }, + } + } + + /// Pay validator incentive bonus for a single page. + /// + /// Calculates the validator's incentive amount and transfers it from the validator + /// incentive pot. This is a convenience wrapper that combines calculation and transfer. + /// + /// Returns the amount paid if successful, 0 otherwise. + fn pay_validator_incentive_for_page( + era: EraIndex, + stash: &T::AccountId, + page_stake_part: Perbill, + ) -> BalanceOf { + let amount = match Self::calculate_validator_incentive_for_page(era, stash, page_stake_part) + { + Some(amt) => amt, + None => return Zero::zero(), + }; + + Self::transfer_validator_incentive(era, stash, amount) + } + /// Remove all associated data of a stash account from the staking system. /// /// Assumes storage is upgraded before calling. /// /// This is called: /// - after a `withdraw_unbonded()` call that frees all of a stash's bonded balance. - /// - through `reap_stash()` if the balance has fallen to zero (through slashing). + /// + /// If there is a pending incentive hold that cannot be converted to a vesting schedule + /// (e.g. vesting slots full), returns [`Error::IncentiveVestingPending`]. The user must + /// free a vesting slot and retry. pub(crate) fn kill_stash(stash: &T::AccountId) -> DispatchResult { + Self::settle_incentive_hold_on_exit(stash, false)?; + Self::do_kill_stash(stash) + } + + /// Force-remove all staking data for a stash, releasing any incentive hold as liquid + /// if vesting conversion fails. + /// + /// Used by `force_unstake` (Root) and `reap_stash` (slashed dust cleanup) where the + /// exit must not be blocked by full vesting slots. + pub(crate) fn force_kill_stash(stash: &T::AccountId) -> DispatchResult { + Self::settle_incentive_hold_on_exit(stash, true)?; + Self::do_kill_stash(stash) + } + + fn do_kill_stash(stash: &T::AccountId) -> DispatchResult { // removes controller from `Bonded` and staking ledger from `Ledger`, as well as reward // setting of the stash in `Payee`. StakingLedger::::kill(&stash)?; @@ -545,6 +1050,115 @@ impl Pallet { Ok(()) } + /// Settle any outstanding incentive hold when a validator exits staking. + /// + /// Unlike batch conversion (which applies a retroactive unlock fraction), exit settlement + /// vests the full held amount — we don't know how long the hold has been accumulating. + /// + /// If `force` is true, releases as liquid when vesting fails (for `force_unstake` and + /// `reap_stash`). If `force` is false, returns [`Error::IncentiveVestingPending`] on + /// failure (for voluntary `withdraw_unbonded`). + fn settle_incentive_hold_on_exit(stash: &T::AccountId, force: bool) -> DispatchResult { + let payout_account = match Self::resolve_payout_account(stash) { + Some(account) => account, + None => return Ok(()), + }; + + let held = asset::incentive_held::(&payout_account); + if held.is_zero() { + return Ok(()); + } + + let ed = asset::existential_deposit::(); + + // Below ED -> just release as liquid. + if held < ed { + asset::release_incentive_hold::(&payout_account); + return Ok(()); + } + + let vesting_duration = T::VestingDuration::get(); + + // No vesting configured — release as liquid. + if vesting_duration.is_zero() { + asset::release_incentive_hold::(&payout_account); + return Ok(()); + } + + // Release hold and vest the full amount. + asset::release_incentive_hold::(&payout_account); + + match T::ValidatorIncentivePayout::payout( + &payout_account, + &payout_account, + held, + vesting_duration, + ) { + Ok(_) => { + Self::deposit_event(Event::::IncentiveVestingConverted { + validator_stash: stash.clone(), + liquid: Zero::zero(), + vested: held, + }); + Ok(()) + }, + Err(e) => { + log!(warn, "Failed to vest incentive on exit for {:?}: {:?}", payout_account, e); + if force { + // Forced exit: already released, leave as liquid. + Self::deposit_event(Event::::IncentiveVestingConverted { + validator_stash: stash.clone(), + liquid: held, + vested: Zero::zero(), + }); + Ok(()) + } else { + // Non-force path is only called from user extrinsics — returning Err + // reverts the entire transaction including the hold release above. + // TODO(ank4n): Benchmark worst-case path (vesting payout failure + revert). + Err(Error::::IncentiveVestingPending.into()) + } + }, + } + } + + /// Move any accumulated incentive hold from the old payout account to the new one + /// when the payee changes. + /// + /// Releases the hold on the old account and re-holds on the new account. If the new + /// payee resolves to the same account (or there's nothing held), this is a no-op. + pub(super) fn migrate_incentive_hold_on_payee_change( + stash: &T::AccountId, + new_payee: &RewardDestination, + ) -> DispatchResult { + let old_account = Self::resolve_payout_account(stash); + let new_account = Self::payout_account_for_dest(stash, new_payee); + + // Same account or no old account — nothing to migrate. + if old_account == new_account || old_account.is_none() { + return Ok(()); + } + + let old_account = old_account.expect("checked above; qed"); + let held = asset::incentive_held::(&old_account); + if held.is_zero() { + return Ok(()); + } + + let new_account = match new_account { + Some(acc) => acc, + // Cannot change to None while incentive hold exists — would bypass vesting. + None => return Err(Error::::IncentiveVestingPending.into()), + }; + + // Release from old, transfer to new, re-hold on new. + asset::release_incentive_hold::(&old_account); + T::Currency::transfer(&old_account, &new_account, held, Preservation::Preserve)?; + asset::hold_incentive::(&new_account, held)?; + + Ok(()) + } + #[cfg(test)] pub(crate) fn reward_by_ids(validators_points: impl IntoIterator) { Eras::::reward_active_era(validators_points) @@ -2012,7 +2626,9 @@ impl Pallet { let overview_and_pages = ErasStakersOverview::::iter_prefix(era) .map(|(validator, metadata)| { // ensure `LastValidatorEra` is correctly set - if LastValidatorEra::::get(&validator) != Some(era) { + // If election for planning era is already completed, but era is not switched yet, + // `LastValidatorEra` would be `era + 1`. + if LastValidatorEra::::get(&validator).unwrap_or_default() < era { log!( warn, "Validator {:?} has incorrect LastValidatorEra (expected {:?}, got {:?})", diff --git a/substrate/frame/staking-async/src/pallet/mod.rs b/substrate/frame/staking-async/src/pallet/mod.rs index 2bbe871f6a3a0..f44f81d4c4752 100644 --- a/substrate/frame/staking-async/src/pallet/mod.rs +++ b/substrate/frame/staking-async/src/pallet/mod.rs @@ -19,10 +19,9 @@ use crate::{ asset, session_rotation::EraElectionPlanner, slashing, weights::WeightInfo, AccountIdLookupOf, - ActiveEraInfo, BalanceOf, EraPayout, EraRewardPoints, ExposurePage, Forcing, - LedgerIntegrityState, MaxNominationsOf, NegativeImbalanceOf, Nominations, NominationsQuota, - PositiveImbalanceOf, RewardDestination, StakingLedger, UnappliedSlash, UnlockChunk, - ValidatorPrefs, + ActiveEraInfo, BalanceOf, EraRewardPoints, ExposurePage, Forcing, LedgerIntegrityState, + MaxNominationsOf, NegativeImbalanceOf, Nominations, NominationsQuota, PositiveImbalanceOf, + RewardDestination, StakingLedger, UnappliedSlash, UnlockChunk, ValidatorPrefs, }; use alloc::{format, vec::Vec}; use codec::Codec; @@ -86,8 +85,11 @@ pub mod pallet { ErasValidatorReward, /// Pruning ErasRewardPoints storage ErasRewardPoints, - /// Pruning single-entry storages: ErasTotalStake and ErasNominatorsSlashable + /// Pruning single-entry storages: ErasTotalStake, ErasTotalValidatorWeight, + /// ErasNominatorsSlashable, and ErasValidatorIncentiveAllocation SingleEntryCleanups, + /// Pruning ErasValidatorIncentive storage + ErasValidatorIncentive, /// Pruning ValidatorSlashInEra storage ValidatorSlashInEra, } @@ -255,10 +257,28 @@ pub mod pallet { #[pallet::no_default] type AdminOrigin: EnsureOrigin; - /// The payout for validators and the system for the current era. - /// See [Era payout](./index.html#era-payout). - #[pallet::no_default] - type EraPayout: EraPayout>; + /// Handler for unclaimed era rewards. + /// + /// When era pots are cleaned up past history depth, remaining funds are withdrawn + /// and passed to this handler. Typically wired to DAP (same as `Slash`). + #[pallet::no_default_bounds] + type UnclaimedRewardHandler: OnUnbalanced>; + + /// Provider for general (non-era) reward pot accounts. + /// + /// DAP drips inflation into these pots. At era boundaries, staking snapshots + /// and transfers the balances to era-specific pots. + /// + /// Use [`crate::Seed`] in production with a pallet ID. + /// Use [`crate::SequentialTest`] for testing with predictable sequential IDs. + type GeneralPots: crate::GeneralPotAccountProvider; + + /// Provider for generating era pot account IDs. + /// + /// Use [`crate::Seed`] in production with a pallet ID. + /// Use [`crate::SequentialTest`] for testing with predictable sequential IDs. + #[pallet::no_default_bounds] + type EraPots: crate::EraPotAccountProvider; /// The maximum size of each `T::ExposurePage`. /// @@ -342,16 +362,6 @@ pub mod pallet { type EventListeners: sp_staking::OnStakingUpdate>; /// Maximum allowed era duration in milliseconds. - /// - /// This provides a defensive upper bound to cap the effective era duration, preventing - /// excessively long eras from causing runaway inflation (e.g., due to bugs). If the actual - /// era duration exceeds this value, it will be clamped to this maximum. - /// - /// Example: For an ideal era duration of 24 hours (86,400,000 ms), - /// this can be set to 604,800,000 ms (7 days). - #[pallet::constant] - type MaxEraDuration: Get; - /// Maximum number of storage items that can be pruned in a single call. /// /// This controls how many storage items can be deleted in each call to `prune_era_step`. @@ -374,6 +384,64 @@ pub mod pallet { /// another way (such as pools). type Filter: Contains; + /// Calculator for staker rewards including validator self-stake incentives. + /// + /// This determines: + /// - How validator self-stake is weighted for distributing self-stake incentive rewards + /// - How staking rewards are distributed between validators and nominators + #[pallet::no_default_bounds] + type StakerRewardCalculator: sp_staking::StakerRewardCalculator>; + + /// Total duration of vesting for validator self-stake incentive rewards, in relay + /// chain (RC) blocks. + /// + /// Incentive rewards are not paid out immediately. Instead, they are accumulated + /// under a hold ([`HoldReason::IncentiveVesting`]) for [`Config::BondingDuration`] + /// eras, then batch-converted to a vesting schedule. This avoids exhausting + /// `MaxVestingSchedules` (typically 28) by creating at most + /// `vesting_eras / BondingDuration` schedules over the full vesting period, where + /// `vesting_eras = VestingDuration / (BlocksPerSession * SessionsPerEra)`. + /// + /// At conversion time, the fraction `BondingDuration / vesting_eras` is released + /// as liquid (retroactive unlock), and the remainder is vested over the remaining + /// blocks. + /// + /// Denominated in RC blocks (not parachain blocks, which are variable). This + /// matches the block number provider used in the vesting trait implementation. + /// + /// Set to `0` to pay the incentive as liquid funds immediately (no vesting or hold). + #[pallet::constant] + #[pallet::no_default] + type VestingDuration: Get>; + + /// Number of relay chain (RC) blocks per session. + /// + /// Used together with [`Config::SessionsPerEra`] and [`Config::VestingDuration`] + /// to derive the vesting duration in eras + /// (`VestingDuration / (BlocksPerSession * SessionsPerEra)`) and compute the + /// retroactive unlock fraction at batch conversion time. + /// + /// Denominated in RC blocks (not parachain blocks, which are variable). This + /// matches the block number provider used in the vesting trait implementation. + #[pallet::constant] + #[pallet::no_default] + type BlocksPerSession: Get>; + + /// Mechanism for paying out validator self-stake incentive rewards. + /// + /// Used during batch conversion to create vesting schedules from accumulated + /// incentive holds. See [`HoldReason::IncentiveVesting`] for the full lifecycle. + /// + /// Use [`crate::ImmediateIncentivePayout`] for liquid payouts (no vesting). + /// Use [`crate::VestedIncentivePayout`] to vest over [`Config::VestingDuration`] blocks. + #[pallet::no_default_bounds] + #[pallet::no_default] + type ValidatorIncentivePayout: crate::ValidatorIncentivePayout< + Self::AccountId, + BalanceOf, + BlockNumberFor, + >; + /// Weight information for extrinsics in this pallet. type WeightInfo: WeightInfo; } @@ -384,6 +452,13 @@ pub mod pallet { /// Funds on stake by a nominator or a validator. #[codec(index = 0)] Staking, + /// Validator incentive rewards awaiting conversion to a vesting schedule. + /// + /// Incentive rewards are accumulated under this hold for [`Config::BondingDuration`] + /// eras, then batch-converted to a vesting schedule to avoid exhausting + /// `MaxVestingSchedules`. + #[codec(index = 1)] + IncentiveVesting, } /// Default implementations of [`DefaultConfig`], which can be used to implement [`Config`]. @@ -400,6 +475,7 @@ pub mod pallet { pub const BondingDuration: EraIndex = 3; pub const NominatorFastUnbondDuration: EraIndex = 2; pub const MaxPruningItems: u32 = 100; + pub const StakingAsyncPalletId: frame_support::PalletId = frame_support::PalletId(*b"py/stka "); } #[frame_support::register_default_impl(TestDefaultConfig)] @@ -412,7 +488,10 @@ pub mod pallet { type HistoryDepth = ConstU32<84>; type RewardRemainder = (); type Slash = (); + type UnclaimedRewardHandler = (); type Reward = (); + type GeneralPots = crate::Seed; + type EraPots = crate::Seed; type SessionsPerEra = SessionsPerEra; type BondingDuration = BondingDuration; type NominatorFastUnbondDuration = NominatorFastUnbondDuration; @@ -422,10 +501,11 @@ pub mod pallet { type MaxUnlockingChunks = ConstU32<32>; type MaxValidatorSet = ConstU32<100>; type MaxControllersInDeprecationBatch = ConstU32<100>; - type MaxEraDuration = (); type MaxPruningItems = MaxPruningItems; type EventListeners = (); type Filter = Nothing; + type StakerRewardCalculator = + crate::reward::DefaultStakerRewardCalculator; type WeightInfo = (); } } @@ -458,6 +538,44 @@ pub mod pallet { #[pallet::storage] pub type MinCommission = StorageValue<_, Perbill, ValueQuery>; + /// The maximum commission that validators can set. + /// + /// If not set, defaults to `Perbill::one()` (100%), i.e. no upper limit. + #[pallet::storage] + pub type MaxCommission = StorageValue<_, Perbill, ValueQuery, MaxCommissionDefault>; + + /// Default for MaxCommission: 100% (no restriction). + pub struct MaxCommissionDefault; + impl Get for MaxCommissionDefault { + fn get() -> Perbill { + Perbill::one() + } + } + + /// Optimum self-stake threshold for validators. + /// + /// Validators with self-stake below this value receive full weightage in the validator + /// self-stake incentive reward curve. Above this threshold, diminishing returns apply based + /// on the slope factor. + #[pallet::storage] + pub type OptimumSelfStake = StorageValue<_, BalanceOf, ValueQuery>; + + /// Hard cap on effective validator self-stake. + /// + /// Self-stake above this value receives no additional reward benefit in the validator + /// self-stake incentive system (rewards plateau at this level). + #[pallet::storage] + pub type HardCapSelfStake = StorageValue<_, BalanceOf, ValueQuery>; + + /// Slope factor controlling the discouragement rate for self-stake between optimum and cap. + /// + /// Value between 0 and 1: + /// - k=1 means no discouragement above optimum + /// - k=0 means immediate plateau at optimum + /// - k=0.5 provides moderate discouragement + #[pallet::storage] + pub type SelfStakeSlopeFactor = StorageValue<_, Perbill, ValueQuery>; + /// Whether nominators are slashable or not. /// /// - When set to `true` (default), nominators are slashed along with validators and must wait @@ -750,12 +868,25 @@ pub mod pallet { ValueQuery, >; - /// The total validator era payout for the last [`Config::HistoryDepth`] eras. + /// Total staker reward budget for each era in `[active_era - HistoryDepth, active_era]`. /// - /// Eras that haven't finished yet or has been removed doesn't have reward. + /// This covers both validator and nominator rewards (not just validator despite the legacy + /// storage name which is kept for backward compatibility). #[pallet::storage] pub type ErasValidatorReward = StorageMap<_, Twox64Concat, EraIndex, BalanceOf>; + /// Era allocation for validator self-stake incentive for the last [`Config::HistoryDepth`] + /// eras. + /// + /// This value is snapshotted at era end to ensure consistent calculations across all + /// validator payouts within the era. Without snapshotting, the allocation would decrease + /// with each payout, resulting in unfair distribution among validators. + /// + /// If not set or removed, 0 is returned. + #[pallet::storage] + pub type ErasValidatorIncentiveAllocation = + StorageMap<_, Twox64Concat, EraIndex, BalanceOf, ValueQuery>; + /// Rewards for the last [`Config::HistoryDepth`] eras. /// If reward hasn't been set or has been removed then 0 reward is returned. #[pallet::storage] @@ -768,6 +899,26 @@ pub mod pallet { pub type ErasTotalStake = StorageMap<_, Twox64Concat, EraIndex, BalanceOf, ValueQuery>; + /// The total validator self-stake weight for the last [`Config::HistoryDepth`] eras. + /// Used for distributing validator self-stake incentive rewards proportionally. + /// If total hasn't been set or has been removed then 0 weight is returned. + #[pallet::storage] + pub type ErasTotalValidatorWeight = + StorageMap<_, Twox64Concat, EraIndex, BalanceOf, ValueQuery>; + + /// Individual validator self-stake weight for the last [`Config::HistoryDepth`] eras. + /// Stored during era planning to avoid recalculating during payouts. + #[pallet::storage] + pub type ErasValidatorIncentive = StorageDoubleMap< + _, + Twox64Concat, + EraIndex, + Twox64Concat, + T::AccountId, + BalanceOf, + OptionQuery, + >; + /// Mode of era forcing. #[pallet::storage] pub type ForceEra = StorageValue<_, Forcing, ValueQuery>; @@ -912,6 +1063,16 @@ pub mod pallet { #[pallet::storage] pub type EraPruningState = StorageMap<_, Twox64Concat, EraIndex, PruningStep>; + /// Era after which legacy minting is permanently disabled. + /// + /// Set to the first era where reward provider is active. Once set, this value can only be + /// updated to a lower value (ensuring write-once semantics in production). + /// + /// We use this as a way to hard deprecate minting tokens in this pallet and rely on + /// era pot transfers for staking rewards. + #[pallet::storage] + pub type DisableLegacyMintingEra = StorageValue<_, EraIndex>; + #[pallet::genesis_config] #[derive(frame_support::DefaultNoBound, frame_support::DebugNoBound)] pub struct GenesisConfig { @@ -1126,12 +1287,13 @@ pub mod pallet { pub enum Event { /// The era payout has been set; the first balance is the validator-payout; the second is /// the remainder from the maximum amount of reward. + // Deprecated: Only emitted for `HistoryDepth` eras while transitioning to RewardProvider. EraPaid { era_index: EraIndex, validator_payout: BalanceOf, remainder: BalanceOf, }, - /// The nominator has been rewarded by this amount to this destination. + /// The staker has been rewarded by this amount to this destination. Rewarded { stash: T::AccountId, dest: RewardDestination, @@ -1267,6 +1429,35 @@ pub mod pallet { EraPruned { index: EraIndex, }, + /// Validator incentive has been transferred from the era pot and placed under + /// an [`HoldReason::IncentiveVesting`] hold, awaiting batch conversion to a + /// vesting schedule. + ValidatorIncentiveHeld { + era: EraIndex, + validator_stash: T::AccountId, + dest: RewardDestination, + amount: BalanceOf, + }, + /// Accumulated validator incentive hold has been converted to a vesting schedule. + /// + /// The `liquid` portion was released immediately (retroactive unlock for the + /// accumulation period), and `vested` was placed under a vesting schedule. + IncentiveVestingConverted { + validator_stash: T::AccountId, + liquid: BalanceOf, + vested: BalanceOf, + }, + /// The validator has been paid their self-stake incentive bonus. + /// + /// This is separate from staker rewards (`Rewarded`) and represents + /// an additional bonus paid from the validator incentive pot based on the validator's + /// self-stake weight. + ValidatorIncentivePaid { + era: EraIndex, + validator_stash: T::AccountId, + dest: RewardDestination, + amount: BalanceOf, + }, } /// Represents unexpected or invariant-breaking conditions encountered during execution. @@ -1276,12 +1467,16 @@ pub mod pallet { /// diagnosing issues in production or test environments. #[derive(Clone, Encode, Decode, DecodeWithMemTracking, PartialEq, TypeInfo, Debug)] pub enum UnexpectedKind { - /// Emitted when calculated era duration exceeds the configured maximum. - EraDurationBoundExceeded, /// Received a validator activation event that is not recognized. UnknownValidatorActivation, /// Failed to proceed paged election due to weight limits PagedElectionOutOfWeight { page: PageIndex, required: Weight, had: Weight }, + /// Validator incentive allocation exists but total validator weight is zero for the era. + ValidatorIncentiveWeightMismatch { era: EraIndex }, + /// Transfer of validator incentive failed unexpectedly. + ValidatorIncentiveTransferFailed { era: EraIndex }, + /// Active staker (validator or nominator) with payout due has no payee set. + ValidatorMissingPayee { era: EraIndex }, } #[pallet::error] @@ -1365,6 +1560,19 @@ pub mod pallet { EraNotPrunable, /// The slash has been cancelled and cannot be applied. CancelledSlash, + /// Era reward pot not found and legacy minting is disabled. + /// + /// This is an internal error caused by a potential bug. If observed, please create an + /// issue in `https://github.com/paritytech/polkadot-sdk/issues`. + LegacyMintingDisabled, + /// Optimum self-stake cannot be greater than hard cap. + OptimumGreaterThanCap, + /// Commission is higher than the allowed maximum `MaxCommission`. + CommissionTooHigh, + /// Cannot exit staking while incentive hold is pending conversion to a vesting schedule. + /// + /// Free a vesting slot, claim pending rewards, then retry. + IncentiveVestingPending, } impl Pallet { @@ -1468,11 +1676,23 @@ pub mod pallet { }, PruningStep::SingleEntryCleanups => { ErasTotalStake::::remove(era); - // Also clean up ErasNominatorsSlashable + ErasTotalValidatorWeight::::remove(era); ErasNominatorsSlashable::::remove(era); - EraPruningState::::insert(era, PruningStep::ValidatorSlashInEra); + ErasValidatorIncentiveAllocation::::remove(era); + EraPruningState::::insert(era, PruningStep::ErasValidatorIncentive); T::WeightInfo::prune_era_single_entry_cleanups() }, + PruningStep::ErasValidatorIncentive => { + // Clear ErasValidatorIncentive entries for this era + let result = ErasValidatorIncentive::::clear_prefix(era, items_limit, None); + let items_deleted = result.backend as u32; + + if result.maybe_cursor.is_none() { + EraPruningState::::insert(era, PruningStep::ValidatorSlashInEra); + } + + T::WeightInfo::prune_era_validator_slash_in_era(items_deleted) + }, PruningStep::ValidatorSlashInEra => { // Clear ValidatorSlashInEra entries for this era let result = ValidatorSlashInEra::::clear_prefix(era, items_limit, None); @@ -1834,6 +2054,7 @@ pub mod pallet { // ensure their commission is correct. ensure!(prefs.commission >= MinCommission::::get(), Error::::CommissionTooLow); + ensure!(prefs.commission <= MaxCommission::::get(), Error::::CommissionTooHigh); // Only check limits if they are not already a validator. if !Validators::::contains_key(stash) { @@ -1972,6 +2193,9 @@ pub mod pallet { Error::::ControllerDeprecated ); + // Move any incentive hold from old payout account to the new one. + Self::migrate_incentive_hold_on_payee_change(&ledger.stash, &payee)?; + let _ = ledger .set_payee(payee) .defensive_proof("ledger was retrieved from storage, thus it's bonded; qed.")?; @@ -2114,7 +2338,8 @@ pub mod pallet { ensure_root(origin)?; // Remove all staking-related information and lock. - Self::kill_stash(&stash)?; + // Force: releases incentive hold as liquid if vesting conversion fails. + Self::force_kill_stash(&stash)?; Ok(()) } @@ -2391,6 +2616,65 @@ pub mod pallet { config_op_exp!(AreNominatorsSlashable, are_nominators_slashable); Ok(()) } + + /// Update validator self-stake incentive parameters. + /// + /// * `optimum_self_stake`: The target self-stake threshold for validators. Validators with + /// self-stake below this value receive full weightage. Above this, diminishing returns + /// apply based on the slope factor. + /// * `hard_cap_self_stake`: Maximum effective self-stake. Self-stake above this value + /// receives no additional reward benefit (rewards plateau). + /// * `self_stake_slope_factor`: Controls the discouragement rate between optimum and cap + /// (value between 0 and 1). k=1 means no discouragement, k=0 means immediate plateau. + /// + /// The dispatch origin must be `T::AdminOrigin`. + /// + /// NOTE: Changes take effect in the next era when rewards are calculated. + #[pallet::call_index(33)] + #[pallet::weight(T::WeightInfo::set_validator_self_stake_incentive_config())] + pub fn set_validator_self_stake_incentive_config( + origin: OriginFor, + optimum_self_stake: ConfigOp>, + hard_cap_self_stake: ConfigOp>, + self_stake_slope_factor: ConfigOp, + ) -> DispatchResult { + T::AdminOrigin::ensure_origin(origin)?; + + // Validate that optimum <= cap if both will be set after this operation + let new_optimum = match optimum_self_stake { + ConfigOp::Noop => OptimumSelfStake::::get(), + ConfigOp::Set(v) => v, + ConfigOp::Remove => BalanceOf::::zero(), + }; + + let new_cap = match hard_cap_self_stake { + ConfigOp::Noop => HardCapSelfStake::::get(), + ConfigOp::Set(v) => v, + ConfigOp::Remove => BalanceOf::::zero(), + }; + + // Only validate if both will be non-zero (both configured) + if !new_optimum.is_zero() && !new_cap.is_zero() { + ensure!(new_optimum <= new_cap, Error::::OptimumGreaterThanCap); + } + + macro_rules! config_op_exp { + ($storage:ty, $op:ident) => { + match $op { + ConfigOp::Noop => (), + ConfigOp::Set(v) => <$storage>::put(v), + ConfigOp::Remove => <$storage>::kill(), + } + }; + } + + config_op_exp!(OptimumSelfStake, optimum_self_stake); + config_op_exp!(HardCapSelfStake, hard_cap_self_stake); + config_op_exp!(SelfStakeSlopeFactor, self_stake_slope_factor); + + Ok(()) + } + /// Declare a `controller` to stop participating as either a validator or nominator. /// /// Effects will be felt at the beginning of the next era. @@ -2482,9 +2766,8 @@ pub mod pallet { Ok(()) } - /// Force a validator to have at least the minimum commission. This will not affect a - /// validator who already has a commission greater than or equal to the minimum. Any account - /// can call this. + /// Force a validator's commission to be within the allowed range + /// [`MinCommission`, `MaxCommission`]. Any account can call this. #[pallet::call_index(24)] #[pallet::weight(T::WeightInfo::force_apply_min_commission())] pub fn force_apply_min_commission( @@ -2493,12 +2776,12 @@ pub mod pallet { ) -> DispatchResult { ensure_signed(origin)?; let min_commission = MinCommission::::get(); + let max_commission = MaxCommission::::get(); Validators::::try_mutate_exists(validator_stash, |maybe_prefs| { maybe_prefs .as_mut() .map(|prefs| { - (prefs.commission < min_commission) - .then(|| prefs.commission = min_commission) + prefs.commission = prefs.commission.clamp(min_commission, max_commission); }) .ok_or(Error::::NotStash) })?; @@ -2513,10 +2796,24 @@ pub mod pallet { #[pallet::weight(T::WeightInfo::set_min_commission())] pub fn set_min_commission(origin: OriginFor, new: Perbill) -> DispatchResult { T::AdminOrigin::ensure_origin(origin)?; + ensure!(new <= MaxCommission::::get(), Error::::CommissionTooHigh); MinCommission::::put(new); Ok(()) } + /// Sets the maximum commission that validators can set. + /// + /// This call has lower privilege requirements than `set_staking_config` and can be called + /// by the `T::AdminOrigin`. Root can always call this. + #[pallet::call_index(35)] + #[pallet::weight(T::WeightInfo::set_max_commission())] + pub fn set_max_commission(origin: OriginFor, new: Perbill) -> DispatchResult { + T::AdminOrigin::ensure_origin(origin)?; + ensure!(new >= MinCommission::::get(), Error::::CommissionTooLow); + MaxCommission::::put(new); + Ok(()) + } + /// Pay out a page of the stakers behind a validator for the given era and page. /// /// - `validator_stash` is the stash account of the validator. diff --git a/substrate/frame/staking-async/src/reward.rs b/substrate/frame/staking-async/src/reward.rs new file mode 100644 index 0000000000000..9cad927c0e6ba --- /dev/null +++ b/substrate/frame/staking-async/src/reward.rs @@ -0,0 +1,467 @@ +// 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. + +//! Era reward management. +//! +//! This module provides utilities for managing the lifecycle of era reward pot accounts, +//! including creation, funding, and cleanup. + +use crate::*; +use frame_support::{ + defensive, + traits::{ + fungible::{Balanced, Inspect, Mutate}, + tokens::{Fortitude, Precision, Preservation}, + Defensive, OnUnbalanced, + }, +}; +use sp_runtime::traits::Zero; +use sp_staking::EraIndex; + +/// Allocation breakdown returned by [`EraRewardManager::snapshot_era_rewards`]. +pub(crate) struct EraRewardAllocation { + pub staker_rewards: Balance, + pub validator_incentive: Balance, +} + +/// Manager for era reward allocation and distribution. +/// +/// Handles the lifecycle of era rewards from creation to cleanup: +/// - Creates reward pot accounts with provider references to prevent premature reaping +/// - Manages funding through the reward provider +/// - Cleans up old pots by transferring unclaimed rewards and removing providers +pub struct EraRewardManager(core::marker::PhantomData); + +impl EraRewardManager { + /// Creates and initializes an era pot account by adding a provider reference. + /// + /// This must be called when creating a new era pot to prevent the account from being + /// reaped prematurely. The provider will be removed during cleanup via [`Self::destroy`]. + /// + /// # Returns + /// The account ID of the created pot. + pub(crate) fn create(era: EraIndex, pot_type: EraPotType) -> T::AccountId { + let pot_account = T::EraPots::era_pot_account(era, pot_type); + frame_system::Pallet::::inc_providers(&pot_account); + pot_account + } + + /// Snapshots the general reward pots and transfers their balances into era-specific pots. + /// + /// DAP drips inflation continuously into the general pots. At era boundary, this method: + /// 1. Creates era-specific pot accounts + /// 2. Reads balance from general pots (accumulated since last era) + /// 3. Transfers from general → era-specific pots + /// + /// # Note on general pot account lifecycle + /// General pot accounts do not have explicit provider references. They are kept alive by + /// their balance: the first DAP inflation drip (which exceeds ED) creates the account, and + /// subsequent snapshots use `Preservation::Preserve` to keep ED in the account. Someone (a + /// runtime maintainer) can also send ED to the general pot account to ensure they are created + /// before mint. + /// + /// # Returns + /// The allocation breakdown showing amounts transferred into each era pot. + pub(crate) fn snapshot_era_rewards(era: EraIndex) -> EraRewardAllocation> { + let staker_era_pot = Self::create(era, EraPotType::StakerRewards); + let incentive_era_pot = Self::create(era, EraPotType::ValidatorSelfStake); + + // Read accumulated balances from general pots. + let general_staker_pot = T::GeneralPots::general_pot_account(GeneralPotType::StakerRewards); + let general_incentive_pot = + T::GeneralPots::general_pot_account(GeneralPotType::ValidatorIncentive); + // we want to leave ED in the general pot accounts to keep them alive. + let staker_balance = T::Currency::reducible_balance( + &general_staker_pot, + Preservation::Preserve, + frame_support::traits::tokens::Fortitude::Polite, + ); + let incentive_balance = T::Currency::reducible_balance( + &general_incentive_pot, + Preservation::Preserve, + frame_support::traits::tokens::Fortitude::Polite, + ); + + // Transfer from general pots to era-specific pots, keeping general pots alive. + // Track actual transferred amounts — if a transfer fails, we must not report + // the intended amount as available in the era pot. + let actual_staker = if !staker_balance.is_zero() { + match T::Currency::transfer( + &general_staker_pot, + &staker_era_pot, + staker_balance, + Preservation::Preserve, + ) { + Ok(_) => staker_balance, + Err(e) => { + log!(error, "Era {:?}: staker reward transfer failed: {:?}", era, e); + defensive!("Failed to transfer staker rewards to era pot"); + Zero::zero() + }, + } + } else { + Zero::zero() + }; + + let actual_incentive = if !incentive_balance.is_zero() { + match T::Currency::transfer( + &general_incentive_pot, + &incentive_era_pot, + incentive_balance, + Preservation::Preserve, + ) { + Ok(_) => incentive_balance, + Err(e) => { + log!(error, "Era {:?}: validator incentive transfer failed: {:?}", era, e); + defensive!("Failed to transfer validator incentive to era pot"); + Zero::zero() + }, + } + } else { + Zero::zero() + }; + + log::info!( + target: LOG_TARGET, + "Era {era}: snapshotted staker_rewards={actual_staker:?}, \ + validator_incentive={actual_incentive:?}" + ); + + EraRewardAllocation { staker_rewards: actual_staker, validator_incentive: actual_incentive } + } + + /// Destroys an era pot account by withdrawing unclaimed rewards and removing the provider. + /// + /// Any remaining balance is withdrawn as a `Credit` and passed to + /// `T::UnclaimedRewardHandler`. The provider is then decremented. + /// + /// The symmetric operation to [`Self::create`]. + pub(crate) fn destroy(era: EraIndex, pot_type: EraPotType) { + let pot_account = T::EraPots::era_pot_account(era, pot_type); + let remaining = T::Currency::balance(&pot_account); + + if !remaining.is_zero() { + match T::Currency::withdraw( + &pot_account, + remaining, + Precision::BestEffort, + Preservation::Expendable, + Fortitude::Force, + ) { + Ok(credit) => { + T::UnclaimedRewardHandler::on_unbalanced(credit); + log::debug!( + target: crate::LOG_TARGET, + "Withdrew {:?} unclaimed rewards from era {:?} {:?} pot", + remaining, + era, + pot_type + ); + }, + Err(e) => { + defensive!("Failed to withdraw unclaimed rewards from era pot"); + log::error!( + target: crate::LOG_TARGET, + "Era {:?} {:?}: unclaimed reward withdrawal failed: {:?}", + era, + pot_type, + e + ); + }, + } + } + + let _ = frame_system::Pallet::::dec_providers(&pot_account) + .defensive_proof("Provider was added in Self::create; qed"); + } + + /// Checks if an era has a staker rewards pot by checking if the account has providers. + /// + /// Returns true if the pot exists (has providers), false otherwise. + pub(crate) fn has_staker_rewards_pot(era: EraIndex) -> bool { + let staker_rewards_pot = T::EraPots::era_pot_account(era, EraPotType::StakerRewards); + frame_system::Pallet::::providers(&staker_rewards_pot) > 0 + } + + /// Cleans up all pot accounts for a given era. + /// + /// Calls [`Self::destroy`] for both staker rewards and validator incentive pots. + pub(crate) fn cleanup_era(era: EraIndex) { + Self::destroy(era, EraPotType::StakerRewards); + Self::destroy(era, EraPotType::ValidatorSelfStake); + } +} + +/// Default implementation of the staker reward calculator. +/// +/// Implements: +/// - Sqrt-based piecewise reward curve for validator self-stake incentives +/// - Standard staking reward distribution (commission + proportional stake split) +pub struct DefaultStakerRewardCalculator(core::marker::PhantomData); + +impl sp_staking::StakerRewardCalculator> + for DefaultStakerRewardCalculator +where + BalanceOf: Into + From, +{ + fn calculate_validator_incentive_weight(self_stake: BalanceOf) -> BalanceOf { + let optimum = OptimumSelfStake::::get(); + let cap = HardCapSelfStake::::get(); + let slope_factor = SelfStakeSlopeFactor::::get(); + + incentive_weight::>(self_stake, optimum, cap, slope_factor) + } + + fn calculate_staker_reward( + validator_total_reward: BalanceOf, + validator_commission: Perbill, + validator_own_stake: BalanceOf, + total_stake: BalanceOf, + ) -> sp_staking::StakerRewardResult> { + let validator_commission_payout = validator_commission.mul_floor(validator_total_reward); + let leftover = validator_total_reward.saturating_sub(validator_commission_payout); + let validator_exposure_part = Perbill::from_rational(validator_own_stake, total_stake); + let validator_staking_payout = validator_exposure_part.mul_floor(leftover); + let validator_payout = validator_staking_payout.saturating_add(validator_commission_payout); + // Nominator payout as remainder to avoid double rounding. + let nominator_payout = leftover.saturating_sub(validator_staking_payout); + + sp_staking::StakerRewardResult { validator_payout, nominator_payout } + } +} + +/// Piecewise sqrt-based incentive weight function. +/// +/// - Below optimum: `w(s) = √s` +/// - Between optimum and cap: `w(s) = √(T + k² × (s - T))` +/// - Above cap: plateau at `w(cap)` +fn incentive_weight( + self_stake: Balance, + optimum: Balance, + cap: Balance, + slope_factor: Perbill, +) -> Balance +where + Balance: AtLeast32BitUnsigned + Copy + Into + From, +{ + if self_stake.is_zero() { + return Balance::zero(); + } + + if optimum.is_zero() && cap.is_zero() { + return Balance::zero(); + } + + let self_stake_u128: u128 = self_stake.into(); + let optimum_u128: u128 = optimum.into(); + let cap_u128: u128 = cap.into(); + + let weight_u128 = if self_stake <= optimum { + sp_arithmetic::helpers_128bit::sqrt(self_stake_u128) + } else if self_stake <= cap { + let k_squared = slope_factor.square(); + let excess = self_stake_u128.saturating_sub(optimum_u128); + let arg = optimum_u128.saturating_add(k_squared.mul_floor(excess)); + sp_arithmetic::helpers_128bit::sqrt(arg) + } else { + let k_squared = slope_factor.square(); + let excess = cap_u128.saturating_sub(optimum_u128); + let arg = optimum_u128.saturating_add(k_squared.mul_floor(excess)); + sp_arithmetic::helpers_128bit::sqrt(arg) + }; + + Balance::from(weight_u128) +} + +#[cfg(test)] +mod tests { + use super::*; + use sp_runtime::Perbill; + + type Balance = u128; + + fn calculate_weight( + self_stake: Balance, + optimum: Balance, + cap: Balance, + slope_factor: Perbill, + ) -> Balance { + incentive_weight(self_stake, optimum, cap, slope_factor) + } + + #[test] + fn weight_calculation_zero_self_stake() { + // GIVEN: Zero self-stake + let self_stake: Balance = 0; + let optimum: Balance = 100_000; + let cap: Balance = 500_000; + let slope_factor = Perbill::from_rational(1u32, 2u32); + + // WHEN/THEN + assert_eq!(calculate_weight(self_stake, optimum, cap, slope_factor), 0); + } + + #[test] + fn weight_calculation_config_not_set() { + // GIVEN: Config not set (both optimum and cap are zero) + let self_stake: Balance = 100_000; + let optimum: Balance = 0; + let cap: Balance = 0; + let slope_factor = Perbill::from_rational(1u32, 2u32); + + // WHEN/THEN + assert_eq!(calculate_weight(self_stake, optimum, cap, slope_factor), 0); + } + + #[test] + fn weight_calculation_below_optimum() { + // GIVEN: Self-stake below optimum; w(s) = √s + let self_stake: Balance = 10_000; + let optimum: Balance = 100_000; + let cap: Balance = 500_000; + let slope_factor = Perbill::from_rational(1u32, 2u32); + + // WHEN/THEN: √10_000 = 100 + assert_eq!(calculate_weight(self_stake, optimum, cap, slope_factor), 100); + } + + #[test] + fn weight_calculation_at_optimum() { + // GIVEN: Self-stake exactly at optimum + let self_stake: Balance = 100_000; + let optimum: Balance = 100_000; + let cap: Balance = 500_000; + let slope_factor = Perbill::from_rational(1u32, 2u32); + + // WHEN/THEN: √100_000 ≈ 316 + assert_eq!(calculate_weight(self_stake, optimum, cap, slope_factor), 316); + } + + #[test] + fn weight_calculation_between_optimum_and_cap() { + // w(s) = √(T + k² × (s - T)) = √(100k + 0.25 × 200k) = √150k ≈ 387 + let self_stake: Balance = 300_000; + let optimum: Balance = 100_000; + let cap: Balance = 500_000; + let slope_factor = Perbill::from_rational(1u32, 2u32); + + assert_eq!(calculate_weight(self_stake, optimum, cap, slope_factor), 387); + } + + #[test] + fn weight_calculation_at_cap() { + // w(C) = √(T + k² × (C - T)) = √(100k + 0.25 × 400k) = √200k ≈ 447 + let self_stake: Balance = 500_000; + let optimum: Balance = 100_000; + let cap: Balance = 500_000; + let slope_factor = Perbill::from_rational(1u32, 2u32); + + assert_eq!(calculate_weight(self_stake, optimum, cap, slope_factor), 447); + } + + #[test] + fn weight_calculation_equal_optimum_and_cap() { + // GIVEN: Optimum equals cap (edge case) + let self_stake: Balance = 100_000; + let optimum: Balance = 100_000; + let cap: Balance = 100_000; + let slope_factor = Perbill::from_rational(1u32, 2u32); + + // WHEN/THEN: √100_000 ≈ 316 + assert_eq!(calculate_weight(self_stake, optimum, cap, slope_factor), 316); + } + + #[test] + fn weight_calculation_monotonically_increasing_below_cap() { + // GIVEN: Multiple self-stake values below cap + let optimum: Balance = 100_000; + let cap: Balance = 500_000; + let slope_factor = Perbill::from_rational(1u32, 2u32); + + // WHEN: Calculate weights for increasing self-stakes + let weight_50k = calculate_weight(50_000, optimum, cap, slope_factor); + let weight_100k = calculate_weight(100_000, optimum, cap, slope_factor); + let weight_200k = calculate_weight(200_000, optimum, cap, slope_factor); + let weight_400k = calculate_weight(400_000, optimum, cap, slope_factor); + + // THEN: Weights should be monotonically increasing + assert!(weight_50k < weight_100k, "{} < {}", weight_50k, weight_100k); + assert!(weight_100k < weight_200k, "{} < {}", weight_100k, weight_200k); + assert!(weight_200k < weight_400k, "{} < {}", weight_200k, weight_400k); + } + + #[test] + fn weight_calculation_plateau_above_cap() { + // GIVEN: Self-stakes above cap + let optimum: Balance = 100_000; + let cap: Balance = 500_000; + let slope_factor = Perbill::from_rational(1u32, 2u32); + + // WHEN: Calculate weights for self-stakes above cap + let weight_at_cap = calculate_weight(500_000, optimum, cap, slope_factor); + let weight_above_cap_1 = calculate_weight(1_000_000, optimum, cap, slope_factor); + let weight_above_cap_2 = calculate_weight(10_000_000, optimum, cap, slope_factor); + + // THEN: All weights above cap should equal weight at cap (plateau) + assert_eq!(weight_at_cap, weight_above_cap_1); + assert_eq!(weight_at_cap, weight_above_cap_2); + } + + #[test] + fn weight_calculation_very_small_self_stake() { + // GIVEN: Very small self-stake (1 unit) + let self_stake: Balance = 1; + let optimum: Balance = 100_000; + let cap: Balance = 500_000; + let slope_factor = Perbill::from_rational(1u32, 2u32); + + // WHEN/THEN: √1 = 1 + assert_eq!(calculate_weight(self_stake, optimum, cap, slope_factor), 1); + } + + #[test] + fn weight_calculation_different_slope_factors() { + // GIVEN: Same self-stake with different slope factors + let self_stake: Balance = 300_000; // Between optimum and cap + let optimum: Balance = 100_000; + let cap: Balance = 500_000; + + // WHEN: Calculate weights with different slope factors + let weight_k_025 = calculate_weight( + self_stake, + optimum, + cap, + Perbill::from_rational(1u32, 4u32), // k = 0.25 + ); + let weight_k_050 = calculate_weight( + self_stake, + optimum, + cap, + Perbill::from_rational(1u32, 2u32), // k = 0.5 + ); + let weight_k_075 = calculate_weight( + self_stake, + optimum, + cap, + Perbill::from_rational(3u32, 4u32), // k = 0.75 + ); + + // THEN: Larger slope factor should result in larger weight + assert!(weight_k_025 < weight_k_050, "{} < {}", weight_k_025, weight_k_050); + assert!(weight_k_050 < weight_k_075, "{} < {}", weight_k_050, weight_k_075); + } +} diff --git a/substrate/frame/staking-async/src/session_rotation.rs b/substrate/frame/staking-async/src/session_rotation.rs index 376bc4b49ebe4..1e8b4c581a78b 100644 --- a/substrate/frame/staking-async/src/session_rotation.rs +++ b/substrate/frame/staking-async/src/session_rotation.rs @@ -76,18 +76,19 @@ //! * end 4, start 5, plan 6 // RC::session receives and queues this set. //! * end 5, start 6, plan 7 // Session report contains activation timestamp with Current Era. -use crate::*; +use crate::{reward::EraRewardManager, *}; use alloc::{boxed::Box, vec::Vec}; use frame_election_provider_support::{BoundedSupportsOf, ElectionProvider, PageIndex}; use frame_support::{ pallet_prelude::*, - traits::{Defensive, DefensiveMax, DefensiveSaturating, OnUnbalanced, TryCollect}, + traits::{Defensive, DefensiveMax, DefensiveSaturating, TryCollect}, weights::WeightMeter, }; use pallet_staking_async_rc_client::RcClientInterface; -use sp_runtime::{Perbill, Percent, Saturating}; +use sp_runtime::{Perbill, Saturating}; use sp_staking::{ currency_to_vote::CurrencyToVote, Exposure, Page, PagedExposureMetadata, SessionIndex, + StakerRewardCalculator, }; /// A handler for all era-based storage items. @@ -362,14 +363,22 @@ impl Eras { }; } - pub(crate) fn set_validators_reward(era: EraIndex, amount: BalanceOf) { + pub(crate) fn set_stakers_reward(era: EraIndex, amount: BalanceOf) { ErasValidatorReward::::insert(era, amount); } - pub(crate) fn get_validators_reward(era: EraIndex) -> Option> { + pub(crate) fn get_stakers_reward(era: EraIndex) -> Option> { ErasValidatorReward::::get(era) } + pub(crate) fn set_validator_incentive_allocation(era: EraIndex, amount: BalanceOf) { + ErasValidatorIncentiveAllocation::::insert(era, amount); + } + + pub(crate) fn get_validator_incentive_allocation(era: EraIndex) -> BalanceOf { + ErasValidatorIncentiveAllocation::::get(era) + } + /// Update the total exposure for all the elected validators in the era. pub(crate) fn add_total_stake(era: EraIndex, stake: BalanceOf) { >::mutate(era, |total_stake| { @@ -377,6 +386,13 @@ impl Eras { }); } + /// Update the total validator self-stake weight for all the elected validators in the era. + pub(crate) fn add_total_validator_weight(era: EraIndex, weight: BalanceOf) { + >::mutate(era, |total_weight| { + *total_weight += weight; + }); + } + /// Check if the rewards for the given era and page index have been claimed. pub(crate) fn is_rewards_claimed(era: EraIndex, validator: &T::AccountId, page: Page) -> bool { ClaimedRewards::::get(era, validator).contains(&page) @@ -438,6 +454,19 @@ impl Eras { ensure!(e2 == e4, "era info presence not consistent"); + // Check validator incentive weight consistency + let total_weight = ErasTotalValidatorWeight::::get(era); + if !total_weight.is_zero() { + let sum_of_individual_weights: BalanceOf = + ErasValidatorIncentive::::iter_prefix_values(era) + .fold(BalanceOf::::zero(), |acc, w| acc.saturating_add(w)); + + ensure!( + total_weight == sum_of_individual_weights, + "ErasTotalValidatorWeight must equal sum of ErasValidatorIncentive" + ); + } + if e2 { Ok(()) } else { @@ -466,18 +495,21 @@ impl Eras { let e0 = ErasValidatorPrefs::::iter_prefix_values(era).count() != 0; let e1 = ErasStakersPaged::::iter_prefix_values((era,)).count() != 0; let e2 = ErasStakersOverview::::iter_prefix_values(era).count() != 0; + let e3 = ErasValidatorIncentive::::iter_prefix_values(era).count() != 0; // check maps // `ErasValidatorReward` is set at active era n for era n-1 - let e3 = ErasValidatorReward::::contains_key(era); - let e4 = ErasTotalStake::::contains_key(era); + let e4 = ErasValidatorReward::::contains_key(era); + let e5 = ErasTotalStake::::contains_key(era); + let e6 = ErasTotalValidatorWeight::::contains_key(era); + let e7 = ErasValidatorIncentiveAllocation::::contains_key(era); // these two are only populated conditionally, so we only check them for lack of existence - let e6 = ClaimedRewards::::iter_prefix_values(era).count() != 0; - let e7 = ErasRewardPoints::::contains_key(era); + let e8 = ClaimedRewards::::iter_prefix_values(era).count() != 0; + let e9 = ErasRewardPoints::::contains_key(era); // Check if era info is consistent - if not, era is in partial pruning state - if !vec![e0, e1, e2, e3, e4, e6, e7].windows(2).all(|w| w[0] == w[1]) { + if !vec![e0, e1, e2, e3, e4, e5, e6, e7, e8, e9].windows(2).all(|w| w[0] == w[1]) { return Err("era info absence not consistent - partial pruning state".into()); } @@ -736,6 +768,11 @@ impl Rotator { Self::start_era_inc_active_era(new_era_start_timestamp); Self::start_era_update_bonded_eras(starting_era, starting_session); + // Cleanup old era pot beyond history depth + if let Some(era_to_cleanup) = starting_era.checked_sub(T::HistoryDepth::get() + 1) { + EraRewardManager::::cleanup_era(era_to_cleanup); + } + // Snapshot the current nominators slashable setting for this era. // Cleanup will happen via lazy pruning at HistoryDepth. ErasNominatorsSlashable::::insert(starting_era, AreNominatorsSlashable::::get()); @@ -796,64 +833,32 @@ impl Rotator { }); } - fn end_era(ending_era: &ActiveEraInfo, new_era_start: u64) { - let previous_era_start = ending_era.start.defensive_unwrap_or(new_era_start); - let uncapped_era_duration = new_era_start.saturating_sub(previous_era_start); + fn end_era(ending_era: &ActiveEraInfo, _new_era_start: u64) { + log!(debug, "snapshotting reward pots for era {:?}", ending_era.index); - // maybe cap the era duration to the maximum allowed by the runtime. - let cap = T::MaxEraDuration::get(); - let era_duration = if cap == 0 { - // if the cap is zero (not set), we don't cap the era duration. - uncapped_era_duration - } else if uncapped_era_duration > cap { - Pallet::::deposit_event(Event::Unexpected(UnexpectedKind::EraDurationBoundExceeded)); + // Snapshot general reward pots into era-specific pots. + // DAP has been dripping inflation into the general pots since the last era boundary. + let allocation = EraRewardManager::::snapshot_era_rewards(ending_era.index); - // if the cap is set, and era duration exceeds the cap, we cap the era duration to the - // maximum allowed. - log!( - warn, - "capping era duration for era {:?} from {:?} to max allowed {:?}", - ending_era.index, - uncapped_era_duration, - cap - ); - cap - } else { - uncapped_era_duration - }; - - Self::end_era_compute_payout(ending_era, era_duration); - } + if allocation.staker_rewards.is_zero() { + log!(warn, "Era {:?} has zero staker rewards in general pot", ending_era.index); + } - fn end_era_compute_payout(ending_era: &ActiveEraInfo, era_duration: u64) { - let staked = ErasTotalStake::::get(ending_era.index); - let issuance = asset::total_issuance::(); + Eras::::set_stakers_reward(ending_era.index, allocation.staker_rewards); - log!( - debug, - "computing inflation for era {:?} with duration {:?}", + Eras::::set_validator_incentive_allocation( ending_era.index, - era_duration + allocation.validator_incentive, ); - 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::::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); - - Pallet::::deposit_event(Event::::EraPaid { - era_index: ending_era.index, - validator_payout, - remainder, - }); - // Set ending era reward. - Eras::::set_validators_reward(ending_era.index, validator_payout); - T::RewardRemainder::on_unbalanced(asset::issue::(remainder)); + // Update DisableLegacyMintingEra to prevent legacy minting. + if !allocation.staker_rewards.is_zero() { + DisableLegacyMintingEra::::mutate(|maybe_era| { + if maybe_era.is_none() || maybe_era.is_some_and(|e| ending_era.index < e) { + *maybe_era = Some(ending_era.index); + } + }); + } } /// Plans a new era by kicking off the election process. @@ -1089,6 +1094,7 @@ impl EraElectionPlanner { ) -> BoundedVec> { // populate elected stash, stakers, exposures, and the snapshot of validator prefs. let mut total_stake_page: BalanceOf = Zero::zero(); + let mut total_validator_weight_page: BalanceOf = Zero::zero(); let mut elected_stashes_page = Vec::with_capacity(exposures.len()); let mut total_backers = 0u32; @@ -1105,8 +1111,20 @@ impl EraElectionPlanner { // accumulate total stake and backer count for bookkeeping. total_stake_page = total_stake_page.saturating_add(exposure.total); total_backers += exposure.others.len() as u32; - // set or update staker exposure for this era. + Eras::::upsert_exposure(new_planned_era, &stash, exposure); + + // Calculate incentive weight from own-stake. + let own = ErasStakersOverview::::get(new_planned_era, &stash) + .map(|o| o.own) + .unwrap_or_default(); + // skip updating if own is zero, or incentive is already written. + if !own.is_zero() && !ErasValidatorIncentive::::contains_key(new_planned_era, &stash) + { + let weight = T::StakerRewardCalculator::calculate_validator_incentive_weight(own); + total_validator_weight_page = total_validator_weight_page.saturating_add(weight); + ErasValidatorIncentive::::insert(new_planned_era, &stash, weight); + } }); let elected_stashes: BoundedVec<_, MaxWinnersPerPageOf> = @@ -1117,6 +1135,9 @@ impl EraElectionPlanner { // adds to total stake in this era. Eras::::add_total_stake(new_planned_era, total_stake_page); + // adds to total validator self-stake weight for incentive distribution. + Eras::::add_total_validator_weight(new_planned_era, total_validator_weight_page); + // collect or update the pref of all winners. // TODO: rather inefficient, we can do this once at the last page across all entries in // `ElectableStashes`. diff --git a/substrate/frame/staking-async/src/tests/bonding.rs b/substrate/frame/staking-async/src/tests/bonding.rs index 21faa3889d688..2b60c0028ec81 100644 --- a/substrate/frame/staking-async/src/tests/bonding.rs +++ b/substrate/frame/staking-async/src/tests/bonding.rs @@ -1013,11 +1013,11 @@ fn bond_with_little_staked_value_bounded() { staking_events_since_last_call(), vec![ Event::PayoutStarted { era_index: 1, validator_stash: 11, page: 0, next: None }, - Event::Rewarded { stash: 11, dest: RewardDestination::Stash, amount: 2500 }, + Event::Rewarded { stash: 11, dest: RewardDestination::Stash, amount: 2499 }, Event::PayoutStarted { era_index: 1, validator_stash: 21, page: 0, next: None }, - Event::Rewarded { stash: 21, dest: RewardDestination::Staked, amount: 2500 }, + Event::Rewarded { stash: 21, dest: RewardDestination::Staked, amount: 2499 }, Event::PayoutStarted { era_index: 1, validator_stash: 31, page: 0, next: None }, - Event::Rewarded { stash: 31, dest: RewardDestination::Staked, amount: 2500 } + Event::Rewarded { stash: 31, dest: RewardDestination::Staked, amount: 2499 } ] ); @@ -1032,11 +1032,11 @@ fn bond_with_little_staked_value_bounded() { staking_events_since_last_call(), vec![ Event::PayoutStarted { era_index: 2, validator_stash: 1, page: 0, next: None }, - Event::Rewarded { stash: 1, dest: RewardDestination::Account(1), amount: 2500 }, + Event::Rewarded { stash: 1, dest: RewardDestination::Account(1), amount: 2499 }, Event::PayoutStarted { era_index: 2, validator_stash: 11, page: 0, next: None }, - Event::Rewarded { stash: 11, dest: RewardDestination::Stash, amount: 2500 }, + Event::Rewarded { stash: 11, dest: RewardDestination::Stash, amount: 2499 }, Event::PayoutStarted { era_index: 2, validator_stash: 21, page: 0, next: None }, - Event::Rewarded { stash: 21, dest: RewardDestination::Staked, amount: 2500 } + Event::Rewarded { stash: 21, dest: RewardDestination::Staked, amount: 2499 } ] ); diff --git a/substrate/frame/staking-async/src/tests/configs.rs b/substrate/frame/staking-async/src/tests/configs.rs index 80a4a3cff8e4f..c63123f2151d3 100644 --- a/substrate/frame/staking-async/src/tests/configs.rs +++ b/substrate/frame/staking-async/src/tests/configs.rs @@ -77,3 +77,110 @@ fn set_staking_configs_works() { assert_eq!(AreNominatorsSlashable::::get(), true); }); } + +#[test] +fn set_max_commission_works() { + ExtBuilder::default().build_and_execute(|| { + let admin = 1; // AdminOrigin (see mock) + let non_admin = 2; + + // GIVEN: Default is 100% + assert_eq!(MaxCommission::::get(), Perbill::one()); + + // WHEN/THEN: Root and admin can set, non-admin cannot + assert_ok!(Staking::set_max_commission(RuntimeOrigin::root(), Perbill::from_percent(50),)); + assert_eq!(MaxCommission::::get(), Perbill::from_percent(50)); + + assert_ok!(Staking::set_max_commission( + RuntimeOrigin::signed(admin), + Perbill::from_percent(25), + )); + assert_eq!(MaxCommission::::get(), Perbill::from_percent(25)); + + assert_noop!( + Staking::set_max_commission( + RuntimeOrigin::signed(non_admin), + Perbill::from_percent(10) + ), + BadOrigin + ); + }); +} + +#[test] +fn max_commission_rejects_validate_above_max() { + ExtBuilder::default().build_and_execute(|| { + let alice = 11; // validator + + // GIVEN: MaxCommission set to 10% + MaxCommission::::set(Perbill::from_percent(10)); + + // WHEN/THEN: Above max rejected, at or below accepted + assert_noop!( + Staking::validate( + RuntimeOrigin::signed(alice), + ValidatorPrefs { commission: Perbill::from_percent(11), blocked: false } + ), + Error::::CommissionTooHigh + ); + + assert_ok!(Staking::validate( + RuntimeOrigin::signed(alice), + ValidatorPrefs { commission: Perbill::from_percent(10), blocked: false } + )); + + assert_ok!(Staking::validate( + RuntimeOrigin::signed(alice), + ValidatorPrefs { commission: Perbill::from_percent(5), blocked: false } + )); + }); +} + +#[test] +fn max_commission_min_commission_invariant() { + ExtBuilder::default().build_and_execute(|| { + // GIVEN: MinCommission = 10% + assert_ok!(Staking::set_min_commission(RuntimeOrigin::root(), Perbill::from_percent(10),)); + + // WHEN/THEN: Cannot set max below min + assert_noop!( + Staking::set_max_commission(RuntimeOrigin::root(), Perbill::from_percent(5)), + Error::::CommissionTooLow + ); + + // GIVEN: MaxCommission = 50% + assert_ok!(Staking::set_max_commission(RuntimeOrigin::root(), Perbill::from_percent(50),)); + + // WHEN/THEN: Cannot set min above max + assert_noop!( + Staking::set_min_commission(RuntimeOrigin::root(), Perbill::from_percent(51)), + Error::::CommissionTooHigh + ); + + // Equal values are fine + assert_ok!(Staking::set_min_commission(RuntimeOrigin::root(), Perbill::from_percent(50),)); + }); +} + +#[test] +fn force_apply_min_commission_also_caps_to_max() { + let prefs = |c| ValidatorPrefs { commission: Perbill::from_percent(c), blocked: false }; + ExtBuilder::default().build_and_execute(|| { + let alice = 31; // validator + let bob = 21; // validator + + assert_ok!(Staking::validate(RuntimeOrigin::signed(alice), prefs(50))); + assert_ok!(Staking::validate(RuntimeOrigin::signed(bob), prefs(20))); + + // GIVEN: Max commission set to 30% + MaxCommission::::set(Perbill::from_percent(30)); + + // WHEN/THEN: Alice (50%) is capped to 30% + assert_ok!(Staking::force_apply_min_commission(RuntimeOrigin::signed(1), alice)); + assert_eq!(Validators::::get(alice), prefs(30)); + + // Bob (20%) is already within range — no change + assert_ok!(Staking::force_apply_min_commission(RuntimeOrigin::signed(1), bob)); + assert_eq!(Validators::::get(bob), prefs(20)); + }); +} diff --git a/substrate/frame/staking-async/src/tests/election_provider.rs b/substrate/frame/staking-async/src/tests/election_provider.rs index 55bed312e2698..71ef0b8afb860 100644 --- a/substrate/frame/staking-async/src/tests/election_provider.rs +++ b/substrate/frame/staking-async/src/tests/election_provider.rs @@ -49,7 +49,6 @@ fn planning_era_offset_less_0() { Event::SessionRotated { starting_session: 6, active_era: 0, planned_era: 1 }, Event::PagedElectionProceeded { page: 0, result: Ok(2) }, Event::SessionRotated { starting_session: 7, active_era: 0, planned_era: 1 }, - Event::EraPaid { era_index: 0, validator_payout: 20000, remainder: 20000 }, Event::SessionRotated { starting_session: 8, active_era: 1, planned_era: 1 } ] ); @@ -69,7 +68,6 @@ fn planning_era_offset_less_0() { Event::SessionRotated { starting_session: 14, active_era: 1, planned_era: 2 }, Event::PagedElectionProceeded { page: 0, result: Ok(2) }, Event::SessionRotated { starting_session: 15, active_era: 1, planned_era: 2 }, - Event::EraPaid { era_index: 1, validator_payout: 20000, remainder: 20000 }, Event::SessionRotated { starting_session: 16, active_era: 2, planned_era: 2 } ] ); @@ -101,7 +99,6 @@ fn planning_era_offset_works_1() { Event::SessionRotated { starting_session: 5, active_era: 0, planned_era: 1 }, Event::PagedElectionProceeded { page: 0, result: Ok(2) }, Event::SessionRotated { starting_session: 6, active_era: 0, planned_era: 1 }, - Event::EraPaid { era_index: 0, validator_payout: 17500, remainder: 17500 }, Event::SessionRotated { starting_session: 7, active_era: 1, planned_era: 1 } ] ); @@ -120,7 +117,6 @@ fn planning_era_offset_works_1() { Event::SessionRotated { starting_session: 12, active_era: 1, planned_era: 2 }, Event::PagedElectionProceeded { page: 0, result: Ok(2) }, Event::SessionRotated { starting_session: 13, active_era: 1, planned_era: 2 }, - Event::EraPaid { era_index: 1, validator_payout: 17500, remainder: 17500 }, Event::SessionRotated { starting_session: 14, active_era: 2, planned_era: 2 } ] ); @@ -148,7 +144,6 @@ fn planning_era_offset_works_2() { Event::SessionRotated { starting_session: 4, active_era: 0, planned_era: 1 }, Event::PagedElectionProceeded { page: 0, result: Ok(2) }, Event::SessionRotated { starting_session: 5, active_era: 0, planned_era: 1 }, - Event::EraPaid { era_index: 0, validator_payout: 15000, remainder: 15000 }, Event::SessionRotated { starting_session: 6, active_era: 1, planned_era: 1 } ] ); @@ -166,7 +161,6 @@ fn planning_era_offset_works_2() { Event::SessionRotated { starting_session: 10, active_era: 1, planned_era: 2 }, Event::PagedElectionProceeded { page: 0, result: Ok(2) }, Event::SessionRotated { starting_session: 11, active_era: 1, planned_era: 2 }, - Event::EraPaid { era_index: 1, validator_payout: 15000, remainder: 15000 }, Event::SessionRotated { starting_session: 12, active_era: 2, planned_era: 2 } ] ); @@ -194,7 +188,6 @@ fn planning_era_offset_works_smart() { Event::SessionRotated { starting_session: 4, active_era: 0, planned_era: 1 }, Event::PagedElectionProceeded { page: 0, result: Ok(2) }, Event::SessionRotated { starting_session: 5, active_era: 0, planned_era: 1 }, - Event::EraPaid { era_index: 0, validator_payout: 15000, remainder: 15000 }, Event::SessionRotated { starting_session: 6, active_era: 1, planned_era: 1 } ] ); @@ -212,7 +205,6 @@ fn planning_era_offset_works_smart() { Event::SessionRotated { starting_session: 10, active_era: 1, planned_era: 2 }, Event::PagedElectionProceeded { page: 0, result: Ok(2) }, Event::SessionRotated { starting_session: 11, active_era: 1, planned_era: 2 }, - Event::EraPaid { era_index: 1, validator_payout: 15000, remainder: 15000 }, Event::SessionRotated { starting_session: 12, active_era: 2, planned_era: 2 } ] ); @@ -241,7 +233,6 @@ fn planning_era_offset_works_smart_with_delay() { Event::SessionRotated { starting_session: 4, active_era: 0, planned_era: 1 }, Event::PagedElectionProceeded { page: 0, result: Ok(2) }, Event::SessionRotated { starting_session: 5, active_era: 0, planned_era: 1 }, - Event::EraPaid { era_index: 0, validator_payout: 15000, remainder: 15000 }, Event::SessionRotated { starting_session: 6, active_era: 1, planned_era: 1 } ] ); @@ -259,7 +250,6 @@ fn planning_era_offset_works_smart_with_delay() { Event::SessionRotated { starting_session: 10, active_era: 1, planned_era: 2 }, Event::PagedElectionProceeded { page: 0, result: Ok(2) }, Event::SessionRotated { starting_session: 11, active_era: 1, planned_era: 2 }, - Event::EraPaid { era_index: 1, validator_payout: 15000, remainder: 15000 }, Event::SessionRotated { starting_session: 12, active_era: 2, planned_era: 2 } ] ); diff --git a/substrate/frame/staking-async/src/tests/era_rotation.rs b/substrate/frame/staking-async/src/tests/era_rotation.rs index 6fe05bb54c459..ff8eefb417fff 100644 --- a/substrate/frame/staking-async/src/tests/era_rotation.rs +++ b/substrate/frame/staking-async/src/tests/era_rotation.rs @@ -19,6 +19,7 @@ use crate::{ session_rotation::{Eras, Rotator}, tests::session_mock::{CurrentIndex, Timestamp}, }; +use frame_support::traits::fungible::Inspect; use super::*; @@ -72,7 +73,6 @@ fn forcing_no_forcing_default() { Event::SessionRotated { starting_session: 4, active_era: 1, planned_era: 2 }, Event::PagedElectionProceeded { page: 0, result: Ok(2) }, Event::SessionRotated { starting_session: 5, active_era: 1, planned_era: 2 }, - Event::EraPaid { era_index: 1, validator_payout: 7500, remainder: 7500 }, Event::SessionRotated { starting_session: 6, active_era: 2, planned_era: 2 } ] ); @@ -95,7 +95,6 @@ fn forcing_force_always() { Event::SessionRotated { starting_session: 4, active_era: 0, planned_era: 1 }, Event::PagedElectionProceeded { page: 0, result: Ok(2) }, Event::SessionRotated { starting_session: 5, active_era: 0, planned_era: 1 }, - Event::EraPaid { era_index: 0, validator_payout: 15000, remainder: 15000 }, Event::SessionRotated { starting_session: 6, active_era: 1, planned_era: 1 } ] ); @@ -112,7 +111,6 @@ fn forcing_force_always() { Event::PagedElectionProceeded { page: 0, result: Ok(2) }, // by now it is given to mock session, and is buffered Event::SessionRotated { starting_session: 8, active_era: 1, planned_era: 2 }, - Event::EraPaid { era_index: 1, validator_payout: 7500, remainder: 7500 }, // and by now it is activated. Note how the validator payout is less, since the // era duration is less. Note that we immediately plan the next era as well. Event::SessionRotated { starting_session: 9, active_era: 2, planned_era: 3 } @@ -137,7 +135,6 @@ fn forcing_force_new() { Event::SessionRotated { starting_session: 4, active_era: 0, planned_era: 1 }, Event::PagedElectionProceeded { page: 0, result: Ok(2) }, Event::SessionRotated { starting_session: 5, active_era: 0, planned_era: 1 }, - Event::EraPaid { era_index: 0, validator_payout: 15000, remainder: 15000 }, Event::SessionRotated { starting_session: 6, active_era: 1, planned_era: 1 } ] ); @@ -155,7 +152,6 @@ fn forcing_force_new() { Event::PagedElectionProceeded { page: 0, result: Ok(2) }, // by now it is given to mock session, and is buffered Event::SessionRotated { starting_session: 8, active_era: 1, planned_era: 2 }, - Event::EraPaid { era_index: 1, validator_payout: 7500, remainder: 7500 }, // and by now it is activated. Note how the validator payout is less, since the // era duration is less. Event::SessionRotated { starting_session: 9, active_era: 2, planned_era: 2 } @@ -173,7 +169,6 @@ fn forcing_force_new() { Event::SessionRotated { starting_session: 13, active_era: 2, planned_era: 3 }, Event::PagedElectionProceeded { page: 0, result: Ok(2) }, Event::SessionRotated { starting_session: 14, active_era: 2, planned_era: 3 }, - Event::EraPaid { era_index: 2, validator_payout: 15000, remainder: 15000 }, Event::SessionRotated { starting_session: 15, active_era: 3, planned_era: 3 } ] ); @@ -227,69 +222,6 @@ fn activation_timestamp_when_era_planning_not_complete() { todo!("what if we receive an activation timestamp when the era planning (election) is not complete?"); } -#[test] -fn max_era_duration_safety_guard() { - ExtBuilder::default().build_and_execute(|| { - // let's deduce some magic numbers for the test. - let ideal_era_payout = total_payout_for(time_per_era()); - let ideal_treasury_payout = RemainderRatio::get() * ideal_era_payout; - let ideal_validator_payout = ideal_era_payout - ideal_treasury_payout; - // max era duration is capped to 7 times the ideal era duration. - let max_validator_payout = 7 * ideal_validator_payout; - let max_treasury_payout = 7 * ideal_treasury_payout; - - // these are the values we expect to see in the events. - assert_eq!(ideal_treasury_payout, 7500); - assert_eq!(ideal_validator_payout, 7500); - // when the era duration exceeds `MaxEraDuration`, the payouts should be capped to the - // following values. - assert_eq!(max_treasury_payout, 52500); - assert_eq!(max_validator_payout, 52500); - - // GIVEN: we are at end of an era (2). - Session::roll_until_active_era(2); - assert_eq!( - staking_events_since_last_call(), - vec![ - Event::SessionRotated { starting_session: 4, active_era: 1, planned_era: 2 }, - Event::PagedElectionProceeded { page: 0, result: Ok(2) }, - Event::SessionRotated { starting_session: 5, active_era: 1, planned_era: 2 }, - Event::EraPaid { - era_index: 1, - validator_payout: ideal_validator_payout, - remainder: ideal_treasury_payout - }, - Event::SessionRotated { starting_session: 6, active_era: 2, planned_era: 2 } - ] - ); - - // WHEN: subsequent era takes longer than MaxEraDuration. - // (this can happen either because of a bug or because a long stall in the chain). - Timestamp::set(Timestamp::get() + 2 * MaxEraDuration::get()); - Session::roll_until_active_era(3); - - // THEN: we should see the payouts capped to the max values. - assert_eq!( - staking_events_since_last_call(), - vec![ - Event::SessionRotated { starting_session: 7, active_era: 2, planned_era: 3 }, - Event::PagedElectionProceeded { page: 0, result: Ok(2) }, - Event::SessionRotated { starting_session: 8, active_era: 2, planned_era: 3 }, - // an event is emitted to indicate something unexpected happened, i.e. the era - // duration exceeded the `MaxEraDuration` limit. - Event::Unexpected(UnexpectedKind::EraDurationBoundExceeded), - // the payouts are capped to the max values. - Event::EraPaid { - era_index: 2, - validator_payout: max_validator_payout, - remainder: max_treasury_payout - }, - Event::SessionRotated { starting_session: 9, active_era: 3, planned_era: 3 } - ] - ); - }); -} - #[test] fn era_cleanup_history_depth_works_with_prune_era_step_extrinsic() { ExtBuilder::default().build_and_execute(|| { @@ -302,10 +234,14 @@ fn era_cleanup_history_depth_works_with_prune_era_step_extrinsic() { &[ .., Event::SessionRotated { starting_session: 236, active_era: 78, planned_era: 79 }, - Event::EraPaid { era_index: 78, validator_payout: 7500, remainder: 7500 }, Event::SessionRotated { starting_session: 237, active_era: 79, planned_era: 79 } ] )); + // Verify era 78 staker pot has been funded (DAP drips into general pot, staking snapshots). + let staker_pot_78 = + ::EraPots::era_pot_account(78, EraPotType::StakerRewards); + let ideal_validator_payout = validator_payout_for(time_per_era()); + assert_eq!(Balances::balance(&staker_pot_78), ideal_validator_payout); // All eras from 1 to current still present assert_ok!(Eras::::era_fully_present(1)); assert_ok!(Eras::::era_fully_present(2)); @@ -328,7 +264,6 @@ fn era_cleanup_history_depth_works_with_prune_era_step_extrinsic() { &staking_events_since_last_call()[..], &[ .., - Event::EraPaid { era_index: 80, validator_payout: 7500, remainder: 7500 }, // NO EraPruned event - pruning is now manual Event::SessionRotated { starting_session: 243, active_era: 81, planned_era: 81 } ] @@ -345,11 +280,21 @@ fn era_cleanup_history_depth_works_with_prune_era_step_extrinsic() { &staking_events_since_last_call()[..], &[ .., - Event::EraPaid { era_index: 81, validator_payout: 7500, remainder: 7500 }, // NO EraPruned event - pruning is now manual Event::SessionRotated { starting_session: 246, active_era: 82, planned_era: 82 } ] )); + // Verify eras 79-81 staker pots were funded with expected amount. + let expected_per_era = validator_payout_for(time_per_era()); + for era in 79..=81 { + let staker_pot = + ::EraPots::era_pot_account(era, EraPotType::StakerRewards); + assert_eq!( + Balances::balance(&staker_pot), + expected_per_era, + "Era {era} staker pot should have {expected_per_era}" + ); + } // Only old eras (outside pruning window) can be pruned // Try to prune era 2 (should fail as it's within the history window) @@ -380,6 +325,7 @@ fn era_cleanup_history_depth_works_with_prune_era_step_extrinsic() { ErasValidatorReward, ErasRewardPoints, SingleEntryCleanups, + ErasValidatorIncentive, ValidatorSlashInEra, ]; @@ -468,11 +414,20 @@ fn era_cleanup_history_depth_works_with_prune_era_step_extrinsic() { !crate::ErasTotalStake::::contains_key(1), "{expected_step:?} should be empty after completing step" ); - // Also verify ErasNominatorsSlashable is cleaned (piggybacks on this step) + // Also verify ErasNominatorsSlashable and ErasValidatorIncentiveAllocation are + // cleaned assert!( !crate::ErasNominatorsSlashable::::contains_key(1), "ErasNominatorsSlashable should be empty after completing SingleEntryCleanups step" ); + assert!( + !crate::ErasValidatorIncentiveAllocation::::contains_key(1), + "ErasValidatorIncentiveAllocation should be empty after completing SingleEntryCleanups step" + ); + }, + ErasValidatorIncentive => { + let count = crate::ErasValidatorIncentive::::iter_prefix(1).count(); + assert_eq!(count, 0, "{expected_step:?} should be empty after completing step"); }, ValidatorSlashInEra => assert_eq!( crate::ValidatorSlashInEra::::iter_prefix_values(1).count(), @@ -525,8 +480,9 @@ mod inflation { #[test] fn max_staked_rewards_default_not_set_works() { ExtBuilder::default().build_and_execute(|| { + // 50% of time_per_era() (other half goes to buffer as per mock::default_budget() let default_stakers_payout = validator_payout_for(time_per_era()); - assert!(default_stakers_payout > 0); + assert_eq!(default_stakers_payout, Balance::from(time_per_era()) / 2); assert_eq!(>::get(), None); @@ -538,7 +494,6 @@ mod inflation { Event::SessionRotated { starting_session: 4, active_era: 1, planned_era: 2 }, Event::PagedElectionProceeded { page: 0, result: Ok(2) }, Event::SessionRotated { starting_session: 5, active_era: 1, planned_era: 2 }, - Event::EraPaid { era_index: 1, validator_payout: 7500, remainder: 7500 }, Event::SessionRotated { starting_session: 6, active_era: 2, planned_era: 2 } ] ); @@ -552,7 +507,7 @@ mod inflation { fn max_staked_rewards_default_equal_100() { ExtBuilder::default().build_and_execute(|| { let default_stakers_payout = validator_payout_for(time_per_era()); - assert!(default_stakers_payout > 0); + assert_eq!(default_stakers_payout, Balance::from(time_per_era()) / 2); >::set(Some(Percent::from_parts(100))); Session::roll_until_active_era(2); @@ -563,7 +518,6 @@ mod inflation { Event::SessionRotated { starting_session: 4, active_era: 1, planned_era: 2 }, Event::PagedElectionProceeded { page: 0, result: Ok(2) }, Event::SessionRotated { starting_session: 5, active_era: 1, planned_era: 2 }, - Event::EraPaid { era_index: 1, validator_payout: 7500, remainder: 7500 }, Event::SessionRotated { starting_session: 6, active_era: 2, planned_era: 2 } ] ); @@ -572,54 +526,78 @@ mod inflation { assert_eq!(ErasValidatorReward::::get(0).unwrap(), default_stakers_payout); }); } +} - #[test] - fn max_staked_rewards_works() { - ExtBuilder::default().nominate(true).build_and_execute(|| { - // 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(10)), - ConfigOp::Noop, - )); - - assert_eq!(>::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); +#[test] +fn era_pot_cleanup_after_history_depth() { + ExtBuilder::default().build_and_execute(|| { + // GIVEN: Start at era 2 + Session::roll_until_active_era(2); + let _ = staking_events_since_last_call(); - Session::roll_until_active_era(2); - assert_eq!( - staking_events_since_last_call(), - vec![ - Event::SessionRotated { starting_session: 4, active_era: 1, planned_era: 2 }, - Event::PagedElectionProceeded { page: 0, result: Ok(2) }, - Event::SessionRotated { starting_session: 5, active_era: 1, planned_era: 2 }, - Event::EraPaid { era_index: 1, validator_payout: 1500, remainder: 13500 }, - Event::SessionRotated { starting_session: 6, active_era: 2, planned_era: 2 } - ] - ); + // Verify era-1 staker pot was funded with expected amount. + let staker_pot_1 = ::EraPots::era_pot_account(1, EraPotType::StakerRewards); + let expected_per_era = validator_payout_for(time_per_era()); + assert_eq!(Balances::balance(&staker_pot_1), expected_per_era); - let treasury_payout = RewardRemainderUnbalanced::get(); - let validators_payout = ErasValidatorReward::::get(1).unwrap(); - let total_payout = treasury_payout + validators_payout; + // era we expect to be cleaned up + let cleanup_era = 1; - // total payout is the same - assert_eq!(total_payout, total_payout_for(time_per_era())); - // validators get only 10% - assert_eq!(validators_payout, Percent::from_percent(10) * total_payout); - // treasury gets 90% - assert_eq!(treasury_payout, Percent::from_percent(90) * total_payout); - }) - } + // WHEN: Advance past HistoryDepth + // At era (1 + HistoryDepth + 1), era 1 should be cleaned up + // For HistoryDepth = 80: cleanup happens at era 82 + let target_era = cleanup_era + HistoryDepth::get() + 1; + Session::roll_until_active_era(target_era); + let _ = staking_events_since_last_call(); + // Verify rewards were allocated for the eras we advanced through. + + // THEN: Verify era-1 pots have been cleaned up + let staker_pot = + ::EraPots::era_pot_account(cleanup_era, EraPotType::StakerRewards); + let validator_pot = + ::EraPots::era_pot_account(cleanup_era, EraPotType::ValidatorSelfStake); + + assert_eq!(Balances::balance(&staker_pot), 0, "Staker pot should have zero balance"); + assert_eq!(Balances::balance(&validator_pot), 0, "Validator pot should have zero balance"); + assert_eq!(System::providers(&staker_pot), 0, "Staker pot should have no providers"); + assert_eq!(System::providers(&validator_pot), 0, "Validator pot should have no providers"); + }); +} + +#[test] +fn disable_legacy_minting_era_updates_correctly() { + ExtBuilder::default().build_and_execute(|| { + // GIVEN: DisableLegacyMintingEra is set to 0 in test genesis + assert_eq!(DisableLegacyMintingEra::::get(), Some(0)); + + // WHEN: Era 1 ends with non-zero reward allocation + Session::roll_until_active_era(2); + let _ = staking_events_since_last_call(); + + // THEN: DisableLegacyMintingEra remains at 0 + assert_eq!(DisableLegacyMintingEra::::get(), Some(0)); + }); +} + +#[test] +fn disable_legacy_minting_era_write_once_semantics() { + ExtBuilder::default().build_and_execute(|| { + // GIVEN: Clear DisableLegacyMintingEra to simulate pre-migration state + DisableLegacyMintingEra::::kill(); + assert_eq!(DisableLegacyMintingEra::::get(), None); + + // WHEN: First era ends with rewards + Session::roll_until_active_era(2); + let _ = staking_events_since_last_call(); + + // THEN: DisableLegacyMintingEra is set to era 1 + assert_eq!(DisableLegacyMintingEra::::get(), Some(1)); + + // WHEN: More eras end + Session::roll_until_active_era(5); + let _ = staking_events_since_last_call(); + + // THEN: DisableLegacyMintingEra stays at 1 (not updated to higher values) + assert_eq!(DisableLegacyMintingEra::::get(), Some(1)); + }); } diff --git a/substrate/frame/staking-async/src/tests/mod.rs b/substrate/frame/staking-async/src/tests/mod.rs index f07674a0d4278..34b70e2152fca 100644 --- a/substrate/frame/staking-async/src/tests/mod.rs +++ b/substrate/frame/staking-async/src/tests/mod.rs @@ -47,6 +47,7 @@ mod nominators_no_slashing; mod payout_stakers; mod slashing; mod try_state; +mod validator_incentive; #[test] fn basic_setup_session_queuing_should_work() { @@ -203,7 +204,6 @@ fn basic_setup_works() { Event::SessionRotated { starting_session: 1, active_era: 0, planned_era: 1 }, Event::PagedElectionProceeded { page: 0, result: Ok(2) }, Event::SessionRotated { starting_session: 2, active_era: 0, planned_era: 1 }, - Event::EraPaid { era_index: 0, validator_payout: 7500, remainder: 7500 }, Event::SessionRotated { starting_session: 3, active_era: 1, planned_era: 1 } ] ); @@ -255,7 +255,6 @@ fn basic_setup_sessions_per_era() { Event::SessionRotated { starting_session: 4, active_era: 0, planned_era: 1 }, Event::PagedElectionProceeded { page: 0, result: Ok(2) }, Event::SessionRotated { starting_session: 5, active_era: 0, planned_era: 1 }, - Event::EraPaid { era_index: 0, validator_payout: 15000, remainder: 15000 }, Event::SessionRotated { starting_session: 6, active_era: 1, planned_era: 1 } ] ); @@ -275,7 +274,6 @@ fn basic_setup_sessions_per_era() { Event::SessionRotated { starting_session: 10, active_era: 1, planned_era: 2 }, Event::PagedElectionProceeded { page: 0, result: Ok(2) }, Event::SessionRotated { starting_session: 11, active_era: 1, planned_era: 2 }, - Event::EraPaid { era_index: 1, validator_payout: 15000, remainder: 15000 }, Event::SessionRotated { starting_session: 12, active_era: 2, planned_era: 2 } ] ); @@ -1194,7 +1192,6 @@ mod hold_migration { // vec![ // Event::PagedElectionProceeded { page: 0, result: Ok(7) }, // Event::StakersElected, -// Event::EraPaid { era_index: 0, validator_payout: 11075, remainder: 33225 }, // Event::OffenceReported { // validator: 11, // fraction: Perbill::from_percent(10), diff --git a/substrate/frame/staking-async/src/tests/payout_stakers.rs b/substrate/frame/staking-async/src/tests/payout_stakers.rs index 9242147203b2a..e16ec16273590 100644 --- a/substrate/frame/staking-async/src/tests/payout_stakers.rs +++ b/substrate/frame/staking-async/src/tests/payout_stakers.rs @@ -39,7 +39,6 @@ fn rewards_with_nominator_should_work() { // Compute total payout now for whole duration of the session. let validator_payout_0 = validator_payout_for(time_per_era()); - let maximum_payout = total_payout_for(time_per_era()); assert_eq_uvec!(Session::validators(), vec![11, 21]); @@ -63,17 +62,14 @@ fn rewards_with_nominator_should_work() { Event::SessionRotated { starting_session: 4, active_era: 1, planned_era: 2 }, Event::PagedElectionProceeded { page: 0, result: Ok(2) }, Event::SessionRotated { starting_session: 5, active_era: 1, planned_era: 2 }, - Event::EraPaid { - era_index: 1, - validator_payout: validator_payout_0, - remainder: maximum_payout - validator_payout_0 - }, Event::SessionRotated { starting_session: 6, active_era: 2, planned_era: 2 } ] ); - assert_eq!(mock::RewardRemainderUnbalanced::get(), maximum_payout - validator_payout_0); + // With DAP, treasury rewards are minted directly into buffer (not sent to RewardRemainder) + assert_eq!(mock::RewardRemainderUnbalanced::get(), 0); - // make note of total issuance before rewards. + // make note of total issuance before payouts (rewards already minted at era finalization + // above) let pre_issuance = asset::total_issuance::(); mock::make_all_reward_payment(1); @@ -81,17 +77,18 @@ fn rewards_with_nominator_should_work() { mock::staking_events_since_last_call(), vec![ Event::PayoutStarted { era_index: 1, validator_stash: 11, page: 0, next: None }, - Event::Rewarded { stash: 11, dest: RewardDestination::Account(11), amount: 4000 }, + Event::Rewarded { stash: 11, dest: RewardDestination::Account(11), amount: 3999 }, Event::Rewarded { stash: 101, dest: RewardDestination::Account(101), amount: 1000 }, Event::PayoutStarted { era_index: 1, validator_stash: 21, page: 0, next: None }, - Event::Rewarded { stash: 21, dest: RewardDestination::Account(21), amount: 2000 }, + Event::Rewarded { stash: 21, dest: RewardDestination::Account(21), amount: 1999 }, Event::Rewarded { stash: 101, dest: RewardDestination::Account(101), amount: 500 } ] ); - // total issuance should have increased + // With DAP, rewards are minted at era finalization (not during payout) + // So total issuance should not change during payout (just transfers from pot) let post_issuance = asset::total_issuance::(); - assert_eq!(post_issuance, pre_issuance + validator_payout_0); + assert_eq!(post_issuance, pre_issuance); assert_eq_error_rate!( asset::total_balance::(&11), @@ -119,23 +116,24 @@ fn rewards_with_nominator_should_work() { Session::roll_until_active_era(3); - assert_eq!( - mock::RewardRemainderUnbalanced::get(), - maximum_payout * 2 - validator_payout_0 - total_payout_1, - ); + // With DAP, treasury rewards go to buffer (not RewardRemainder) + assert_eq!(mock::RewardRemainderUnbalanced::get(), 0); assert_eq!( mock::staking_events_since_last_call(), vec![ Event::SessionRotated { starting_session: 7, active_era: 2, planned_era: 3 }, Event::PagedElectionProceeded { page: 0, result: Ok(2) }, Event::SessionRotated { starting_session: 8, active_era: 2, planned_era: 3 }, - Event::EraPaid { era_index: 2, validator_payout: 7500, remainder: 7500 }, Event::SessionRotated { starting_session: 9, active_era: 3, planned_era: 3 } ] ); + // Capture issuance before payout (rewards already minted during era finalization above) + let pre_payout_2 = asset::total_issuance::(); + mock::make_all_reward_payment(2); - assert_eq!(asset::total_issuance::(), post_issuance + total_payout_1); + // With DAP, payouts just transfer from pot (no minting), so issuance unchanged + assert_eq!(asset::total_issuance::(), pre_payout_2); assert_eq_error_rate!( asset::total_balance::(&11), @@ -227,7 +225,6 @@ fn nominating_and_rewards_should_work() { Event::SessionRotated { starting_session: 4, active_era: 1, planned_era: 2 }, Event::PagedElectionProceeded { page: 0, result: Ok(2) }, Event::SessionRotated { starting_session: 5, active_era: 1, planned_era: 2 }, - Event::EraPaid { era_index: 1, validator_payout: 7500, remainder: 7500 }, Event::SessionRotated { starting_session: 6, active_era: 2, planned_era: 2 } ] ); @@ -279,7 +276,7 @@ fn nominating_and_rewards_should_work() { staking_events_since_last_call(), vec![ Event::PayoutStarted { era_index: 2, validator_stash: 11, page: 0, next: None }, - Event::Rewarded { stash: 11, dest: RewardDestination::Staked, amount: 500 }, + Event::Rewarded { stash: 11, dest: RewardDestination::Staked, amount: 499 }, Event::Rewarded { stash: 1, dest: RewardDestination::Stash, amount: 750 }, Event::Rewarded { stash: 3, @@ -287,8 +284,8 @@ fn nominating_and_rewards_should_work() { amount: 2500 }, Event::PayoutStarted { era_index: 2, validator_stash: 41, page: 0, next: None }, - Event::Rewarded { stash: 41, dest: RewardDestination::Staked, amount: 2000 }, - Event::Rewarded { stash: 1, dest: RewardDestination::Stash, amount: 1750 } + Event::Rewarded { stash: 41, dest: RewardDestination::Staked, amount: 1999 }, + Event::Rewarded { stash: 1, dest: RewardDestination::Stash, amount: 1751 } ] ); }); @@ -880,7 +877,6 @@ fn test_multi_page_payout_stakers_by_page() { assert_eq!(Eras::::exposure_page_count(2, &11), 2); // compute and ensure the reward amount is greater than zero. - let payout = validator_payout_for(time_per_era()); Session::roll_until_active_era(3); // verify the exposures are calculated correctly. @@ -919,8 +915,6 @@ fn test_multi_page_payout_stakers_by_page() { let controller_balance_after_p0_payout = asset::stakeable_balance::(&11); // verify rewards have been paid out but still some left - assert!(pallet_balances::TotalIssuance::::get() > pre_payout_total_issuance); - assert!(pallet_balances::TotalIssuance::::get() < pre_payout_total_issuance + payout); // verify the validator has been rewarded assert!(controller_balance_after_p0_payout > controller_balance_before_p0_payout); @@ -943,13 +937,9 @@ fn test_multi_page_payout_stakers_by_page() { // verify the validator was not rewarded the second time assert_eq!(asset::stakeable_balance::(&11), controller_balance_after_p0_payout); - // verify all rewards have been paid out - assert_eq_error_rate!( - pallet_balances::TotalIssuance::::get(), - pre_payout_total_issuance + payout, - 2 - ); - assert!(RewardOnUnbalanceWasCalled::get()); + // With DAP, rewards minted at era finalization, so no change during payout + // Issuance was already increased during Session::roll_until_active_era above + assert_eq!(pallet_balances::TotalIssuance::::get(), pre_payout_total_issuance); // Top 64 nominators of validator 11 automatically paid out, including the validator assert!(asset::stakeable_balance::(&11) > balance); @@ -966,18 +956,22 @@ fn test_multi_page_payout_stakers_by_page() { Staking::reward_by_ids(vec![(11, 1)]); // compute and ensure the reward amount is greater than zero. - let payout = validator_payout_for(time_per_era()); - let pre_payout_total_issuance = pallet_balances::TotalIssuance::::get(); + let total_payout = total_payout_for(time_per_era()); + let pre_roll_issuance = pallet_balances::TotalIssuance::::get(); Session::roll_until_active_era(i); - RewardOnUnbalanceWasCalled::set(false); - mock::make_all_reward_payment(i - 1); + + // Issuance should have increased by total_payout (staker + treasury rewards) assert_eq_error_rate!( pallet_balances::TotalIssuance::::get(), - pre_payout_total_issuance + payout, + pre_roll_issuance + total_payout, 2 ); - assert!(RewardOnUnbalanceWasCalled::get()); + + let post_roll_issuance = pallet_balances::TotalIssuance::::get(); + mock::make_all_reward_payment(i - 1); + // Payout just transfers from pot, so issuance doesnt change + assert_eq!(pallet_balances::TotalIssuance::::get(), post_roll_issuance); // verify we track rewards for each era and page for page in 0..Eras::::exposure_page_count(i - 1, &11) { @@ -1079,7 +1073,6 @@ fn test_multi_page_payout_stakers_backward_compatible() { assert_eq!(Eras::::exposure_page_count(2, &11), 2); // compute and ensure the reward amount is greater than zero. - let payout = validator_payout_for(time_per_era()); Session::roll_until_active_era(3); // verify the exposures are calculated correctly. @@ -1109,8 +1102,6 @@ fn test_multi_page_payout_stakers_backward_compatible() { let controller_balance_after_p0_payout = asset::stakeable_balance::(&11); // verify rewards have been paid out but still some left - assert!(pallet_balances::TotalIssuance::::get() > pre_payout_total_issuance); - assert!(pallet_balances::TotalIssuance::::get() < pre_payout_total_issuance + payout); // verify the validator has been rewarded assert!(controller_balance_after_p0_payout > controller_balance_before_p0_payout); @@ -1130,10 +1121,9 @@ fn test_multi_page_payout_stakers_backward_compatible() { // verify all rewards have been paid out assert_eq_error_rate!( pallet_balances::TotalIssuance::::get(), - pre_payout_total_issuance + payout, + pre_payout_total_issuance, 2 ); - assert!(RewardOnUnbalanceWasCalled::get()); // verify all nominators of validator 11 are paid out, including the validator // Validator payout goes to controller. @@ -1151,18 +1141,22 @@ fn test_multi_page_payout_stakers_backward_compatible() { Staking::reward_by_ids(vec![(11, 1)]); // compute and ensure the reward amount is greater than zero. - let payout = validator_payout_for(time_per_era()); - let pre_payout_total_issuance = pallet_balances::TotalIssuance::::get(); + let total_payout = total_payout_for(time_per_era()); + let pre_roll_issuance = pallet_balances::TotalIssuance::::get(); Session::roll_until_active_era(i); - RewardOnUnbalanceWasCalled::set(false); - mock::make_all_reward_payment(i - 1); + + // Issuance should have increased by total_payout (staker + treasury rewards) assert_eq_error_rate!( pallet_balances::TotalIssuance::::get(), - pre_payout_total_issuance + payout, + pre_roll_issuance + total_payout, 2 ); - assert!(RewardOnUnbalanceWasCalled::get()); + + let post_roll_issuance = pallet_balances::TotalIssuance::::get(); + mock::make_all_reward_payment(i - 1); + // Payout just transfers from pot, so issuance doesnt change + assert_eq!(pallet_balances::TotalIssuance::::get(), post_roll_issuance); // verify we track rewards for each era and page for page in 0..Eras::::exposure_page_count(i - 1, &11) { @@ -1488,7 +1482,8 @@ fn test_commission_paid_across_pages() { assert!(before_balance < after_balance); } - assert_eq_error_rate!(asset::stakeable_balance::(&11), initial_balance + payout / 2, 1,); + // Allow error rate of 2 due to floor rounding in payout calculations + assert_eq_error_rate!(asset::stakeable_balance::(&11), initial_balance + payout / 2, 2,); }); } @@ -1649,11 +1644,12 @@ fn test_runtime_api_pending_rewards() { individual: bounded_btree_map![validator_one => 1, validator_two => 1, validator_three => 1], }; ErasRewardPoints::::insert(0, reward); - - // build exposure + // Build exposure let mut individual_exposures: Vec> = vec![]; for i in 0..=MaxExposurePageSize::get() { - individual_exposures.push(IndividualExposure { who: i.into(), value: stake }); + let nominator: AccountId = (10_000 + i).into(); + bond(nominator, stake); + individual_exposures.push(IndividualExposure { who: nominator, value: stake }); } let exposure = Exposure:: { total: stake * (MaxExposurePageSize::get() as Balance + 2), diff --git a/substrate/frame/staking-async/src/tests/slashing.rs b/substrate/frame/staking-async/src/tests/slashing.rs index 58f45afd095c9..7d3317f22cf3e 100644 --- a/substrate/frame/staking-async/src/tests/slashing.rs +++ b/substrate/frame/staking-async/src/tests/slashing.rs @@ -356,7 +356,6 @@ fn deferred_slashes_are_deferred() { Event::SessionRotated { starting_session: 4, active_era: 1, planned_era: 2 }, Event::PagedElectionProceeded { page: 0, result: Ok(2) }, Event::SessionRotated { starting_session: 5, active_era: 1, planned_era: 2 }, - Event::EraPaid { era_index: 1, validator_payout: 7500, remainder: 7500 }, Event::SessionRotated { starting_session: 6, active_era: 2, planned_era: 2 } ] ); @@ -372,7 +371,6 @@ fn deferred_slashes_are_deferred() { Event::SessionRotated { starting_session: 7, active_era: 2, planned_era: 3 }, Event::PagedElectionProceeded { page: 0, result: Ok(2) }, Event::SessionRotated { starting_session: 8, active_era: 2, planned_era: 3 }, - Event::EraPaid { era_index: 2, validator_payout: 7500, remainder: 7500 }, Event::SessionRotated { starting_session: 9, active_era: 3, planned_era: 3 } ] ); @@ -431,7 +429,6 @@ fn retroactive_deferred_slashes_two_eras_before() { Event::SessionRotated { starting_session: 7, active_era: 2, planned_era: 3 }, Event::PagedElectionProceeded { page: 0, result: Ok(2) }, Event::SessionRotated { starting_session: 8, active_era: 2, planned_era: 3 }, - Event::EraPaid { era_index: 2, validator_payout: 7500, remainder: 7500 }, Event::SessionRotated { starting_session: 9, active_era: 3, planned_era: 3 } ] ); @@ -490,7 +487,6 @@ fn retroactive_deferred_slashes_one_before() { Event::SessionRotated { starting_session: 10, active_era: 3, planned_era: 4 }, Event::PagedElectionProceeded { page: 0, result: Ok(2) }, Event::SessionRotated { starting_session: 11, active_era: 3, planned_era: 4 }, - Event::EraPaid { era_index: 3, validator_payout: 7500, remainder: 7500 }, Event::SessionRotated { starting_session: 12, active_era: 4, planned_era: 4 } ] ); diff --git a/substrate/frame/staking-async/src/tests/validator_incentive.rs b/substrate/frame/staking-async/src/tests/validator_incentive.rs new file mode 100644 index 0000000000000..34dc88371d56b --- /dev/null +++ b/substrate/frame/staking-async/src/tests/validator_incentive.rs @@ -0,0 +1,1332 @@ +// 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. + +use super::*; +use crate::{ + asset, + session_rotation::{EraElectionPlanner, Eras, Rotator}, +}; + +/// Sets up the default validator self-stake incentive config used across tests. +fn setup_incentive_config() { + assert_ok!(Staking::set_validator_self_stake_incentive_config( + RuntimeOrigin::root(), + ConfigOp::Set(30_000), + ConfigOp::Set(100_000), + ConfigOp::Set(Perbill::from_rational(1u32, 2u32)), + )); +} + +/// Sets up incentive config and a budget allocation with the given percentages. +fn setup_incentive_with_budget(staker_pct: u32, incentive_pct: u32) { + setup_incentive_config(); + let buffer_pct = 100u32.saturating_sub(staker_pct).saturating_sub(incentive_pct); + let mut entries = vec![(staker_reward_key(), staker_pct)]; + if incentive_pct > 0 { + entries.push((validator_incentive_key(), incentive_pct)); + } + if buffer_pct > 0 { + entries.push((buffer_key(), buffer_pct)); + } + pallet_dap::BudgetAllocation::::put(build_budget(&entries)); +} + +/// Finds the staker reward amount for a given stash from events. +fn staker_reward_for(stash: AccountId, events: &[Event]) -> Option { + events.iter().find_map(|e| match e { + Event::Rewarded { stash: s, amount, .. } if *s == stash => Some(*amount), + _ => None, + }) +} + +/// Finds the validator incentive amount for a given stash from events. +fn incentive_paid_for(stash: AccountId, events: &[Event]) -> Option { + incentive_paid_details(stash, events).map(|(amount, _)| amount) +} + +/// Finds the validator incentive amount and destination for a given stash from events. +fn incentive_paid_details( + stash: AccountId, + events: &[Event], +) -> Option<(Balance, RewardDestination)> { + events.iter().find_map(|e| match e { + Event::ValidatorIncentivePaid { validator_stash, amount, dest, .. } + if *validator_stash == stash => + { + Some((*amount, *dest)) + }, + _ => None, + }) +} + +#[test] +fn set_validator_self_stake_incentive_config_works() { + ExtBuilder::default().build_and_execute(|| { + // Setting all parameters works + assert_ok!(Staking::set_validator_self_stake_incentive_config( + RuntimeOrigin::root(), + ConfigOp::Set(30_000), + ConfigOp::Set(100_000), + ConfigOp::Set(Perbill::from_rational(1u32, 2u32)), // 0.5 + )); + assert_eq!(OptimumSelfStake::::get(), 30_000); + assert_eq!(HardCapSelfStake::::get(), 100_000); + assert_eq!(SelfStakeSlopeFactor::::get(), Perbill::from_rational(1u32, 2u32)); + + // Noop does nothing + assert_storage_noop!(assert_ok!(Staking::set_validator_self_stake_incentive_config( + RuntimeOrigin::root(), + ConfigOp::Noop, + ConfigOp::Noop, + ConfigOp::Noop, + ))); + + // Removing works + assert_ok!(Staking::set_validator_self_stake_incentive_config( + RuntimeOrigin::root(), + ConfigOp::Remove, + ConfigOp::Remove, + ConfigOp::Remove, + )); + assert!(!OptimumSelfStake::::exists()); + assert!(!HardCapSelfStake::::exists()); + assert!(!SelfStakeSlopeFactor::::exists()); + }); +} + +#[test] +fn set_validator_self_stake_incentive_config_requires_admin() { + ExtBuilder::default().build_and_execute(|| { + // as setup in mock + let admin = 1; + + // Non-admin origin should fail + assert_noop!( + Staking::set_validator_self_stake_incentive_config( + RuntimeOrigin::signed(2), + ConfigOp::Set(30_000), + ConfigOp::Set(100_000), + ConfigOp::Set(Perbill::from_rational(1u32, 2u32)), + ), + DispatchError::BadOrigin + ); + + // Admin origin should work + assert_ok!(Staking::set_validator_self_stake_incentive_config( + RuntimeOrigin::signed(admin), + ConfigOp::Set(30_000), + ConfigOp::Set(100_000), + ConfigOp::Set(Perbill::from_rational(1u32, 2u32)), + )); + }); +} + +#[test] +fn set_validator_self_stake_incentive_config_partial_update() { + ExtBuilder::default().build_and_execute(|| { + // Set initial values + assert_ok!(Staking::set_validator_self_stake_incentive_config( + RuntimeOrigin::root(), + ConfigOp::Set(30_000), + ConfigOp::Set(100_000), + ConfigOp::Set(Perbill::from_rational(1u32, 2u32)), + )); + + // Update only optimum_self_stake + assert_ok!(Staking::set_validator_self_stake_incentive_config( + RuntimeOrigin::root(), + ConfigOp::Set(50_000), + ConfigOp::Noop, + ConfigOp::Noop, + )); + assert_eq!(OptimumSelfStake::::get(), 50_000); + assert_eq!(HardCapSelfStake::::get(), 100_000); + assert_eq!(SelfStakeSlopeFactor::::get(), Perbill::from_rational(1u32, 2u32)); + + // Update only slope factor + assert_ok!(Staking::set_validator_self_stake_incentive_config( + RuntimeOrigin::root(), + ConfigOp::Noop, + ConfigOp::Noop, + ConfigOp::Set(Perbill::from_rational(3u32, 4u32)), + )); + assert_eq!(OptimumSelfStake::::get(), 50_000); + assert_eq!(HardCapSelfStake::::get(), 100_000); + assert_eq!(SelfStakeSlopeFactor::::get(), Perbill::from_rational(3u32, 4u32)); + }); +} + +#[test] +fn set_validator_self_stake_incentive_config_rejects_optimum_greater_than_cap() { + ExtBuilder::default().build_and_execute(|| { + // Setting both with optimum > cap should fail + assert_noop!( + Staking::set_validator_self_stake_incentive_config( + RuntimeOrigin::root(), + // optimum + ConfigOp::Set(100_000), + // hard cap + ConfigOp::Set(50_000), + ConfigOp::Set(Perbill::from_rational(1u32, 2u32)), + ), + Error::::OptimumGreaterThanCap + ); + }); +} + +#[test] +fn set_validator_self_stake_incentive_config_rejects_setting_optimum_greater_than_existing_cap() { + ExtBuilder::default().build_and_execute(|| { + // Set initial config with valid values + assert_ok!(Staking::set_validator_self_stake_incentive_config( + RuntimeOrigin::root(), + ConfigOp::Set(30_000), + ConfigOp::Set(100_000), + ConfigOp::Set(Perbill::from_rational(1u32, 2u32)), + )); + + // Try to update optimum to be greater than existing cap should fail + assert_noop!( + Staking::set_validator_self_stake_incentive_config( + RuntimeOrigin::root(), + // optimum + ConfigOp::Set(150_000), + // existing hard cap is 100_000 + ConfigOp::Noop, + ConfigOp::Noop, + ), + Error::::OptimumGreaterThanCap + ); + }); +} + +#[test] +fn set_validator_self_stake_incentive_config_rejects_setting_cap_less_than_existing_optimum() { + ExtBuilder::default().build_and_execute(|| { + // Set initial config with valid values + assert_ok!(Staking::set_validator_self_stake_incentive_config( + RuntimeOrigin::root(), + ConfigOp::Set(50_000), + ConfigOp::Set(100_000), + ConfigOp::Set(Perbill::from_rational(1u32, 2u32)), + )); + + // Try to update cap to be less than existing optimum should fail + assert_noop!( + Staking::set_validator_self_stake_incentive_config( + RuntimeOrigin::root(), + // existing optimum is 50_000 + ConfigOp::Noop, + // hard cap + ConfigOp::Set(30_000), + ConfigOp::Noop, + ), + Error::::OptimumGreaterThanCap + ); + }); +} + +#[test] +fn set_validator_self_stake_incentive_config_accepts_equal_values() { + ExtBuilder::default().build_and_execute(|| { + // Setting both with optimum = cap should succeed + assert_ok!(Staking::set_validator_self_stake_incentive_config( + RuntimeOrigin::root(), + ConfigOp::Set(50_000), + ConfigOp::Set(50_000), + ConfigOp::Set(Perbill::from_rational(1u32, 2u32)), + )); + + assert_eq!(OptimumSelfStake::::get(), 50_000); + assert_eq!(HardCapSelfStake::::get(), 50_000); + assert_eq!(SelfStakeSlopeFactor::::get(), Perbill::from_rational(1u32, 2u32)); + }); +} + +#[test] +fn set_validator_self_stake_incentive_config_allows_removing_parameters() { + ExtBuilder::default().build_and_execute(|| { + // Set initial config + assert_ok!(Staking::set_validator_self_stake_incentive_config( + RuntimeOrigin::root(), + ConfigOp::Set(30_000), + ConfigOp::Set(100_000), + ConfigOp::Set(Perbill::from_rational(1u32, 2u32)), + )); + + // Removing optimum while keeping cap should succeed (no validation needed) + assert_ok!(Staking::set_validator_self_stake_incentive_config( + RuntimeOrigin::root(), + ConfigOp::Remove, + ConfigOp::Noop, + ConfigOp::Noop, + )); + assert!(!OptimumSelfStake::::exists()); + assert_eq!(HardCapSelfStake::::get(), 100_000); + + // Set optimum again + assert_ok!(Staking::set_validator_self_stake_incentive_config( + RuntimeOrigin::root(), + ConfigOp::Set(30_000), + ConfigOp::Noop, + ConfigOp::Noop, + )); + + // Removing cap while keeping optimum should succeed (no validation needed) + assert_ok!(Staking::set_validator_self_stake_incentive_config( + RuntimeOrigin::root(), + ConfigOp::Noop, + ConfigOp::Remove, + ConfigOp::Noop, + )); + assert_eq!(OptimumSelfStake::::get(), 30_000); + assert!(!HardCapSelfStake::::exists()); + }); +} + +#[test] +fn set_validator_self_stake_incentive_config_allows_setting_optimum_when_cap_is_zero() { + ExtBuilder::default().build_and_execute(|| { + // Setting optimum when cap is zero (not configured) should succeed + // because the config is incomplete and won't be used + assert_ok!(Staking::set_validator_self_stake_incentive_config( + RuntimeOrigin::root(), + ConfigOp::Set(100_000), + ConfigOp::Noop, // cap remains 0 + ConfigOp::Set(Perbill::from_rational(1u32, 2u32)), + )); + + assert_eq!(OptimumSelfStake::::get(), 100_000); + assert_eq!(HardCapSelfStake::::get(), 0); // Still zero + }); +} + +#[test] +fn set_validator_self_stake_incentive_config_allows_setting_cap_when_optimum_is_zero() { + ExtBuilder::default().build_and_execute(|| { + // Setting cap when optimum is zero (not configured) should succeed + // because the config is incomplete and won't be used + assert_ok!(Staking::set_validator_self_stake_incentive_config( + RuntimeOrigin::root(), + ConfigOp::Noop, // optimum remains 0 + ConfigOp::Set(100_000), + ConfigOp::Set(Perbill::from_rational(1u32, 2u32)), + )); + + assert_eq!(OptimumSelfStake::::get(), 0); // Still zero + assert_eq!(HardCapSelfStake::::get(), 100_000); + }); +} + +#[test] +fn validator_receives_both_staker_and_incentive_rewards() { + ExtBuilder::default().build_and_execute(|| { + // GIVEN: Validator with incentive budget enabled + let alice = 11; // validator + let bob = 101; // nominator + + setup_incentive_with_budget(45, 5); + + // Era 2 has validator weights set by election + Session::roll_until_active_era(2); + Eras::::reward_active_era(vec![(alice, 1), (21, 1)]); + Session::roll_until_active_era(3); + let _ = staking_events_since_last_call(); + + let alice_balance_before = asset::total_balance::(&alice); + + // WHEN: Payout rewards + make_all_reward_payment(2); + + // THEN: Validator receives both staker reward and incentive bonus + let events = staking_events_since_last_call(); + + let staker_reward = + staker_reward_for(alice, &events).expect("Validator should receive staker reward"); + let incentive = + incentive_paid_for(alice, &events).expect("Validator should receive incentive bonus"); + + // Alice balance increased by correct amount + let alice_balance_after = asset::total_balance::(&alice); + assert_eq!(alice_balance_after - alice_balance_before, staker_reward + incentive); + + // Bob (the nominator) received staker reward but not incentive + assert!(staker_reward_for(bob, &events).is_some()); + assert!(incentive_paid_for(bob, &events).is_none()); + }); +} + +#[test] +fn nominator_reward_is_proportional_to_staker_budget() { + ExtBuilder::default().build_and_execute(|| { + // GIVEN: Validator with nominator, validator incentive enabled + let alice = 11; // validator + let bob = 101; // nominator + + setup_incentive_with_budget(40, 10); + + Eras::::reward_active_era(vec![(alice, 1), (21, 1)]); + Session::roll_until_active_era(2); + let _ = staking_events_since_last_call(); + + // WHEN: Rewards distributed + make_all_reward_payment(1); + let events = staking_events_since_last_call(); + + // THEN: Both receive rewards, validator gets more (higher stake = 1000 vs 500) + let nominator_reward = + staker_reward_for(bob, &events).expect("Nominator should receive reward"); + let validator_reward = + staker_reward_for(alice, &events).expect("Validator should receive reward"); + + assert!( + validator_reward > nominator_reward, + "Validator ({validator_reward}) should earn more than nominator ({nominator_reward})" + ); + }); +} + +#[test] +fn multiple_validators_share_incentive_pot_correctly() { + ExtBuilder::default().build_and_execute(|| { + // GIVEN: Two validators with equal reward points + let alice = 11; // validator + let bob = 21; // validator + + setup_incentive_with_budget(45, 5); + + Eras::::reward_active_era(vec![(alice, 1), (bob, 1)]); + Session::roll_until_active_era(2); + Eras::::reward_active_era(vec![(alice, 1), (bob, 1)]); + Session::roll_until_active_era(3); + let _ = staking_events_since_last_call(); + + let pot_snapshot = ErasValidatorIncentiveAllocation::::get(2); + assert!(pot_snapshot > 0); + + let alice_weight = ErasValidatorIncentive::::get(2, alice).unwrap(); + let bob_weight = ErasValidatorIncentive::::get(2, bob).unwrap(); + let total_weight = ErasTotalValidatorWeight::::get(2); + + let alice_expected_share = + Perbill::from_rational(alice_weight, total_weight).mul_floor(pot_snapshot); + let bob_expected_share = + Perbill::from_rational(bob_weight, total_weight).mul_floor(pot_snapshot); + + // WHEN: Both validators claim rewards + make_all_reward_payment(2); + + // THEN: Pot is depleted and total matches expected + let pot_account = + ::EraPots::era_pot_account(2, EraPotType::ValidatorSelfStake); + let remaining = Balances::free_balance(&pot_account); + assert_eq!(remaining, 0, "Pot should be empty, has {}", remaining); + + let total_claimed = pot_snapshot - remaining; + let expected_total = alice_expected_share + bob_expected_share; + + // Rewards are always rounded down, so total_claimed <= expected_total + assert!( + total_claimed <= expected_total, + "Total claimed ({}) should not exceed expected ({})", + total_claimed, + expected_total + ); + let diff = expected_total - total_claimed; + assert!(diff < 5, "Rounding dust too large: {}", diff); + }); +} + +#[test] +fn validator_incentive_prorated_across_pages() { + // Verifies that validator incentive is prorated across multiple pages: + // - ValidatorIncentivePaid event is emitted once per page + // - Sum of all page incentives equals the validator's total share from the pot + ExtBuilder::default().build_and_execute(|| { + // GIVEN: Validator with incentive enabled + let alice = 11; // validator + + setup_incentive_with_budget(45, 5); + + Session::roll_until_active_era(2); + Eras::::reward_active_era(vec![(alice, 1), (21, 1)]); + Session::roll_until_active_era(3); + let _ = staking_events_since_last_call(); + + let validator_weight = ErasValidatorIncentive::::get(2, alice).unwrap(); + let total_weight = ErasTotalValidatorWeight::::get(2); + let pot_allocation = ErasValidatorIncentiveAllocation::::get(2); + let validator_share = Perbill::from_rational(validator_weight, total_weight); + let expected_total_incentive = validator_share.mul_floor(pot_allocation); + + // WHEN: All pages paid out + make_all_reward_payment(2); + let events = staking_events_since_last_call(); + + // THEN: Validator receives ValidatorIncentivePaid events (one per page) + let incentive_events: Vec = events + .iter() + .filter_map(|e| match e { + Event::ValidatorIncentivePaid { validator_stash, amount, .. } + if *validator_stash == alice => + { + Some(*amount) + }, + _ => None, + }) + .collect(); + + // Sum of all page incentives should equal expected total (within rounding error) + let total_incentive_paid: Balance = incentive_events.iter().sum(); + assert!( + total_incentive_paid <= expected_total_incentive, + "Total incentive paid ({}) should not exceed expected ({})", + total_incentive_paid, + expected_total_incentive + ); + let diff = expected_total_incentive - total_incentive_paid; + assert!(diff < 5, "Rounding dust too large: {}", diff); + }); +} + +#[test] +fn validator_with_zero_reward_points_no_payout_triggered() { + ExtBuilder::default().build_and_execute(|| { + // GIVEN: Alice and Bob are validators, but only Bob has reward points + let alice = 11; // validator + let bob = 21; // validator + + setup_incentive_with_budget(45, 5); + + Eras::::reward_active_era(vec![(bob, 1)]); + Session::roll_until_active_era(2); + let _ = staking_events_since_last_call(); + + // WHEN: Payouts triggered + make_all_reward_payment(1); + let events = staking_events_since_last_call(); + + // THEN: Alice (no reward points) gets nothing + assert!(staker_reward_for(alice, &events).is_none()); + assert!(incentive_paid_for(alice, &events).is_none()); + + // Bob (has reward points) gets staker reward + assert!(staker_reward_for(bob, &events).is_some()); + }); +} + +#[test] +fn changing_budget_allocation_affects_rewards() { + ExtBuilder::default().build_and_execute(|| { + // GIVEN: Era 1 with no validator incentive + let alice = 11; // validator + + // Era 1: 50% staker, 0% incentive + setup_incentive_with_budget(50, 0); + + Eras::::reward_active_era(vec![(alice, 1), (21, 1)]); + Session::roll_until_active_era(2); + let _ = staking_events_since_last_call(); + + make_all_reward_payment(1); + let era1_events = staking_events_since_last_call(); + + assert!(staker_reward_for(alice, &era1_events).is_some()); + assert!(incentive_paid_for(alice, &era1_events).is_none()); + + // WHEN: Era 2 with validator incentive enabled (40% staker, 10% incentive) + setup_incentive_with_budget(40, 10); + + Eras::::reward_active_era(vec![(alice, 1), (21, 1)]); + Session::roll_until_active_era(3); + let _ = staking_events_since_last_call(); + + make_all_reward_payment(2); + let era2_events = staking_events_since_last_call(); + + // THEN: Era 2 has both staker and incentive rewards + assert!(staker_reward_for(alice, &era2_events).is_some()); + assert!(incentive_paid_for(alice, &era2_events).is_some()); + + assert_eq!(ErasValidatorIncentiveAllocation::::get(1), 0); + assert!(ErasValidatorIncentiveAllocation::::get(2) > 0); + }); +} + +#[test] +fn lowering_nominator_rewards_via_budget_adjustment() { + ExtBuilder::default().build_and_execute(|| { + let alice = 11; // validator + let bob = 101; // nominator + + // GIVEN: Era 1 baseline with 45% staker rewards + setup_incentive_with_budget(45, 0); + + Eras::::reward_active_era(vec![(alice, 1), (21, 1)]); + Session::roll_until_active_era(2); + let _ = staking_events_since_last_call(); + + make_all_reward_payment(1); + let era1_events = staking_events_since_last_call(); + let bob_reward_era1 = + staker_reward_for(bob, &era1_events).expect("Bob should receive reward"); + + // WHEN: Era 2 with reduced staker budget (30% staker, 15% incentive) + setup_incentive_with_budget(30, 15); + + Eras::::reward_active_era(vec![(alice, 1), (21, 1)]); + Session::roll_until_active_era(3); + let _ = staking_events_since_last_call(); + + make_all_reward_payment(2); + let era2_events = staking_events_since_last_call(); + let bob_reward_era2 = + staker_reward_for(bob, &era2_events).expect("Bob should receive reward"); + + // THEN: Staker budget 45% -> 30% is a 33% reduction. Check at least 30% decrease. + assert!(bob_reward_era2 < bob_reward_era1); + let decrease_pct = + Perbill::from_rational(bob_reward_era1 - bob_reward_era2, bob_reward_era1); + assert!(decrease_pct >= Perbill::from_percent(30)); + }); +} + +#[test] +fn extreme_budget_scenarios_validator_heavy() { + ExtBuilder::default().build_and_execute(|| { + // GIVEN: Very high validator incentive (40%), low staker budget (10%) + let alice = 11; // validator + let bob = 101; // nominator + + setup_incentive_with_budget(10, 40); + + // Era 2 has validator weights set by election + Session::roll_until_active_era(2); + Eras::::reward_active_era(vec![(alice, 1), (21, 1)]); + Session::roll_until_active_era(3); + let _ = staking_events_since_last_call(); + + // WHEN: Rewards distributed for era 2 + make_all_reward_payment(2); + let events = staking_events_since_last_call(); + + // THEN: Incentive (40% budget) should exceed staker reward (10% budget) + let alice_staker_reward = + staker_reward_for(alice, &events).expect("Alice should receive staker reward"); + let bob_staker_reward = + staker_reward_for(bob, &events).expect("Bob should receive staker reward"); + let alice_incentive = + incentive_paid_for(alice, &events).expect("Alice should receive incentive"); + + // Alice's total should be >2x Bob's (who only gets staker reward) + let alice_total = alice_staker_reward + alice_incentive; + assert!( + alice_total > bob_staker_reward * 2, + "Alice total ({alice_total}) should be >2x Bob's staker reward ({bob_staker_reward})" + ); + + // 40% incentive budget vs 10% staker budget + assert!( + alice_incentive > alice_staker_reward, + "Incentive ({alice_incentive}) should exceed staker reward ({alice_staker_reward})" + ); + }); +} + +#[test] +fn nominator_apy_decreases_as_validator_incentive_increases() { + ExtBuilder::default().build_and_execute(|| { + let alice = 11; // validator + let bob = 101; // nominator + + // Scenario 1: 50% staker, 0% incentive + setup_incentive_with_budget(50, 0); + Eras::::reward_active_era(vec![(alice, 1), (21, 1)]); + Session::roll_until_active_era(2); + let _ = staking_events_since_last_call(); + make_all_reward_payment(1); + let events1 = staking_events_since_last_call(); + let bob_s1 = staker_reward_for(bob, &events1).expect("Bob should receive reward"); + + // Scenario 2: 40% staker, 10% incentive + setup_incentive_with_budget(40, 10); + Eras::::reward_active_era(vec![(alice, 1), (21, 1)]); + Session::roll_until_active_era(3); + let _ = staking_events_since_last_call(); + make_all_reward_payment(2); + let events2 = staking_events_since_last_call(); + let bob_s2 = staker_reward_for(bob, &events2).expect("Bob should receive reward"); + + // Scenario 3: 25% staker, 25% incentive + setup_incentive_with_budget(25, 25); + Eras::::reward_active_era(vec![(alice, 1), (21, 1)]); + Session::roll_until_active_era(4); + let _ = staking_events_since_last_call(); + make_all_reward_payment(3); + let events3 = staking_events_since_last_call(); + let bob_s3 = staker_reward_for(bob, &events3).expect("Bob should receive reward"); + + // THEN: Nominator rewards decrease as validator incentive increases + assert!(bob_s1 > bob_s2, "S1: {bob_s1}, S2: {bob_s2}"); + assert!(bob_s2 > bob_s3, "S2: {bob_s2}, S3: {bob_s3}"); + + // 50% -> 25% staker budget means ~2x reduction in nominator reward + let ratio = bob_s1 as f64 / bob_s3 as f64; + assert!(ratio > 1.5, "Ratio: {ratio}"); + }); +} + +// ===== Tests for RewardDestination variants ===== + +#[test] +fn validator_incentive_with_account_destination() { + ExtBuilder::default().build_and_execute(|| { + // GIVEN: Validator with RewardDestination::Account(other) + let alice = 11; // validator + let reward_account = 999; // custom reward account + + assert_ok!(Staking::set_payee( + RuntimeOrigin::signed(alice), + RewardDestination::Account(reward_account) + )); + + setup_incentive_with_budget(45, 5); + + Session::roll_until_active_era(2); + Eras::::reward_active_era(vec![(alice, 1), (21, 1)]); + Session::roll_until_active_era(3); + let _ = staking_events_since_last_call(); + + let reward_account_balance_before = asset::total_balance::(&reward_account); + + // WHEN: Payout rewards + make_all_reward_payment(2); + let events = staking_events_since_last_call(); + + // THEN: Incentive event records the custom account destination + let (incentive, dest) = incentive_paid_details(alice, &events) + .expect("Validator should receive incentive"); + assert_eq!(dest, RewardDestination::Account(reward_account)); + + // Custom account receives both staker rewards and validator incentive + let reward_account_balance_after = asset::total_balance::(&reward_account); + let total_received = reward_account_balance_after - reward_account_balance_before; + assert!( + total_received >= incentive, + "Custom reward account should receive at least the incentive ({incentive}), got {total_received}", + ); + }); +} + +#[test] +fn validator_incentive_with_staked_destination() { + ExtBuilder::default().build_and_execute(|| { + // GIVEN: Validator with RewardDestination::Staked + let alice = 11; // validator + + assert_ok!(Staking::set_payee(RuntimeOrigin::signed(alice), RewardDestination::Staked)); + setup_incentive_with_budget(45, 5); + + Session::roll_until_active_era(2); + Eras::::reward_active_era(vec![(alice, 1), (21, 1)]); + Session::roll_until_active_era(3); + let _ = staking_events_since_last_call(); + + let alice_balance_before = asset::total_balance::(&alice); + + // WHEN: Payout rewards + make_all_reward_payment(2); + let events = staking_events_since_last_call(); + + // THEN: Incentive event records Staked destination and balance increases + let (incentive, dest) = + incentive_paid_details(alice, &events).expect("Validator should receive incentive"); + assert_eq!(dest, RewardDestination::Staked); + assert!(incentive > 0, "Incentive amount should be non-zero"); + + let alice_balance_after = asset::total_balance::(&alice); + assert!( + alice_balance_after > alice_balance_before, + "Alice balance should increase from incentive payout" + ); + }); +} + +// ===== Tests for edge cases ===== + +#[test] +fn validator_chills_before_payout() { + ExtBuilder::default().build_and_execute(|| { + // GIVEN: Validator earns weight in era 2, then chills before payout + let alice = 11; // validator + + setup_incentive_with_budget(45, 5); + + Session::roll_until_active_era(2); + Eras::::reward_active_era(vec![(alice, 1), (21, 1)]); + Session::roll_until_active_era(3); + let _ = staking_events_since_last_call(); + + assert!(ErasValidatorIncentive::::get(2, alice).is_some()); + + // WHEN: Alice chills before claiming payout + assert_ok!(Staking::chill(RuntimeOrigin::signed(alice))); + assert!(!Validators::::contains_key(&alice)); + + // THEN: Alice can still claim incentive for era 2 + make_all_reward_payment(2); + let events = staking_events_since_last_call(); + + assert!( + incentive_paid_for(alice, &events).is_some(), + "Chilled validator should still receive incentive for era they were active" + ); + }); +} + +#[test] +fn all_validators_zero_reward_points() { + ExtBuilder::default().build_and_execute(|| { + // GIVEN: Validators with self-stake weight but zero reward points + setup_incentive_with_budget(45, 5); + + // Don't call reward_active_era — no reward points + Session::roll_until_active_era(2); + let _ = staking_events_since_last_call(); + + // WHEN: Try to claim payouts + make_all_reward_payment(1); + let events = staking_events_since_last_call(); + + // THEN: No incentive paid + let any_incentive = + events.iter().any(|e| matches!(e, Event::ValidatorIncentivePaid { .. })); + assert!(!any_incentive, "No incentive when no validators earned reward points"); + }); +} + +#[test] +fn validator_payee_changes_before_payout() { + ExtBuilder::default().build_and_execute(|| { + // GIVEN: Validator with payee set to old_account during weight calculation + let alice = 11; // validator + let old_account = 888; + let new_account = 999; + + assert_ok!(Staking::set_payee( + RuntimeOrigin::signed(alice), + RewardDestination::Account(old_account) + )); + + setup_incentive_with_budget(45, 5); + + Session::roll_until_active_era(2); + Eras::::reward_active_era(vec![(alice, 1), (21, 1)]); + Session::roll_until_active_era(3); + let _ = staking_events_since_last_call(); + + // WHEN: Payee changes to new_account before payout + assert_ok!(Staking::set_payee( + RuntimeOrigin::signed(alice), + RewardDestination::Account(new_account) + )); + + let old_balance_before = asset::total_balance::(&old_account); + let new_balance_before = asset::total_balance::(&new_account); + + make_all_reward_payment(2); + let events = staking_events_since_last_call(); + + // THEN: Incentive uses payee at payout time (new_account) + let (incentive, dest) = + incentive_paid_details(alice, &events).expect("Validator should receive incentive"); + assert_eq!(dest, RewardDestination::Account(new_account)); + + assert_eq!(asset::total_balance::(&old_account), old_balance_before); + let new_balance_after = asset::total_balance::(&new_account); + assert!( + new_balance_after - new_balance_before >= incentive, + "New account should receive at least the incentive amount" + ); + }); +} + +#[test] +fn very_small_self_stake_weight() { + ExtBuilder::default().build_and_execute(|| { + // GIVEN: Validator with self-stake of 1000 (from mock), below optimum of 30_000. + // w(1000) = √1000 ≈ 31, so weight is small but non-zero. + let alice = 11; // validator + + setup_incentive_with_budget(45, 5); + + Session::roll_until_active_era(2); + Eras::::reward_active_era(vec![(alice, 1), (21, 1)]); + Session::roll_until_active_era(3); + let _ = staking_events_since_last_call(); + + // Verify weight was calculated (√1000 ≈ 31) + let weight = ErasValidatorIncentive::::get(2, alice).unwrap(); + assert_eq!(weight, 31); + + // WHEN: Payout + make_all_reward_payment(2); + let events = staking_events_since_last_call(); + + // THEN: Incentive is paid (small but non-zero) + assert!(incentive_paid_for(alice, &events).is_some()); + }); +} + +/// Finds the held incentive amount from events. +fn incentive_held_for(stash: AccountId, events: &[Event]) -> Option { + events.iter().find_map(|e| match e { + Event::ValidatorIncentiveHeld { validator_stash, amount, .. } + if *validator_stash == stash => + { + Some(*amount) + }, + _ => None, + }) +} + +/// Finds vesting conversion details from events. +fn vesting_converted_for(stash: AccountId, events: &[Event]) -> Option<(Balance, Balance)> { + events.iter().find_map(|e| match e { + Event::IncentiveVestingConverted { validator_stash, liquid, vested, .. } + if *validator_stash == stash => + { + Some((*liquid, *vested)) + }, + _ => None, + }) +} + +#[test] +fn incentive_held_when_vesting_enabled() { + ExtBuilder::default().build_and_execute(|| { + // GIVEN: Vesting enabled, non-batch-boundary era + let alice = 11; // validator + setup_vesting_params(150, 5); + setup_incentive_with_budget(45, 5); + + // Advance to era 2 so validator weights are set by election + Session::roll_until_active_era(2); + Eras::::reward_active_era(vec![(alice, 1), (21, 1)]); + Session::roll_until_active_era(3); + let _ = staking_events_since_last_call(); + + let alice_balance_before = asset::total_balance::(&alice); + assert_eq!(asset::incentive_held::(&alice), 0); + + // WHEN: Payout era 2 (not a batch boundary: 2 % 3 != 0) + make_all_reward_payment(2); + let events = staking_events_since_last_call(); + + // THEN: Incentive is held, not paid liquid + let held_amount = incentive_held_for(alice, &events).expect("should emit held event"); + assert!(held_amount > 0); + assert_eq!(asset::incentive_held::(&alice), held_amount); + + // Balance increased (transfer from pot) but part is held + let alice_balance_after = asset::total_balance::(&alice); + assert!(alice_balance_after > alice_balance_before); + + // No ValidatorIncentivePaid event (that's for liquid path) + assert!(incentive_paid_for(alice, &events).is_none()); + // No conversion event (not a batch boundary) + assert!(vesting_converted_for(alice, &events).is_none()); + }); +} + +#[test] +fn incentive_accumulates_and_converts_at_batch_boundary() { + ExtBuilder::default().build_and_execute(|| { + // GIVEN: Vesting enabled with BondingDuration = 3 + // Batch boundaries at eras 3, 6, 9, ... + let alice = 11; // validator + setup_vesting_params(150, 5); + setup_incentive_with_budget(45, 5); + + // Advance through eras 2-6, rewarding each. Era 6 is a batch boundary (6 % 3 == 0). + for target_era in 2..=6 { + Session::roll_until_active_era(target_era); + Eras::::reward_active_era(vec![(alice, 1), (21, 1)]); + } + Session::roll_until_active_era(7); + let _ = staking_events_since_last_call(); + + // Pay eras 2-5, hold accumulates (non-boundary eras for 2,4,5; era 3 is boundary + // but we claim it with pages left from other eras still unclaimed) + make_all_reward_payment(2); + make_all_reward_payment(4); + make_all_reward_payment(5); + // Also pay era 3 — batch boundary but hold already accumulated from era 2 + make_all_reward_payment(3); + + // After era 3 payout, conversion should have triggered for eras 2+3 incentive. + // Remaining from eras 4+5 is still held. Flush events before era 6 payout. + let _ = staking_events_since_last_call(); + + // WHEN: Payout era 6 (batch boundary, 6 % 3 == 0) + make_all_reward_payment(6); + let events = staking_events_since_last_call(); + + // THEN: Conversion triggered for eras 4+5+6 incentive + let (liquid, vested) = vesting_converted_for(alice, &events) + .expect("Should emit IncentiveVestingConverted at era 6"); + + // Retroactive unlock = 30% (BondingDuration=3 / vesting_eras=10) + let total_converted = liquid + vested; + let expected_liquid = Perbill::from_rational(3u32, 10u32).mul_floor(total_converted); + assert_eq!(liquid, expected_liquid); + assert_eq!(vested, total_converted - expected_liquid); + + // Hold should be fully released after conversion + assert_eq!(asset::incentive_held::(&alice), 0); + }); +} + +#[test] +fn vesting_duration_zero_pays_liquid() { + ExtBuilder::default().build_and_execute(|| { + // GIVEN: VestingDuration = 0 (default), so no hold, pay liquid directly. + let alice = 11; // validator + setup_incentive_with_budget(45, 5); + + Session::roll_until_active_era(2); + Eras::::reward_active_era(vec![(alice, 1), (21, 1)]); + Session::roll_until_active_era(3); + let _ = staking_events_since_last_call(); + + // WHEN: Payout era 2 + make_all_reward_payment(2); + let events = staking_events_since_last_call(); + + // THEN: Paid liquid via ValidatorIncentivePaid, no hold + assert!(incentive_paid_for(alice, &events).is_some()); + assert_eq!(asset::incentive_held::(&alice), 0); + assert!(vesting_converted_for(alice, &events).is_none()); + }); +} + +#[test] +fn multiple_batch_cycles() { + ExtBuilder::default().build_and_execute(|| { + // GIVEN: Vesting enabled, run through two batch cycles + let alice = 11; // validator + setup_vesting_params(150, 5); + setup_incentive_with_budget(45, 5); + + // First cycle: eras 2-3. Era 3 is first batch boundary (3 % 3 == 0). + Session::roll_until_active_era(2); + Eras::::reward_active_era(vec![(alice, 1), (21, 1)]); + Session::roll_until_active_era(3); + Eras::::reward_active_era(vec![(alice, 1), (21, 1)]); + Session::roll_until_active_era(4); + let _ = staking_events_since_last_call(); + + make_all_reward_payment(2); + make_all_reward_payment(3); + let events1 = staking_events_since_last_call(); + + let (liquid1, vested1) = + vesting_converted_for(alice, &events1).expect("Should convert at era 3"); + assert!(liquid1 > 0); + assert!(vested1 > 0); + assert_eq!(asset::incentive_held::(&alice), 0); + + // Second cycle: eras 4-6. Era 6 is second batch boundary. + for target_era in 4..=6 { + Session::roll_until_active_era(target_era); + Eras::::reward_active_era(vec![(alice, 1), (21, 1)]); + } + Session::roll_until_active_era(7); + let _ = staking_events_since_last_call(); + + make_all_reward_payment(4); + make_all_reward_payment(5); + make_all_reward_payment(6); + let events2 = staking_events_since_last_call(); + + // THEN: Second conversion also happens at era 6 + let (liquid2, vested2) = + vesting_converted_for(alice, &events2).expect("Should convert at era 6"); + assert!(liquid2 > 0); + assert!(vested2 > 0); + assert_eq!(asset::incentive_held::(&alice), 0); + + // Both cycles apply the same 30% formula: liquid = floor(held * 3/10) + let total1 = liquid1 + vested1; + let total2 = liquid2 + vested2; + let expected_liquid1 = Perbill::from_rational(3u32, 10u32).mul_floor(total1); + let expected_liquid2 = Perbill::from_rational(3u32, 10u32).mul_floor(total2); + assert_eq!(liquid1, expected_liquid1); + assert_eq!(liquid2, expected_liquid2); + }); +} + +#[test] +fn withdraw_unbonded_settles_incentive_hold() { + ExtBuilder::default().build_and_execute(|| { + // GIVEN: Validator with incentive held after payout. + let alice = 11; // validator + let reward_account = 999; + setup_vesting_params(150, 5); + setup_incentive_with_budget(45, 5); + + Session::roll_until_active_era(2); + Eras::::reward_active_era(vec![(alice, 1), (21, 1)]); + Session::roll_until_active_era(3); + make_all_reward_payment(2); + + let held = asset::incentive_held::(&alice); + assert!(held > 0, "incentive should be held after payout"); + + // Helper: unbond and withdraw alice. + let unbond_and_withdraw = || { + assert_ok!(Staking::chill(RuntimeOrigin::signed(alice))); + let bonded = Staking::ledger(11.into()).unwrap().active; + assert_ok!(Staking::unbond(RuntimeOrigin::signed(alice), bonded)); + Session::roll_until_active_era(3 + BondingDuration::get() + 1); + let _ = staking_events_since_last_call(); + assert_ok!(Staking::withdraw_unbonded(RuntimeOrigin::signed(alice), 0)); + staking_events_since_last_call() + }; + + // WHEN/THEN: Payee = Stash — full held amount vested on exit (no retroactive unlock). + hypothetically!({ + let events = unbond_and_withdraw(); + assert!(Staking::ledger(11.into()).is_err()); + assert_eq!(asset::incentive_held::(&alice), 0); + let (liquid, vested) = + vesting_converted_for(alice, &events).expect("should emit conversion event"); + assert_eq!(liquid, 0, "no retroactive unlock on exit"); + assert_eq!(vested, held, "full amount vested on exit"); + }); + + // WHEN/THEN: Payee = Account(other) — hold on reward_account is settled. + let ed = asset::existential_deposit::(); + >::mint_into(&reward_account, ed) + .unwrap(); + assert_ok!(Staking::set_payee( + RuntimeOrigin::signed(alice), + RewardDestination::Account(reward_account) + )); + // Hold migrated to reward_account by set_payee. + assert_eq!(asset::incentive_held::(&reward_account), held); + + let _events = unbond_and_withdraw(); + assert!(Staking::ledger(11.into()).is_err()); + assert_eq!(asset::incentive_held::(&reward_account), 0); + }); +} + +#[test] +fn vesting_duration_in_eras_equals_bonding_releases_all_liquid() { + ExtBuilder::default().build_and_execute(|| { + // GIVEN: vesting_eras == BondingDuration → 100% unlocked at conversion + let alice = 11; // validator + VestingDurationBlocks::set(45); // 3 eras * 15 blocks/era + BlocksPerSession::set(5); // vesting_eras = 45 / (5*3) = 3, same as BondingDuration + setup_incentive_with_budget(45, 5); + + // Advance to era 2+ for validator weights, accumulate through era 3 (batch boundary) + Session::roll_until_active_era(2); + Eras::::reward_active_era(vec![(alice, 1), (21, 1)]); + Session::roll_until_active_era(3); + Eras::::reward_active_era(vec![(alice, 1), (21, 1)]); + Session::roll_until_active_era(4); + let _ = staking_events_since_last_call(); + + // WHEN: Payout era 2 and 3 (era 3 = batch boundary, vesting_eras == BondingDuration) + make_all_reward_payment(2); + make_all_reward_payment(3); + let events = staking_events_since_last_call(); + + // THEN: Everything released liquid (no vesting schedule created) + let converted = events.iter().find_map(|e| match e { + Event::IncentiveVestingConverted { validator_stash, liquid, vested } + if *validator_stash == alice => + { + Some((*liquid, *vested)) + }, + _ => None, + }); + assert!( + converted.is_some(), + "Should release all liquid when vesting_eras == BondingDuration" + ); + let (liquid, vested) = converted.unwrap(); + assert!(liquid > 0, "liquid portion should be non-zero"); + assert_eq!(vested, 0, "vested should be zero when vesting_eras == BondingDuration"); + assert_eq!(asset::incentive_held::(&alice), 0); + }); +} + +#[test] +fn payee_change_migrates_incentive_hold() { + ExtBuilder::default().build_and_execute(|| { + // GIVEN: Validator with incentive held on Account(other). + let alice = 11; // validator + let other = 888; + let another = 999; + setup_vesting_params(150, 5); + setup_incentive_with_budget(45, 5); + + let ed = asset::existential_deposit::(); + >::mint_into(&other, ed).unwrap(); + >::mint_into(&another, ed).unwrap(); + + assert_ok!(Staking::set_payee( + RuntimeOrigin::signed(alice), + RewardDestination::Account(other) + )); + + Session::roll_until_active_era(2); + Eras::::reward_active_era(vec![(alice, 1), (21, 1)]); + Session::roll_until_active_era(3); + make_all_reward_payment(2); + + let held = asset::incentive_held::(&other); + assert!(held > 0); + + // WHEN/THEN: Account(other) → Account(another) migrates the hold. + hypothetically!({ + assert_ok!(Staking::set_payee( + RuntimeOrigin::signed(alice), + RewardDestination::Account(another) + )); + assert_eq!(asset::incentive_held::(&other), 0); + assert_eq!(asset::incentive_held::(&another), held); + }); + + // WHEN/THEN: Account(other) → Stash migrates hold to stash (which has a staking hold too). + hypothetically!({ + assert_ok!(Staking::set_payee(RuntimeOrigin::signed(alice), RewardDestination::Stash)); + assert_eq!(asset::incentive_held::(&other), 0); + assert_eq!(asset::incentive_held::(&alice), held); + }); + + // WHEN/THEN: Account(other) → None blocked while hold exists. + assert_noop!( + Staking::set_payee(RuntimeOrigin::signed(alice), RewardDestination::None), + Error::::IncentiveVestingPending + ); + }); +} + +#[test] +fn force_unstake_settles_incentive_hold() { + ExtBuilder::default().build_and_execute(|| { + // GIVEN: Validator with held incentive. + let alice = 11; + setup_vesting_params(150, 5); + setup_incentive_with_budget(45, 5); + + Session::roll_until_active_era(2); + Eras::::reward_active_era(vec![(alice, 1), (21, 1)]); + Session::roll_until_active_era(3); + make_all_reward_payment(2); + + let held = asset::incentive_held::(&alice); + assert!(held > 0); + let balance_before = asset::total_balance::(&alice); + + // WHEN: Root force-unstakes. + assert_ok!(Staking::force_unstake(RuntimeOrigin::root(), alice, 0)); + + // THEN: Hold cleared, stash killed, balance preserved. + assert_eq!(asset::incentive_held::(&alice), 0); + assert!(Staking::ledger(11.into()).is_err()); + assert!(asset::total_balance::(&alice) >= balance_before); + }); +} + +#[test] +fn multi_page_election_does_not_overwrite_incentive_weight() { + // Validator incentive weight must be written only once + ExtBuilder::default().exposures_page_size(1).build_and_execute(|| { + let alice = 11; // validator + setup_incentive_config(); + + Session::roll_to_next_session(); + let planned_era = Rotator::::planned_era(); + + // Scenario 1: own-stake arrives on page 1, page 2 has only nominators. + hypothetically!({ + let page1 = bounded_vec![( + alice, + Exposure { + total: 1000 + 250, + own: 1000, + others: vec![IndividualExposure { who: 101, value: 250 }] + }, + )]; + EraElectionPlanner::::store_stakers_info(page1, planned_era); + + let weight = ErasValidatorIncentive::::get(planned_era, alice).unwrap(); + let total = ErasTotalValidatorWeight::::get(planned_era); + assert!(weight > 0); + + let page2 = bounded_vec![( + alice, + Exposure { + total: 250, + own: 0, + others: vec![IndividualExposure { who: 102, value: 250 }] + }, + )]; + EraElectionPlanner::::store_stakers_info(page2, planned_era); + + // Weight and total unchanged — page 2 must not overwrite with 0. + assert_eq!(ErasValidatorIncentive::::get(planned_era, alice).unwrap(), weight); + assert_eq!(ErasTotalValidatorWeight::::get(planned_era), total); + }); + + // Scenario 2: own-stake arrives on page 2 (not page 1). + hypothetically!({ + let page1 = bounded_vec![( + alice, + Exposure { + total: 250, + own: 0, + others: vec![IndividualExposure { who: 101, value: 250 }] + }, + )]; + EraElectionPlanner::::store_stakers_info(page1, planned_era); + + // After page 1: no weight stored since own hasn't arrived yet. + assert_eq!(ErasValidatorIncentive::::get(planned_era, alice), None); + + let page2 = bounded_vec![( + alice, + Exposure { + total: 1000 + 250, + own: 1000, + others: vec![IndividualExposure { who: 102, value: 250 }] + }, + )]; + EraElectionPlanner::::store_stakers_info(page2, planned_era); + + // After page 2: weight should reflect own = 1000 from the overview. + let weight = ErasValidatorIncentive::::get(planned_era, alice).unwrap(); + assert!(weight > 0, "Weight should be updated when own-stake arrives on page 2"); + assert_eq!(ErasTotalValidatorWeight::::get(planned_era), weight); + }); + }); +} diff --git a/substrate/frame/staking-async/src/weights.rs b/substrate/frame/staking-async/src/weights.rs index 7167ec12e2338..52295d578dd01 100644 --- a/substrate/frame/staking-async/src/weights.rs +++ b/substrate/frame/staking-async/src/weights.rs @@ -84,6 +84,8 @@ pub trait WeightInfo { fn chill_other() -> Weight; fn force_apply_min_commission() -> Weight; fn set_min_commission() -> Weight; + fn set_max_commission() -> Weight; + fn set_validator_self_stake_incentive_config() -> Weight; fn restore_ledger() -> Weight; fn migrate_currency() -> Weight; fn apply_slash(n: u32, ) -> Weight; @@ -722,6 +724,14 @@ impl WeightInfo for SubstrateWeight { Weight::from_parts(4_000_000, 0) .saturating_add(T::DbWeight::get().writes(1_u64)) } + // TODO(ank4n): Run benchmarks + fn set_max_commission() -> Weight { + Self::set_min_commission() + } + // TODO(ank4n): Run benchmarks + fn set_validator_self_stake_incentive_config() -> Weight { + T::DbWeight::get().reads_writes(2, 3) + } /// Storage: `System::Account` (r:1 w:0) /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) /// Storage: `Staking::VirtualStakers` (r:1 w:0) @@ -1634,6 +1644,14 @@ impl WeightInfo for () { Weight::from_parts(4_000_000, 0) .saturating_add(RocksDbWeight::get().writes(1_u64)) } + // TODO(ank4n): Run benchmarks + fn set_max_commission() -> Weight { + Self::set_min_commission() + } + // TODO(ank4n): Run benchmarks + fn set_validator_self_stake_incentive_config() -> Weight { + RocksDbWeight::get().reads_writes(2, 3) + } /// Storage: `System::Account` (r:1 w:0) /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) /// Storage: `Staking::VirtualStakers` (r:1 w:0) diff --git a/substrate/frame/staking/src/lib.rs b/substrate/frame/staking/src/lib.rs index c510f3952a5cc..9ba5509eb16dc 100644 --- a/substrate/frame/staking/src/lib.rs +++ b/substrate/frame/staking/src/lib.rs @@ -980,6 +980,7 @@ impl SessionInterface for () { /// Adaptor to turn a `PiecewiseLinear` curve definition into an `EraPayout` impl, used for /// backwards compatibility. pub struct ConvertCurve(core::marker::PhantomData); +#[allow(deprecated)] impl sp_staking::EraPayout for ConvertCurve where Balance: AtLeast32BitUnsigned + Clone + Copy, diff --git a/substrate/frame/staking/src/mock.rs b/substrate/frame/staking/src/mock.rs index 0826cec3b4057..62a0b1abffa4e 100644 --- a/substrate/frame/staking/src/mock.rs +++ b/substrate/frame/staking/src/mock.rs @@ -34,6 +34,8 @@ use frame_system::{EnsureRoot, EnsureSignedBy}; use sp_core::ConstBool; use sp_io; use sp_runtime::{curve::PiecewiseLinear, testing::UintAuthorityId, traits::Zero, BuildStorage}; +#[allow(deprecated)] +use sp_staking::EraPayout; use sp_staking::{ offence::{OffenceDetails, OnOffenceHandler}, OnStakingUpdate, StakingAccount, @@ -686,6 +688,7 @@ pub(crate) fn start_active_era(era_index: EraIndex) { assert_eq!(current_era(), active_era()); } +#[allow(deprecated)] pub(crate) fn current_total_payout_for_duration(duration: u64) -> Balance { let (payout, _rest) = ::EraPayout::era_payout( pallet_staking::ErasTotalStake::::get(active_era()), @@ -696,6 +699,7 @@ pub(crate) fn current_total_payout_for_duration(duration: u64) -> Balance { payout } +#[allow(deprecated)] pub(crate) fn maximum_payout_for_duration(duration: u64) -> Balance { let (payout, rest) = ::EraPayout::era_payout( pallet_staking::ErasTotalStake::::get(active_era()), diff --git a/substrate/frame/staking/src/pallet/impls.rs b/substrate/frame/staking/src/pallet/impls.rs index df5951dbaf859..e9ee4013d205e 100644 --- a/substrate/frame/staking/src/pallet/impls.rs +++ b/substrate/frame/staking/src/pallet/impls.rs @@ -41,10 +41,12 @@ use sp_runtime::{ }, ArithmeticError, DispatchResult, Perbill, Percent, }; +#[allow(deprecated)] +use sp_staking::EraPayout; use sp_staking::{ currency_to_vote::CurrencyToVote, offence::{OffenceDetails, OnOffenceHandler}, - EraIndex, EraPayout, OnStakingUpdate, Page, SessionIndex, Stake, + EraIndex, OnStakingUpdate, Page, SessionIndex, Stake, StakingAccount::{self, Controller, Stash}, StakingInterface, }; @@ -578,8 +580,8 @@ impl Pallet { let staked = ErasTotalStake::::get(&active_era.index); let issuance = asset::total_issuance::(); - let (validator_payout, remainder) = - T::EraPayout::era_payout(staked, issuance, era_duration); + #[allow(deprecated)] + let (validator_payout, remainder) = T::EraPayout::era_payout(staked, issuance, era_duration); let total_payout = validator_payout.saturating_add(remainder); let max_staked_rewards = diff --git a/substrate/frame/staking/src/pallet/mod.rs b/substrate/frame/staking/src/pallet/mod.rs index b890fc0bc0237..29812211236d3 100644 --- a/substrate/frame/staking/src/pallet/mod.rs +++ b/substrate/frame/staking/src/pallet/mod.rs @@ -231,6 +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] + #[allow(deprecated)] type EraPayout: sp_staking::EraPayout>; /// Something that can estimate the next session change, accurately or as a best effort diff --git a/substrate/primitives/staking/src/lib.rs b/substrate/primitives/staking/src/lib.rs index d58aa4b52c814..bce614527bfdc 100644 --- a/substrate/primitives/staking/src/lib.rs +++ b/substrate/primitives/staking/src/lib.rs @@ -743,6 +743,7 @@ pub trait EraPayout { ) -> (Balance, Balance); } +/// Default implementation that returns zero rewards. impl EraPayout for () { fn era_payout( _total_staked: Balance,