feat: Performance-Based Validator Rewards and Inflation Scaling (#306)

## Summary

Building on #304, this PR implements two complementary mechanisms to
improve validator incentives and network performance:

1. **Performance-Based Validator Rewards** (session-level)
2. **Inflation Scaling** (era-level)

## Reward Model Comparison

### Old Model (main branch) vs New Model

| Metric | Old Model (20 pts/block) | New Model (320 pts/block pool) |
|--------|--------------------------|--------------------------------|
| **Per Block** | Author: 20 pts, Others: 0 | Author: ~196 pts, Others:
~4 pts each |
| **Formula** | Direct author reward | 60% authoring + 30% liveness +
10% base |
| **Per Session** (600 blocks, 32 validators) | 12,000 total pts |
192,000 total pts |
| **Per Validator/Session** (uniform) | ~375 pts | ~6,000 pts |
| **Per Validator/Era** (6 sessions) | ~2,250 pts | ~36,000 pts |
| **Offline Validator** | 0 pts | ~600 pts/session (base only) |
| **Over-performer (150% blocks)** | 150% of fair share | Up to 130%
reward (soft cap) |

### Key Differences
- **Pool-based**: New model adds 320 points to a shared pool per block,
distributed via formula
- **Liveness rewarded**: 30% of rewards go to validators who are online
(heartbeat OR block authorship)
- **Base guarantee**: 10% ensures all active validators receive minimum
rewards
- **Soft cap**: Prevents extreme over-performance rewards (max 150% of
fair share credited)

## Performance-Based Validator Rewards

Introduces a **60/30/10 reward formula** that rewards validators based
on their contribution during each session:

- **60%** based on block production (with soft cap allowing up to 150%
of fair share)
- **30%** based on liveness (ImOnline heartbeat OR block authorship)
- **10%** guaranteed base reward for all active validators

### Key Features
- Tracks individual validator block authorship per session
- Calculates fair share dynamically: `fair_share = total_blocks /
total_validator_count`
- Fair share uses **total** validator count (including whitelisted)
since all validators occupy block slots
- **Soft cap**: Over-performers can earn credit up to 150% of their fair
share (configurable via `OperatorRewardsFairShareCap` at 50%)
- With 60% BlockAuthoringWeight, this gives over-performers up to **30%
bonus reward**
- **BasePointsPerBlock**: Defines points added to pool per block
produced (default: 320)
- Integrates with SessionManager for automatic point awards at session
end
- Excludes whitelisted validators from rewards (but includes them in
fair share calculation)
- Slashing check disabled but hook retained for future use
- Points accumulate across sessions within an era

### Dynamic Parameters (Governance-Adjustable)
- `OperatorRewardsBlockAuthoringWeight`: Weight for block authoring
(default: 60%)
- `OperatorRewardsLivenessWeight`: Weight for liveness (default: 30%)
- `OperatorRewardsFairShareCap`: Soft cap percentage above fair share
(default: 50%)

## Inflation Scaling

Implements **dynamic inflation scaling** that adjusts total inflation
based on network block production:

- **Minimum**: 20% of base inflation (network halt protection)
- **Maximum**: 100% of base inflation (caps at expected blocks)
- **Linear scaling** between minimum and maximum based on performance

### Scaling Examples
- 0% blocks produced → 20% inflation (safety floor)
- 50% blocks produced → 60% inflation  
- 100% blocks produced → 100% inflation
- >100% blocks produced → capped at 100%

### Configuration
- **ExpectedBlocksPerEra**: Computed as `SessionsPerEra ×
EpochDurationInBlocks`
- **MinInflationPercent**: 20%
- **MaxInflationPercent**: 100%

## Combined Effect

These mechanisms work together to create a comprehensive incentive
structure:

1. **Session rewards** encourage individual validator performance and
uptime
2. **Era inflation scaling** incentivizes collective network health
3. **Minimum inflation floor** protects against network halt
4. **Soft cap** allows over-performers to earn up to 30% bonus while
preventing extreme centralization

## Implementation Details

### Pallet Changes
- Add `BlocksAuthoredInSession` storage for per-validator tracking
- Add `BlocksProducedInEra` storage for total network tracking (cleaned
up with HistoryDepth)
- Add `note_block_author()` function called on block production
- Add `award_session_performance_points()` function with configurable
60/30/10 formula
- Add `calculate_scaled_inflation()` function for era-level scaling
- Update `on_era_end()` to use scaled inflation
- Integrate with SessionManager via wrapper types
- Defensive weight validation: proportionally scales if sum > 100%

### Configuration Parameters
- `ValidatorSet`: Provides active validator list
- `LivenessCheck`: Uses `ImOnline::is_online()` (heartbeat OR block
authorship)
- `SlashingCheck`: Integration with slashing pallet (currently disabled)
- `BasePointsPerBlock`: Points added to pool per block (default: 320)
- `BlockAuthoringWeight`: Dynamic parameter (60%)
- `LivenessWeight`: Dynamic parameter (30%)
- `FairShareCap`: Dynamic parameter (50%)
- `ExpectedBlocksPerEra`: Computed from session/epoch config
- `MinInflationPercent`: 20%
- `MaxInflationPercent`: 100%

### Runtime Updates
- Full configuration added to mainnet, testnet, and stagenet runtimes
- Dynamic parameters added to `runtime_params.rs` for governance control
- Uses `prod_or_fast!()` macro for environment-specific parameters
- `ValidatorIsOnline` uses `ImOnline::is_online()` for accurate liveness
detection

## Testing

- **76 tests passing** 
- Comprehensive coverage of both mechanisms

### Test Coverage
- Inflation scaling at 0%, 25%, 50%, 75%, 100%, >100% blocks
- Session performance with 60/30/10 formula
- Fair share calculations with soft cap (150%)
- Whitelisted validator exclusion from rewards (with correct fair share
using total count)
- Total points verification (sum of individual = total)
- Whitelisted over-producer scenarios
- Overflow protection (large block counts, near-u32::MAX)
- End-to-end session to era flow
- MockLivenessCheck mirrors ImOnline behavior (block authorship =
online)
- Multiple eras with different performance levels
- Edge cases (zero participation, single validator, large numbers)
- BlocksProducedInEra cleanup on era start

## ⚠️ Breaking Changes ⚠️

### Reward Distribution

Previously, rewards were distributed equally among all validators
regardless of their contribution. Now:

- **Performance-based**: Validators earn rewards proportional to their
block production (60%), liveness (30%), and a guaranteed base (10%)
- **Pool-based**: `BasePointsPerBlock` defines points added to pool per
block (320), distributed via formula
- **Fair share uses total validators**: Ensures non-whitelisted aren't
penalized for whitelisted validators' block slots
- **Soft cap**: Block production rewards allow up to 150% of fair share
(50% cap = 30% bonus with 60% weight)
- **Slashing check disabled**: Hook retained for future use, but
currently not applied

### Inflation Mechanism

Previously, the full calculated inflation was minted each era. Now:

- **Scaled by performance**: Total inflation scales between 20%-100%
based on actual blocks produced vs expected
- **Safety floor**: Even with zero blocks, 20% of inflation is still
minted to prevent complete halt
- **Network incentive**: Collective block production directly impacts
total rewards available

### Pallet Configuration

The `pallet-external-validators-rewards` Config now requires additional
types:
- `BlockAuthoringWeight`, `LivenessWeight`, `FairShareCap` for reward
formula
- `ValidatorSet`, `LivenessCheck`, `SlashingCheck` for validator
tracking
- `ExpectedBlocksPerEra`, `MinInflationPercent`, `MaxInflationPercent`
for inflation scaling

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Ahmad Kaouk <56095276+ahmadkaouk@users.noreply.github.com>
This commit is contained in:
Steve Degosserie 2025-12-16 16:27:03 +01:00 committed by GitHub
parent d5e64d59e8
commit 67f375860b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 3627 additions and 75 deletions

1
operator/Cargo.lock generated
View file

@ -8978,6 +8978,7 @@ dependencies = [
"frame-support",
"frame-system",
"log",
"pallet-authorship",
"pallet-balances",
"pallet-external-validators",
"pallet-session",

View file

@ -202,7 +202,7 @@ pub mod pallet {
/// All slashing events on validators, mapped by era to the highest slash proportion
/// and slash value of the era.
#[pallet::storage]
pub(crate) type ValidatorSlashInEra<T: Config> =
pub type ValidatorSlashInEra<T: Config> =
StorageDoubleMap<_, Twox64Concat, EraIndex, Twox64Concat, T::AccountId, Perbill>;
/// A mapping from still-bonded eras to the first session index of that era.

View file

@ -26,6 +26,7 @@ sp-std = { workspace = true }
frame-benchmarking = { workspace = true }
pallet-authorship = { workspace = true }
pallet-balances = { workspace = true, optional = true }
pallet-external-validators = { workspace = true }
pallet-session = { workspace = true, features = [ "historical" ] }
@ -49,7 +50,9 @@ std = [
"frame-support/std",
"frame-system/std",
"log/std",
"pallet-authorship/std",
"pallet-balances/std",
"pallet-external-validators/std",
"pallet-session/std",
"pallet-timestamp/std",
"parity-scale-codec/std",
@ -70,6 +73,7 @@ runtime-benchmarks = [
"frame-support/runtime-benchmarks",
"frame-system/runtime-benchmarks",
"pallet-balances/runtime-benchmarks",
"pallet-external-validators/runtime-benchmarks",
"pallet-timestamp/runtime-benchmarks",
"polkadot-primitives/runtime-benchmarks",
"runtime-parachains/runtime-benchmarks",
@ -81,7 +85,9 @@ runtime-benchmarks = [
try-runtime = [
"frame-support/try-runtime",
"frame-system/try-runtime",
"pallet-authorship/try-runtime",
"pallet-balances?/try-runtime",
"pallet-external-validators/try-runtime",
"pallet-session/try-runtime",
"pallet-timestamp/try-runtime",
"runtime-parachains/try-runtime",

View file

@ -35,21 +35,37 @@ pub use pallet::*;
use {
crate::types::{EraRewardsUtils, HandleInflation, SendMessage},
frame_support::traits::{Defensive, Get, ValidatorSet},
frame_support::traits::{Contains, Defensive, Get, ValidatorSet},
pallet_external_validators::traits::{ExternalIndexProvider, OnEraEnd, OnEraStart},
parity_scale_codec::Encode,
polkadot_primitives::ValidatorIndex,
runtime_parachains::session_info,
snowbridge_merkle_tree::{merkle_proof, merkle_root, verify_proof, MerkleProof},
sp_core::H256,
sp_runtime::traits::{Hash, Zero},
sp_runtime::{
traits::{Hash, Zero},
Perbill,
},
sp_staking::SessionIndex,
sp_std::{collections::btree_set::BTreeSet, vec::Vec},
};
/// Trait for checking if a validator has been slashed in a given era
pub trait SlashingCheck<AccountId> {
fn is_slashed(era_index: u32, validator: &AccountId) -> bool;
}
/// Implementation that always returns false (no slashes)
impl<AccountId> SlashingCheck<AccountId> for () {
fn is_slashed(_era_index: u32, _validator: &AccountId) -> bool {
false
}
}
#[frame_support::pallet]
pub mod pallet {
use frame_support::traits::fungible;
use sp_runtime::PerThing;
pub use crate::weights::WeightInfo;
use {
@ -92,6 +108,57 @@ pub mod pallet {
type GetWhitelistedValidators: Get<Vec<Self::AccountId>>;
/// Validator set provider for performance tracking
type ValidatorSet: frame_support::traits::ValidatorSet<Self::AccountId>;
/// Provider to check if validators are online (sent heartbeat this session)
type LivenessCheck: frame_support::traits::Contains<Self::AccountId>;
/// Check if a validator has been slashed in a given era
type SlashingCheck: SlashingCheck<Self::AccountId>;
/// Base points added to the reward pool per block produced.
/// These points are distributed according to the weighted formula:
/// - 60% (BlockAuthoringWeight) goes to the block author
/// - 40% (LivenessWeight + base) is shared among all online validators
///
/// Example with 320 points and 32 validators:
/// - Per block: author gets 192 + 4 = 196, each non-author gets 4
/// - Per session (600 blocks): each validator earns ~6,000 points (uniform distribution)
/// - Per era (6 sessions): each validator earns ~36,000 points
#[pallet::constant]
type BasePointsPerBlock: Get<u32>;
/// Weight of block authoring in the rewards formula (e.g., 60% = Perbill::from_percent(60)).
/// Combined with LivenessWeight, the sum should not exceed 100%.
/// The remainder (100% - block - liveness) is the unconditional base reward.
type BlockAuthoringWeight: Get<Perbill>;
/// Weight of liveness (heartbeat/block authorship) in the rewards formula.
/// Combined with BlockAuthoringWeight, the sum should not exceed 100%.
/// The remainder (100% - block - liveness) is the unconditional base reward.
type LivenessWeight: Get<Perbill>;
/// Soft cap on block authoring rewards as a percentage above fair share.
/// E.g., 50% means validators can earn credit for up to 150% of their fair share.
/// With 60% BlockAuthoringWeight, this gives over-performers up to 30% bonus reward.
type FairShareCap: Get<Perbill>;
/// Expected number of blocks to be produced per era (based on era duration and block time).
/// Used as the baseline (100%) for performance-based inflation scaling.
#[pallet::constant]
type ExpectedBlocksPerEra: Get<u32>;
/// Minimum inflation percentage even with zero blocks produced (e.g., 20 = 20%).
/// Prevents complete halt of inflation during network issues.
#[pallet::constant]
type MinInflationPercent: Get<u32>;
/// Maximum inflation percentage cap (e.g., 100 = 100%).
/// Prevents runaway inflation if blocks exceed expectations.
#[pallet::constant]
type MaxInflationPercent: Get<u32>;
/// Hashing tool used to generate/verify merkle roots and proofs.
type Hashing: Hash<Output = H256>;
@ -206,6 +273,19 @@ pub mod pallet {
pub type RewardPointsForEra<T: Config> =
StorageMap<_, Twox64Concat, EraIndex, EraRewardPoints<T::AccountId>, ValueQuery>;
/// Track the number of blocks authored by each validator in the current session.
/// Cleared at the end of each session.
#[pallet::storage]
#[pallet::unbounded]
pub type BlocksAuthoredInSession<T: Config> =
StorageMap<_, Twox64Concat, T::AccountId, u32, ValueQuery>;
/// Track the total number of blocks produced in each era.
/// Used to scale inflation based on network performance.
#[pallet::storage]
pub type BlocksProducedInEra<T: Config> =
StorageMap<_, Twox64Concat, EraIndex, u32, ValueQuery>;
impl<T: Config> Pallet<T> {
/// Reward validators. Does not check if the validators are valid, caller needs to make sure of that.
pub fn reward_by_ids(points: impl IntoIterator<Item = (T::AccountId, RewardPoints)>) {
@ -272,6 +352,305 @@ pub mod pallet {
})
.ok()
}
/// Track a block authored by a validator
pub fn note_block_author(author: T::AccountId) {
// Track per-session authorship for performance points
BlocksAuthoredInSession::<T>::mutate(&author, |count| {
*count = count.saturating_add(1);
});
// Track total blocks in current era for inflation scaling
let active_era = T::EraIndexProvider::active_era();
BlocksProducedInEra::<T>::mutate(active_era.index, |count| {
*count = count.saturating_add(1);
});
}
/// Calculate performance-scaled inflation based on blocks produced in the era.
///
/// # Formula
///
/// Scales base inflation from MinInflationPercent to MaxInflationPercent based on:
/// - Blocks produced vs expected blocks per era
/// - Capped at expected blocks (no bonus for overproduction)
///
/// `scaled_inflation = base_inflation × (min% + (performance_ratio × (max% - min%)))`
///
/// # Parameters
///
/// - `era_index`: The era to check blocks for
/// - `base_inflation`: The maximum inflation amount (at 100% performance)
///
/// # Returns
///
/// The scaled inflation amount based on network performance
pub fn calculate_scaled_inflation(era_index: EraIndex, base_inflation: u128) -> u128 {
use sp_runtime::Perbill;
let blocks_produced = BlocksProducedInEra::<T>::get(era_index);
let expected_blocks = T::ExpectedBlocksPerEra::get();
let min_percent = T::MinInflationPercent::get();
let max_percent = T::MaxInflationPercent::get();
// Calculate performance ratio (capped at 100%)
let performance_ratio = if expected_blocks > 0 {
Perbill::from_rational(blocks_produced.min(expected_blocks), expected_blocks)
} else {
// If no expected blocks configured, use full inflation
Perbill::one()
};
// Scale from min to max based on performance
// inflation_percent = min + (performance_ratio × (max - min))
let inflation_percent = min_percent.saturating_add(
performance_ratio.mul_floor(max_percent.saturating_sub(min_percent)),
);
// Apply percentage to base inflation
let scaled_inflation =
Perbill::from_percent(inflation_percent).mul_floor(base_inflation);
log::debug!(
target: "ext_validators_rewards",
"Era {} inflation scaling: {} blocks / {} expected = {}% performance → {}% inflation ({} tokens)",
era_index,
blocks_produced,
expected_blocks,
performance_ratio.deconstruct() * 100 / 1_000_000_000,
inflation_percent,
scaled_inflation
);
scaled_inflation
}
/// Awards performance-based points at session end using a configurable weighted formula.
///
/// # Reward Formula
///
/// Each validator receives points based on configurable weights (default 60/30/10):
/// - **BlockAuthoringWeight**: Block production score with soft cap allowing over-performance
/// - **LivenessWeight**: Liveness score (1.0 if online, 0.0 otherwise)
/// - **Base guarantee**: Remainder (100% - block - liveness) always awarded
///
/// Final points = BASE_POINTS × (block_weight × block_score + liveness_weight × liveness_score + base_weight)
///
/// # Block Production Scoring
///
/// - Fair share = total_blocks / total_validator_count
/// - Soft cap allows earning credit up to (1 + FairShareCap) × fair_share
/// - Block score = credited_blocks / fair_share (can exceed 100% with over-performance)
/// - Example: With 50% cap and fair share of 10 blocks, producing 15 blocks → 150% score
///
/// # Liveness Scoring
///
/// Based on ImOnline's is_online() which considers a validator online if:
/// - They sent a heartbeat in the current session, OR
/// - They authored at least one block in the current session
///
/// # Weight Validation
///
/// If BlockAuthoringWeight + LivenessWeight > 100%, values are proportionally scaled down
/// to ensure the sum does not exceed 100%. This prevents configuration errors from
/// breaking the reward system.
///
/// # Whitelisted Validators
///
/// Whitelisted validators are excluded from rewards AND from fair share calculation.
/// This ensures regular validators' fair share isn't diluted by whitelisted validators.
pub fn award_session_performance_points(
session_index: SessionIndex,
validators: Vec<T::AccountId>,
whitelisted_validators: Vec<T::AccountId>,
) {
// Calculate total blocks for the session
let total_blocks: u32 = BlocksAuthoredInSession::<T>::iter()
.map(|(_, count)| count)
.sum();
// Count non-whitelisted validators for fair share calculation
let non_whitelisted_count = validators
.iter()
.filter(|v| !whitelisted_validators.contains(v))
.count() as u32;
if non_whitelisted_count == 0 {
log::warn!(
target: "ext_validators_rewards",
"No non-whitelisted validators in session {}, skipping performance rewards",
session_index
);
// Clear session tracking storage even if no rewards
let _ = BlocksAuthoredInSession::<T>::clear(u32::MAX, None);
return;
}
// Fair share: expected blocks per validator (including whitelisted).
// Whitelisted validators still produce blocks (they just don't receive rewards),
// so block production slots are distributed among ALL validators.
// This ensures non-whitelisted validators aren't penalized for not producing
// blocks that were assigned to whitelisted validators.
// Note: We use floor division here which is appropriate for the soft cap
// (we don't want to give bonus credit for fractional blocks).
// Ensure minimum of 1 to prevent division issues when total_blocks < validator_count.
let total_validator_count = validators.len() as u32;
let fair_share = total_blocks
.checked_div(total_validator_count)
.unwrap_or(1)
.max(1);
// Get soft cap for over-performance rewards
let fair_share_cap = T::FairShareCap::get();
// Calculate max credited blocks based on soft cap
// max_credited = fair_share + cap × fair_share = fair_share × (1 + cap)
let max_credited_blocks =
fair_share.saturating_add(fair_share_cap.mul_floor(fair_share));
// Get and validate reward weights with defensive scaling
let (block_weight, liveness_weight, base_weight) = {
let raw_block = T::BlockAuthoringWeight::get();
let raw_liveness = T::LivenessWeight::get();
let sum = raw_block.saturating_add(raw_liveness);
if sum > Perbill::one() {
// Proportionally scale down to fit within 100%
log::warn!(
target: "ext_validators_rewards",
"Reward weights exceed 100% (block={}%, liveness={}%), scaling proportionally",
raw_block.deconstruct() * 100 / Perbill::ACCURACY,
raw_liveness.deconstruct() * 100 / Perbill::ACCURACY
);
let scale =
Perbill::from_rational(Perbill::one().deconstruct(), sum.deconstruct());
let scaled_block = scale.saturating_mul(raw_block);
let scaled_liveness = scale.saturating_mul(raw_liveness);
(scaled_block, scaled_liveness, Perbill::zero())
} else {
let base = Perbill::one()
.saturating_sub(raw_block)
.saturating_sub(raw_liveness);
(raw_block, raw_liveness, base)
}
};
log::debug!(
target: "ext_validators_rewards",
"Session {} performance: {} total validators, {} non-whitelisted, {} blocks, fair_share={}, max_credited={}, weights={}%/{}%/{}%",
session_index,
total_validator_count,
non_whitelisted_count,
total_blocks,
fair_share,
max_credited_blocks,
block_weight.deconstruct() * 100 / Perbill::ACCURACY,
liveness_weight.deconstruct() * 100 / Perbill::ACCURACY,
base_weight.deconstruct() * 100 / Perbill::ACCURACY
);
// Calculate and award points for each validator
for validator in validators.iter() {
// Skip whitelisted validators - they don't participate in performance rewards
if whitelisted_validators.contains(validator) {
continue;
}
// NOTE: Slashing check is disabled for now but hook is retained for future use.
// Slashed validators will still be slashed financially via the slashing pallet;
// they just won't lose their era rewards. This allows governance to cancel
// erroneous slashes without also losing the validator's rewards.
//
// To re-enable, uncomment the following block:
// let active_era = T::EraIndexProvider::active_era();
// if T::SlashingCheck::is_slashed(active_era.index, validator) {
// log::warn!(
// target: "ext_validators_rewards",
// "Validator {:?} has slash in era {}, nullifying rewards",
// validator,
// active_era.index
// );
// continue;
// }
let blocks_authored = BlocksAuthoredInSession::<T>::get(validator);
// Block production with soft cap allowing over-performance
// credited_blocks = min(blocks_authored, max_credited_blocks)
let credited_blocks = blocks_authored.min(max_credited_blocks);
// Liveness score: based on ImOnline's is_online() which considers
// heartbeats OR block authorship
let is_online = T::LivenessCheck::contains(validator);
let liveness_score = if is_online {
Perbill::one()
} else {
Perbill::zero()
};
// Calculate points using direct computation to avoid Perbill capping.
// Perbill::from_rational caps at 100% when numerator > denominator,
// which would prevent over-performers from getting bonus points.
//
// Formula breakdown:
// - Block contribution: block_weight × credited_blocks × base_points
// This directly rewards blocks authored, allowing over-performers to
// exceed 100% of fair share (up to the soft cap).
//
// - Liveness + Base contribution: (liveness_weight × liveness + base_weight) × total_blocks × base_points / count
// Uses total_blocks instead of fair_share to ensure no points are lost due to
// integer division truncation. The division by count happens at the end to
// distribute the full pool evenly.
//
// Total: block_contribution + liveness_base_contribution
let base_points = T::BasePointsPerBlock::get();
// Block contribution: block_weight × credited_blocks × base_points
// This can exceed fair_share × base_points for over-performers
let block_contribution =
block_weight.mul_floor(credited_blocks.saturating_mul(base_points));
// Liveness + Base contribution: other_weight × effective_total × base_points / total_validators
// Using max(total_blocks, total_validators) ensures:
// 1. No points are lost from fair_share truncation when total_blocks > validator_count
// 2. Minimum guaranteed potential when total_blocks < validator_count
//
// We divide by total_validator_count (not non_whitelisted_count) because:
// - Whitelisted validators still occupy block production slots
// - Each non-whitelisted validator should get their "fair share" of the liveness pool
// - Otherwise liveness would disproportionately outweigh block authoring
let other_weight = liveness_weight
.saturating_mul(liveness_score)
.saturating_add(base_weight);
let effective_total_for_other = total_blocks.max(total_validator_count);
let total_other_pool =
other_weight.mul_floor(effective_total_for_other.saturating_mul(base_points));
let liveness_base_contribution = total_other_pool / total_validator_count;
// Total points = block contribution + liveness/base contribution
let points = block_contribution.saturating_add(liveness_base_contribution);
if points > 0 {
log::debug!(
target: "ext_validators_rewards",
"Validator {:?}: blocks={}/{} (credited={}), online={}, block_pts={}, liveness_base_pts={}, total={}",
validator,
blocks_authored,
fair_share,
credited_blocks,
if is_online { "yes" } else { "no" },
block_contribution,
liveness_base_contribution,
points
);
Self::reward_by_ids([(validator.clone(), points)].into_iter());
}
}
// Clear session tracking storage
let _ = BlocksAuthoredInSession::<T>::clear(u32::MAX, None);
}
}
impl<T: Config> OnEraStart for Pallet<T> {
@ -281,6 +660,7 @@ pub mod pallet {
};
RewardPointsForEra::<T>::remove(era_index_to_delete);
BlocksProducedInEra::<T>::remove(era_index_to_delete);
}
}
@ -306,11 +686,14 @@ pub mod pallet {
}
};
// Mint tokens using the configurable handler
// Calculate performance-scaled inflation based on blocks produced
let ethereum_sovereign_account = T::RewardsEthereumSovereignAccount::get();
let inflation_amount = T::EraInflationProvider::get();
let base_inflation = T::EraInflationProvider::get();
let scaled_inflation = Self::calculate_scaled_inflation(era_index, base_inflation);
// Mint scaled inflation tokens using the configurable handler
if let Err(err) =
T::HandleInflation::mint_inflation(&ethereum_sovereign_account, inflation_amount)
T::HandleInflation::mint_inflation(&ethereum_sovereign_account, scaled_inflation)
{
log::error!(target: "ext_validators_rewards", "Failed to handle inflation: {err:?}");
log::error!(target: "ext_validators_rewards", "Not sending message since there are no rewards to distribute");
@ -327,7 +710,7 @@ pub mod pallet {
message_id,
era_index,
total_points: utils.total_points,
inflation_amount,
inflation_amount: scaled_inflation,
rewards_merkle_root: utils.rewards_merkle_root,
});
}
@ -340,8 +723,16 @@ pub struct RewardValidatorsWithEraPoints<C>(core::marker::PhantomData<C>);
impl<C> RewardValidatorsWithEraPoints<C>
where
C: pallet::Config + session_info::Config,
C::ValidatorSet: ValidatorSet<C::AccountId, ValidatorId = C::AccountId>,
C: pallet::Config
+ session_info::Config<
ValidatorSet: frame_support::traits::ValidatorSet<
C::AccountId,
ValidatorId = C::AccountId,
>,
>,
<C as pallet::Config>::ValidatorSet:
frame_support::traits::ValidatorSet<C::AccountId, ValidatorId = C::AccountId>,
C::AccountId: Ord,
{
/// Reward validators in session with points, but only if they are in the active set.
fn reward_only_active(
@ -357,7 +748,10 @@ where
None => return,
};
// limit rewards to the active validator set
let mut active_set: BTreeSet<_> = C::ValidatorSet::validators().into_iter().collect();
let mut active_set: BTreeSet<C::AccountId> =
<C as pallet::Config>::ValidatorSet::validators()
.into_iter()
.collect();
// Remove whitelisted validators, we don't want to reward them
let whitelisted_validators = C::GetWhitelistedValidators::get();
@ -377,8 +771,17 @@ where
impl<C> runtime_parachains::inclusion::RewardValidators for RewardValidatorsWithEraPoints<C>
where
C: pallet::Config + runtime_parachains::shared::Config + session_info::Config,
C::ValidatorSet: ValidatorSet<C::AccountId, ValidatorId = C::AccountId>,
C: pallet::Config
+ runtime_parachains::shared::Config
+ session_info::Config<
ValidatorSet: frame_support::traits::ValidatorSet<
C::AccountId,
ValidatorId = C::AccountId,
>,
>,
<C as pallet::Config>::ValidatorSet:
frame_support::traits::ValidatorSet<C::AccountId, ValidatorId = C::AccountId>,
C::AccountId: Ord,
{
fn reward_backing(indices: impl IntoIterator<Item = ValidatorIndex>) {
let session_index = runtime_parachains::shared::CurrentSessionIndex::<C>::get();
@ -390,8 +793,16 @@ where
impl<C> runtime_parachains::disputes::RewardValidators for RewardValidatorsWithEraPoints<C>
where
C: pallet::Config + session_info::Config,
C::ValidatorSet: ValidatorSet<C::AccountId, ValidatorId = C::AccountId>,
C: pallet::Config
+ session_info::Config<
ValidatorSet: frame_support::traits::ValidatorSet<
C::AccountId,
ValidatorId = C::AccountId,
>,
>,
<C as pallet::Config>::ValidatorSet:
frame_support::traits::ValidatorSet<C::AccountId, ValidatorId = C::AccountId>,
C::AccountId: Ord,
{
fn reward_dispute_statement(
session: SessionIndex,
@ -400,3 +811,86 @@ where
Self::reward_only_active(session, validators, C::DisputeStatementPoints::get());
}
}
/// Wrapper for pallet_session::SessionManager that awards performance-based points at session end.
///
/// This implements the 60/30/10 performance formula for solochain validators:
/// - 60% weight: Block production (BABE participation)
/// - 30% weight: Heartbeat/liveness (ImOnline participation)
/// - 10% weight: Base guarantee (always awarded)
///
/// Wraps an inner SessionManager (typically `NoteHistoricalRoot<ExternalValidators>`) and calls
/// the performance tracking logic at session end before forwarding to the inner manager.
pub struct SessionPerformanceManager<T, Inner>(core::marker::PhantomData<(T, Inner)>);
impl<T, Inner> pallet_session::SessionManager<T::AccountId> for SessionPerformanceManager<T, Inner>
where
T: pallet::Config,
Inner: pallet_session::SessionManager<T::AccountId>,
<T as pallet::Config>::ValidatorSet: ValidatorSet<T::AccountId, ValidatorId = T::AccountId>,
{
fn new_session(new_index: SessionIndex) -> Option<Vec<T::AccountId>> {
<Inner as pallet_session::SessionManager<T::AccountId>>::new_session(new_index)
}
fn new_session_genesis(new_index: SessionIndex) -> Option<Vec<T::AccountId>> {
<Inner as pallet_session::SessionManager<T::AccountId>>::new_session_genesis(new_index)
}
fn start_session(start_index: SessionIndex) {
<Inner as pallet_session::SessionManager<T::AccountId>>::start_session(start_index)
}
fn end_session(end_index: SessionIndex) {
// Award performance-based points before ending the session
let validators = <T as pallet::Config>::ValidatorSet::validators();
let whitelisted = T::GetWhitelistedValidators::get();
pallet::Pallet::<T>::award_session_performance_points(end_index, validators, whitelisted);
<Inner as pallet_session::SessionManager<T::AccountId>>::end_session(end_index)
}
}
impl<T, Inner> pallet_session::historical::SessionManager<T::AccountId, ()>
for SessionPerformanceManager<T, Inner>
where
T: pallet::Config,
Inner: pallet_session::historical::SessionManager<T::AccountId, ()>,
<T as pallet::Config>::ValidatorSet: ValidatorSet<T::AccountId, ValidatorId = T::AccountId>,
{
fn new_session(new_index: SessionIndex) -> Option<Vec<(T::AccountId, ())>> {
<Inner as pallet_session::historical::SessionManager<T::AccountId, ()>>::new_session(
new_index,
)
}
fn start_session(start_index: SessionIndex) {
<Inner as pallet_session::historical::SessionManager<T::AccountId, ()>>::start_session(
start_index,
)
}
fn end_session(end_index: SessionIndex) {
// Award performance-based points before ending the session
let validators = <T as pallet::Config>::ValidatorSet::validators();
let whitelisted = T::GetWhitelistedValidators::get();
pallet::Pallet::<T>::award_session_performance_points(end_index, validators, whitelisted);
<Inner as pallet_session::historical::SessionManager<T::AccountId, ()>>::end_session(
end_index,
)
}
}
/// Implementation of EventHandler for tracking block authorship
impl<T: Config>
pallet_authorship::EventHandler<T::AccountId, frame_system::pallet_prelude::BlockNumberFor<T>>
for Pallet<T>
{
fn note_author(author: T::AccountId) {
// Track block authorship for performance-based rewards (60/30/10 formula)
Self::note_block_author(author);
}
}

View file

@ -151,6 +151,67 @@ parameter_types! {
pub const TreasuryAccount: u64 = 999;
pub const InflationTreasuryProportion: sp_runtime::Perbill = sp_runtime::Perbill::from_percent(20);
pub EraInflationProvider: u128 = Mock::mock().era_inflation.unwrap_or(42);
// Inflation scaling parameters for tests
// Using 600 blocks as the expected blocks per era for test simplicity
// (In production: 6-second blocks, 1-hour sessions, 6 sessions = 3600 blocks per era)
pub const ExpectedBlocksPerEra: u32 = 600;
pub const MinInflationPercent: u32 = 20; // 20% minimum even with 0 blocks
pub const MaxInflationPercent: u32 = 100; // 100% maximum
// Reward split parameters: 60% block authoring, 30% liveness, 10% base
pub const BlockAuthoringWeight: sp_runtime::Perbill = sp_runtime::Perbill::from_percent(60);
pub const LivenessWeight: sp_runtime::Perbill = sp_runtime::Perbill::from_percent(30);
// Soft cap: validators can earn up to 150% of fair share (50% bonus)
pub const FairShareCap: sp_runtime::Perbill = sp_runtime::Perbill::from_percent(50);
// Base points per block: 320 points added to the pool per block
// With 32 validators: author gets 196 pts, each non-author gets 4 pts per block
// Per session (600 blocks): ~6,000 pts/validator, Per era: ~36,000 pts/validator
pub const BasePointsPerBlock: u32 = 320;
}
pub struct MockValidatorSet;
impl frame_support::traits::ValidatorSet<u64> for MockValidatorSet {
type ValidatorId = u64;
type ValidatorIdOf = sp_runtime::traits::ConvertInto;
fn session_index() -> sp_staking::SessionIndex {
0
}
fn validators() -> Vec<Self::ValidatorId> {
// Return empty vec for now - tests will populate via reward_by_ids
vec![]
}
}
/// Configurable liveness check that mirrors ImOnline behavior.
/// A validator is considered online if:
/// 1. They are NOT in the offline_validators list, OR
/// 2. They have authored at least one block in the current session
///
/// This matches the real ImOnline pallet which considers block authorship
/// as proof of liveness (no heartbeat needed if you authored a block).
pub struct MockLivenessCheck;
impl frame_support::traits::Contains<u64> for MockLivenessCheck {
fn contains(validator: &u64) -> bool {
// Check if validator authored any blocks this session
let authored_blocks = crate::BlocksAuthoredInSession::<Test>::get(validator);
// Validator is online if:
// 1. They authored blocks (proves they're online), OR
// 2. They're not in the offline list (sent heartbeat)
authored_blocks > 0 || !Mock::mock().offline_validators.contains(validator)
}
}
/// Configurable slashing check that reads slashed validators from mock data.
/// Validators in the slashed_validators list (for the given era) are considered slashed.
pub struct MockSlashingCheck;
impl crate::SlashingCheck<u64> for MockSlashingCheck {
fn is_slashed(era_index: u32, validator: &u64) -> bool {
Mock::mock()
.slashed_validators
.contains(&(era_index, *validator))
}
}
impl pallet_external_validators_rewards::Config for Test {
@ -162,6 +223,16 @@ impl pallet_external_validators_rewards::Config for Test {
type EraInflationProvider = EraInflationProvider;
type ExternalIndexProvider = TimestampProvider;
type GetWhitelistedValidators = ();
type ValidatorSet = MockValidatorSet;
type LivenessCheck = MockLivenessCheck;
type SlashingCheck = MockSlashingCheck;
type BasePointsPerBlock = BasePointsPerBlock;
type BlockAuthoringWeight = BlockAuthoringWeight;
type LivenessWeight = LivenessWeight;
type FairShareCap = FairShareCap;
type ExpectedBlocksPerEra = ExpectedBlocksPerEra;
type MinInflationPercent = MinInflationPercent;
type MaxInflationPercent = MaxInflationPercent;
type Hashing = Keccak256;
type SendMessage = MockOkOutboundQueue;
type HandleInflation = InflationMinter;
@ -226,6 +297,10 @@ pub mod mock_data {
pub struct Mocks {
pub active_era: Option<ActiveEraInfo>,
pub era_inflation: Option<u128>,
/// Set of validators that are considered offline (for liveness testing)
pub offline_validators: sp_std::vec::Vec<u64>,
/// Set of (era_index, validator_id) pairs that are slashed
pub slashed_validators: sp_std::vec::Vec<(u32, u64)>,
}
#[pallet::config]

File diff suppressed because it is too large Load diff

View file

@ -164,7 +164,6 @@ const SS58_FORMAT: u16 = EVM_CHAIN_ID as u16;
parameter_types! {
pub const MaxAuthorities: u32 = 32;
pub const BondingDuration: EraIndex = polkadot_runtime_common::prod_or_fast!(28, 3);
pub const AuthorRewardPoints: u32 = 20;
}
//╔═══════════════════════════════════════════════════════════════════════════════════════════════════════════════╗
@ -351,22 +350,9 @@ impl pallet_balances::Config for Runtime {
type DoneSlashHandler = ();
}
pub struct RewardsPoints;
impl pallet_authorship::EventHandler<AccountId, BlockNumber> for RewardsPoints {
fn note_author(author: AccountId) {
let whitelisted_validators =
pallet_external_validators::WhitelistedValidatorsActiveEra::<Runtime>::get();
// Do not reward whitelisted validators
if !whitelisted_validators.contains(&author) {
ExternalValidatorsRewards::reward_by_ids(vec![(author, AuthorRewardPoints::get())])
}
}
}
impl pallet_authorship::Config for Runtime {
type FindAuthor = pallet_session::FindAccountFromAuthorIndex<Self, Babe>;
type EventHandler = (RewardsPoints, ImOnline);
type EventHandler = (ExternalValidatorsRewards, ImOnline);
}
impl pallet_offences::Config for Runtime {
@ -393,7 +379,11 @@ impl pallet_session::Config for Runtime {
type ValidatorIdOf = ConvertInto;
type ShouldEndSession = Babe;
type NextSessionRotation = Babe;
type SessionManager = pallet_session::historical::NoteHistoricalRoot<Self, ExternalValidators>;
// Wrap the session manager with performance tracking to implement 50/30/20 formula
type SessionManager = pallet_external_validators_rewards::SessionPerformanceManager<
Runtime,
pallet_session::historical::NoteHistoricalRoot<Self, ExternalValidators>,
>;
type SessionHandler = <SessionKeys as OpaqueKeys>::KeyTypeIdProviders;
type Keys = SessionKeys;
type WeightInfo = pallet_session::weights::SubstrateWeight<Runtime>;
@ -1524,6 +1514,47 @@ impl pallet_external_validators_rewards::types::SendMessage for RewardsSendAdapt
}
}
/// Wrapper to check if a validator is online in the current session.
/// Uses ImOnline's is_online() which considers a validator online if:
/// - They sent a heartbeat in the current session, OR
/// - They authored at least one block in the current session
pub struct ValidatorIsOnline;
impl frame_support::traits::Contains<AccountId> for ValidatorIsOnline {
fn contains(account: &AccountId) -> bool {
let validators = Session::validators();
if let Some(index) = validators.iter().position(|v| v == account) {
// Check if validator is online (heartbeat OR block authorship)
ImOnline::is_online(index as u32)
} else {
// Not a validator in current session, consider offline
false
}
}
}
/// Wrapper to check if a validator has been slashed in a given era
pub struct ValidatorSlashChecker;
impl pallet_external_validators_rewards::SlashingCheck<AccountId> for ValidatorSlashChecker {
fn is_slashed(era_index: u32, validator: &AccountId) -> bool {
pallet_external_validator_slashes::ValidatorSlashInEra::<Runtime>::contains_key(
era_index, validator,
)
}
}
parameter_types! {
/// Expected number of blocks per era for inflation scaling.
/// Computed as SessionsPerEra × EpochDurationInBlocks to ensure consistency.
pub ExpectedBlocksPerEra: u32 = (SessionsPerEra::get() as u32)
.saturating_mul(EpochDurationInBlocks::get());
/// Minimum inflation percentage even with zero block production (network halt protection)
pub const MinInflationPercent: u32 = 20;
/// Maximum inflation percentage (caps at 100% even if blocks exceed expectations)
pub const MaxInflationPercent: u32 = 100;
}
impl pallet_external_validators_rewards::Config for Runtime {
type RuntimeEvent = RuntimeEvent;
type EraIndexProvider = ExternalValidators;
@ -1538,6 +1569,18 @@ impl pallet_external_validators_rewards::Config for Runtime {
type EraInflationProvider = ExternalRewardsEraInflationProvider;
type ExternalIndexProvider = ExternalValidators;
type GetWhitelistedValidators = GetWhitelistedValidators;
type ValidatorSet = Session;
type LivenessCheck = ValidatorIsOnline;
type SlashingCheck = ValidatorSlashChecker;
type BasePointsPerBlock = ConstU32<320>;
type BlockAuthoringWeight =
runtime_params::dynamic_params::runtime_config::OperatorRewardsBlockAuthoringWeight;
type LivenessWeight =
runtime_params::dynamic_params::runtime_config::OperatorRewardsLivenessWeight;
type FairShareCap = runtime_params::dynamic_params::runtime_config::OperatorRewardsFairShareCap;
type ExpectedBlocksPerEra = ExpectedBlocksPerEra;
type MinInflationPercent = MinInflationPercent;
type MaxInflationPercent = MaxInflationPercent;
type Hashing = Keccak256;
type Currency = Balances;
type RewardsEthereumSovereignAccount = ExternalValidatorRewardsAccount;

View file

@ -357,6 +357,33 @@ pub mod dynamic_params {
/// The treasury portion is minted separately and sent to the treasury account.
pub static InflationTreasuryProportion: Perbill = Perbill::from_percent(20);
#[codec(index = 39)]
#[allow(non_upper_case_globals)]
/// Weight of block authoring in the operator rewards formula.
/// Default: 60% of base points are allocated based on block production performance.
/// Combined with OperatorRewardsLivenessWeight, the sum should not exceed 100%.
/// The remainder (100% - block - liveness) is the unconditional base reward.
/// If the sum exceeds 100%, values are proportionally scaled down.
pub static OperatorRewardsBlockAuthoringWeight: Perbill = Perbill::from_percent(60);
#[codec(index = 40)]
#[allow(non_upper_case_globals)]
/// Weight of liveness (heartbeat/block authorship) in the operator rewards formula.
/// Default: 30% of base points are allocated based on validator online status.
/// Combined with OperatorRewardsBlockAuthoringWeight, the sum should not exceed 100%.
/// The remainder (100% - block - liveness) is the unconditional base reward.
/// If the sum exceeds 100%, values are proportionally scaled down.
pub static OperatorRewardsLivenessWeight: Perbill = Perbill::from_percent(30);
#[codec(index = 41)]
#[allow(non_upper_case_globals)]
/// Soft cap on block authoring rewards as a percentage above fair share.
/// Default: 50% means validators can earn credit for up to 150% of their fair share.
/// With 60% BlockAuthoringWeight, this gives over-performers up to 30% bonus reward.
/// Example: With fair share of 10 blocks and 50% cap, a validator producing 15 blocks
/// gets full credit (150%), but one producing 20 blocks is capped at 15 blocks credit.
pub static OperatorRewardsFairShareCap: Perbill = Perbill::from_percent(50);
// ╚══════════════════════ Validator Rewards Inflation ═══════════════════════╝
}
}

View file

@ -164,7 +164,6 @@ const SS58_FORMAT: u16 = EVM_CHAIN_ID as u16;
parameter_types! {
pub const MaxAuthorities: u32 = 32;
pub const BondingDuration: EraIndex = polkadot_runtime_common::prod_or_fast!(28, 3);
pub const AuthorRewardPoints: u32 = 20;
}
//╔═══════════════════════════════════════════════════════════════════════════════════════════════════════════════╗
@ -351,22 +350,9 @@ impl pallet_balances::Config for Runtime {
type DoneSlashHandler = ();
}
pub struct RewardsPoints;
impl pallet_authorship::EventHandler<AccountId, BlockNumber> for RewardsPoints {
fn note_author(author: AccountId) {
let whitelisted_validators =
pallet_external_validators::WhitelistedValidatorsActiveEra::<Runtime>::get();
// Do not reward whitelisted validators
if !whitelisted_validators.contains(&author) {
ExternalValidatorsRewards::reward_by_ids(vec![(author, AuthorRewardPoints::get())])
}
}
}
impl pallet_authorship::Config for Runtime {
type FindAuthor = pallet_session::FindAccountFromAuthorIndex<Self, Babe>;
type EventHandler = (RewardsPoints, ImOnline);
type EventHandler = (ExternalValidatorsRewards, ImOnline);
}
impl pallet_offences::Config for Runtime {
@ -392,7 +378,10 @@ impl pallet_session::Config for Runtime {
type ValidatorIdOf = ConvertInto;
type ShouldEndSession = Babe;
type NextSessionRotation = Babe;
type SessionManager = pallet_session::historical::NoteHistoricalRoot<Self, ExternalValidators>;
type SessionManager = pallet_external_validators_rewards::SessionPerformanceManager<
Runtime,
pallet_session::historical::NoteHistoricalRoot<Self, ExternalValidators>,
>;
type SessionHandler = <SessionKeys as OpaqueKeys>::KeyTypeIdProviders;
type Keys = SessionKeys;
type WeightInfo = pallet_session::weights::SubstrateWeight<Runtime>;
@ -1520,6 +1509,47 @@ impl pallet_external_validators_rewards::types::SendMessage for RewardsSendAdapt
}
}
/// Wrapper to check if a validator is online in the current session.
/// Uses ImOnline's is_online() which considers a validator online if:
/// - They sent a heartbeat in the current session, OR
/// - They authored at least one block in the current session
pub struct ValidatorIsOnline;
impl frame_support::traits::Contains<AccountId> for ValidatorIsOnline {
fn contains(account: &AccountId) -> bool {
let validators = Session::validators();
if let Some(index) = validators.iter().position(|v| v == account) {
// Check if validator is online (heartbeat OR block authorship)
ImOnline::is_online(index as u32)
} else {
// Not a validator in current session, consider offline
false
}
}
}
/// Wrapper to check if a validator has been slashed in a given era
pub struct ValidatorSlashChecker;
impl pallet_external_validators_rewards::SlashingCheck<AccountId> for ValidatorSlashChecker {
fn is_slashed(era_index: u32, validator: &AccountId) -> bool {
pallet_external_validator_slashes::ValidatorSlashInEra::<Runtime>::contains_key(
era_index, validator,
)
}
}
parameter_types! {
/// Expected number of blocks per era for inflation scaling.
/// Computed as SessionsPerEra × EpochDurationInBlocks to ensure consistency.
pub ExpectedBlocksPerEra: u32 = (SessionsPerEra::get() as u32)
.saturating_mul(EpochDurationInBlocks::get());
/// Minimum inflation percentage even with zero block production (network halt protection)
pub const MinInflationPercent: u32 = 20;
/// Maximum inflation percentage (caps at 100% even if blocks exceed expectations)
pub const MaxInflationPercent: u32 = 100;
}
impl pallet_external_validators_rewards::Config for Runtime {
type RuntimeEvent = RuntimeEvent;
type EraIndexProvider = ExternalValidators;
@ -1534,6 +1564,18 @@ impl pallet_external_validators_rewards::Config for Runtime {
type EraInflationProvider = ExternalRewardsEraInflationProvider;
type ExternalIndexProvider = ExternalValidators;
type GetWhitelistedValidators = GetWhitelistedValidators;
type ValidatorSet = Session;
type LivenessCheck = ValidatorIsOnline;
type SlashingCheck = ValidatorSlashChecker;
type BasePointsPerBlock = ConstU32<320>;
type BlockAuthoringWeight =
runtime_params::dynamic_params::runtime_config::OperatorRewardsBlockAuthoringWeight;
type LivenessWeight =
runtime_params::dynamic_params::runtime_config::OperatorRewardsLivenessWeight;
type FairShareCap = runtime_params::dynamic_params::runtime_config::OperatorRewardsFairShareCap;
type ExpectedBlocksPerEra = ExpectedBlocksPerEra;
type MinInflationPercent = MinInflationPercent;
type MaxInflationPercent = MaxInflationPercent;
type Hashing = Keccak256;
type Currency = Balances;
type RewardsEthereumSovereignAccount = ExternalValidatorRewardsAccount;

View file

@ -362,6 +362,33 @@ pub mod dynamic_params {
/// The treasury portion is minted separately and sent to the treasury account.
pub static InflationTreasuryProportion: Perbill = Perbill::from_percent(20);
#[codec(index = 39)]
#[allow(non_upper_case_globals)]
/// Weight of block authoring in the operator rewards formula.
/// Default: 60% of base points are allocated based on block production performance.
/// Combined with OperatorRewardsLivenessWeight, the sum should not exceed 100%.
/// The remainder (100% - block - liveness) is the unconditional base reward.
/// If the sum exceeds 100%, values are proportionally scaled down.
pub static OperatorRewardsBlockAuthoringWeight: Perbill = Perbill::from_percent(60);
#[codec(index = 40)]
#[allow(non_upper_case_globals)]
/// Weight of liveness (heartbeat/block authorship) in the operator rewards formula.
/// Default: 30% of base points are allocated based on validator online status.
/// Combined with OperatorRewardsBlockAuthoringWeight, the sum should not exceed 100%.
/// The remainder (100% - block - liveness) is the unconditional base reward.
/// If the sum exceeds 100%, values are proportionally scaled down.
pub static OperatorRewardsLivenessWeight: Perbill = Perbill::from_percent(30);
#[codec(index = 41)]
#[allow(non_upper_case_globals)]
/// Soft cap on block authoring rewards as a percentage above fair share.
/// Default: 50% means validators can earn credit for up to 150% of their fair share.
/// With 60% BlockAuthoringWeight, this gives over-performers up to 30% bonus reward.
/// Example: With fair share of 10 blocks and 50% cap, a validator producing 15 blocks
/// gets full credit (150%), but one producing 20 blocks is capped at 15 blocks credit.
pub static OperatorRewardsFairShareCap: Perbill = Perbill::from_percent(50);
// ╚══════════════════════ Validator Rewards Inflation ═══════════════════════╝
}
}

View file

@ -164,7 +164,6 @@ const SS58_FORMAT: u16 = EVM_CHAIN_ID as u16;
parameter_types! {
pub const MaxAuthorities: u32 = 32;
pub const BondingDuration: EraIndex = polkadot_runtime_common::prod_or_fast!(28, 3);
pub const AuthorRewardPoints: u32 = 20;
}
//╔═══════════════════════════════════════════════════════════════════════════════════════════════════════════════╗
@ -351,22 +350,9 @@ impl pallet_balances::Config for Runtime {
type DoneSlashHandler = ();
}
pub struct RewardsPoints;
impl pallet_authorship::EventHandler<AccountId, BlockNumber> for RewardsPoints {
fn note_author(author: AccountId) {
let whitelisted_validators =
pallet_external_validators::WhitelistedValidatorsActiveEra::<Runtime>::get();
// Do not reward whitelisted validators
if !whitelisted_validators.contains(&author) {
ExternalValidatorsRewards::reward_by_ids(vec![(author, AuthorRewardPoints::get())])
}
}
}
impl pallet_authorship::Config for Runtime {
type FindAuthor = pallet_session::FindAccountFromAuthorIndex<Self, Babe>;
type EventHandler = (RewardsPoints, ImOnline);
type EventHandler = (ExternalValidatorsRewards, ImOnline);
}
impl pallet_offences::Config for Runtime {
@ -392,7 +378,10 @@ impl pallet_session::Config for Runtime {
type ValidatorIdOf = ConvertInto;
type ShouldEndSession = Babe;
type NextSessionRotation = Babe;
type SessionManager = pallet_session::historical::NoteHistoricalRoot<Self, ExternalValidators>;
type SessionManager = pallet_external_validators_rewards::SessionPerformanceManager<
Runtime,
pallet_session::historical::NoteHistoricalRoot<Self, ExternalValidators>,
>;
type SessionHandler = <SessionKeys as OpaqueKeys>::KeyTypeIdProviders;
type Keys = SessionKeys;
type WeightInfo = pallet_session::weights::SubstrateWeight<Runtime>;
@ -1524,6 +1513,47 @@ impl pallet_external_validators_rewards::types::SendMessage for RewardsSendAdapt
}
}
/// Wrapper to check if a validator is online in the current session.
/// Uses ImOnline's is_online() which considers a validator online if:
/// - They sent a heartbeat in the current session, OR
/// - They authored at least one block in the current session
pub struct ValidatorIsOnline;
impl frame_support::traits::Contains<AccountId> for ValidatorIsOnline {
fn contains(account: &AccountId) -> bool {
let validators = Session::validators();
if let Some(index) = validators.iter().position(|v| v == account) {
// Check if validator is online (heartbeat OR block authorship)
ImOnline::is_online(index as u32)
} else {
// Not a validator in current session, consider offline
false
}
}
}
/// Wrapper to check if a validator has been slashed in a given era
pub struct ValidatorSlashChecker;
impl pallet_external_validators_rewards::SlashingCheck<AccountId> for ValidatorSlashChecker {
fn is_slashed(era_index: u32, validator: &AccountId) -> bool {
pallet_external_validator_slashes::ValidatorSlashInEra::<Runtime>::contains_key(
era_index, validator,
)
}
}
parameter_types! {
/// Expected number of blocks per era for inflation scaling.
/// Computed as SessionsPerEra × EpochDurationInBlocks to ensure consistency.
pub ExpectedBlocksPerEra: u32 = (SessionsPerEra::get() as u32)
.saturating_mul(EpochDurationInBlocks::get());
/// Minimum inflation percentage even with zero block production (network halt protection)
pub const MinInflationPercent: u32 = 20;
/// Maximum inflation percentage (caps at 100% even if blocks exceed expectations)
pub const MaxInflationPercent: u32 = 100;
}
impl pallet_external_validators_rewards::Config for Runtime {
type RuntimeEvent = RuntimeEvent;
type EraIndexProvider = ExternalValidators;
@ -1538,6 +1568,18 @@ impl pallet_external_validators_rewards::Config for Runtime {
type EraInflationProvider = ExternalRewardsEraInflationProvider;
type ExternalIndexProvider = ExternalValidators;
type GetWhitelistedValidators = GetWhitelistedValidators;
type ValidatorSet = Session;
type LivenessCheck = ValidatorIsOnline;
type SlashingCheck = ValidatorSlashChecker;
type BasePointsPerBlock = ConstU32<320>;
type BlockAuthoringWeight =
runtime_params::dynamic_params::runtime_config::OperatorRewardsBlockAuthoringWeight;
type LivenessWeight =
runtime_params::dynamic_params::runtime_config::OperatorRewardsLivenessWeight;
type FairShareCap = runtime_params::dynamic_params::runtime_config::OperatorRewardsFairShareCap;
type ExpectedBlocksPerEra = ExpectedBlocksPerEra;
type MinInflationPercent = MinInflationPercent;
type MaxInflationPercent = MaxInflationPercent;
type Hashing = Keccak256;
type Currency = Balances;
type RewardsEthereumSovereignAccount = ExternalValidatorRewardsAccount;

View file

@ -358,6 +358,33 @@ pub mod dynamic_params {
/// The treasury portion is minted separately and sent to the treasury account.
pub static InflationTreasuryProportion: Perbill = Perbill::from_percent(20);
#[codec(index = 39)]
#[allow(non_upper_case_globals)]
/// Weight of block authoring in the operator rewards formula.
/// Default: 60% of base points are allocated based on block production performance.
/// Combined with OperatorRewardsLivenessWeight, the sum should not exceed 100%.
/// The remainder (100% - block - liveness) is the unconditional base reward.
/// If the sum exceeds 100%, values are proportionally scaled down.
pub static OperatorRewardsBlockAuthoringWeight: Perbill = Perbill::from_percent(60);
#[codec(index = 40)]
#[allow(non_upper_case_globals)]
/// Weight of liveness (heartbeat/block authorship) in the operator rewards formula.
/// Default: 30% of base points are allocated based on validator online status.
/// Combined with OperatorRewardsBlockAuthoringWeight, the sum should not exceed 100%.
/// The remainder (100% - block - liveness) is the unconditional base reward.
/// If the sum exceeds 100%, values are proportionally scaled down.
pub static OperatorRewardsLivenessWeight: Perbill = Perbill::from_percent(30);
#[codec(index = 41)]
#[allow(non_upper_case_globals)]
/// Soft cap on block authoring rewards as a percentage above fair share.
/// Default: 50% means validators can earn credit for up to 150% of their fair share.
/// With 60% BlockAuthoringWeight, this gives over-performers up to 30% bonus reward.
/// Example: With fair share of 10 blocks and 50% cap, a validator producing 15 blocks
/// gets full credit (150%), but one producing 20 blocks is capped at 15 blocks credit.
pub static OperatorRewardsFairShareCap: Perbill = Perbill::from_percent(50);
// ╚══════════════════════ Validator Rewards Inflation ═══════════════════════╝
}
}

View file

@ -1,5 +1,5 @@
{
"version": "0.1.0-autogenerated.1683923751375582060",
"version": "0.1.0-autogenerated.11864056470473099144",
"name": "@polkadot-api/descriptors",
"files": [
"dist"

Binary file not shown.