mirror of
https://github.com/datahaven-xyz/datahaven
synced 2026-05-24 09:50:01 +00:00
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:
parent
d5e64d59e8
commit
67f375860b
14 changed files with 3627 additions and 75 deletions
1
operator/Cargo.lock
generated
1
operator/Cargo.lock
generated
|
|
@ -8978,6 +8978,7 @@ dependencies = [
|
|||
"frame-support",
|
||||
"frame-system",
|
||||
"log",
|
||||
"pallet-authorship",
|
||||
"pallet-balances",
|
||||
"pallet-external-validators",
|
||||
"pallet-session",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(ðereum_sovereign_account, inflation_amount)
|
||||
T::HandleInflation::mint_inflation(ðereum_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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 ═══════════════════════╝
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 ═══════════════════════╝
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 ═══════════════════════╝
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
Loading…
Reference in a new issue