fix: Use block authorship as liveness indicator for validator rewards (#367)

## Summary

Use block authorship as direct proof of liveness for the 30% liveness
component of validator rewards. Validators who author at least one block
in a session are considered online and receive the full liveness bonus.

## Problem

The rewards pallet was checking validator liveness via ImOnline
**after** the session had rotated - at which point ImOnline had already
cleared its `AuthoredBlocks` storage. This caused all validators to
appear offline, resulting in only ~70% of expected rewards being
allocated (missing the 30% liveness bonus).

## Solution

Use **block authorship as the proxy for liveness**:

- A validator who authored at least one block is definitively online
- Liveness is determined directly in `award_session_performance_points`
via `blocks_authored > 0`
- No dependency on external liveness checks (ImOnline)

### Rewards Formula

- **60%** Block authorship (proportional to blocks produced)
- **30%** Liveness (full bonus if authored ≥1 block, zero otherwise)
- **10%** Base reward (for being in the validator set)

### Files Changed

- `pallets/external-validators-rewards/src/lib.rs` - Core logic changes
- `pallets/external-validators-rewards/src/mock.rs` - Test mock updates
- `pallets/external-validators-rewards/src/tests.rs` - Test updates
- `runtime/{mainnet,testnet,stagenet}/src/configs/mod.rs` - Config
updates

## Testing

- All 76 pallet tests pass
- Local testing should show correct points per session (e.g., 3200
points for 2 validators with 10 blocks)

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Ahmad Kaouk <56095276+ahmadkaouk@users.noreply.github.com>
This commit is contained in:
Steve Degosserie 2026-01-07 14:41:40 +01:00 committed by GitHub
parent aee282613f
commit 9bca5c467b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 126 additions and 334 deletions

3
operator/Cargo.lock generated
View file

@ -8977,7 +8977,6 @@ dependencies = [
name = "pallet-external-validators-rewards"
version = "0.12.0"
dependencies = [
"cumulus-primitives-core",
"frame-benchmarking",
"frame-support",
"frame-system",
@ -8988,8 +8987,6 @@ dependencies = [
"pallet-session",
"pallet-timestamp",
"parity-scale-codec",
"polkadot-primitives",
"polkadot-runtime-parachains",
"scale-info",
"snowbridge-core 0.12.0",
"snowbridge-merkle-tree",

View file

@ -156,7 +156,6 @@ parachains-common = { git = "https://github.com/paritytech/polkadot-sdk.git", ta
polkadot-parachain-primitives = { git = "https://github.com/paritytech/polkadot-sdk", tag = "polkadot-stable2412-6", default-features = false }
polkadot-primitives = { git = "https://github.com/paritytech/polkadot-sdk", tag = "polkadot-stable2412-6", default-features = false }
polkadot-runtime-common = { git = "https://github.com/paritytech/polkadot-sdk", tag = "polkadot-stable2412-6", default-features = false }
runtime-parachains = { package = "polkadot-runtime-parachains", git = "https://github.com/paritytech/polkadot-sdk", tag = "polkadot-stable2412-6", default-features = false }
sc-basic-authorship = { git = "https://github.com/paritytech/polkadot-sdk", tag = "polkadot-stable2412-6", default-features = false }
sc-cli = { git = "https://github.com/paritytech/polkadot-sdk", tag = "polkadot-stable2412-6", default-features = false }
sc-client-api = { git = "https://github.com/paritytech/polkadot-sdk", tag = "polkadot-stable2412-6", default-features = false }

View file

@ -30,13 +30,10 @@ pallet-authorship = { workspace = true }
pallet-balances = { workspace = true, optional = true }
pallet-external-validators = { workspace = true }
pallet-session = { workspace = true, features = [ "historical" ] }
runtime-parachains = { workspace = true }
snowbridge-core = { workspace = true }
snowbridge-merkle-tree = { workspace = true }
snowbridge-outbound-queue-primitives = { workspace = true }
cumulus-primitives-core = { workspace = true }
polkadot-primitives = { workspace = true }
[dev-dependencies]
pallet-timestamp = { workspace = true }
@ -45,7 +42,6 @@ sp-io = { workspace = true }
[features]
default = [ "std" ]
std = [
"cumulus-primitives-core/std",
"frame-benchmarking/std",
"frame-support/std",
"frame-system/std",
@ -56,8 +52,6 @@ std = [
"pallet-session/std",
"pallet-timestamp/std",
"parity-scale-codec/std",
"polkadot-primitives/std",
"runtime-parachains/std",
"scale-info/std",
"snowbridge-core/std",
"snowbridge-merkle-tree/std",
@ -69,15 +63,12 @@ std = [
"sp-std/std",
]
runtime-benchmarks = [
"cumulus-primitives-core/runtime-benchmarks",
"frame-benchmarking/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",
"snowbridge-core/runtime-benchmarks",
"sp-runtime/runtime-benchmarks",
"sp-staking/runtime-benchmarks",
@ -91,6 +82,5 @@ try-runtime = [
"pallet-external-validators/try-runtime",
"pallet-session/try-runtime",
"pallet-timestamp/try-runtime",
"runtime-parachains/try-runtime",
"sp-runtime/try-runtime",
]

View file

@ -35,11 +35,9 @@ pub use pallet::*;
use {
crate::types::{EraRewardsUtils, HandleInflation, SendMessage},
frame_support::traits::{Contains, Defensive, Get, ValidatorSet},
frame_support::traits::{Get, ValidatorSet},
pallet_external_validators::traits::{ExternalIndexProvider, OnEraEnd, OnEraStart},
parity_scale_codec::Encode,
polkadot_primitives::ValidatorIndex,
runtime_parachains::session_info,
parity_scale_codec::{Decode, Encode},
snowbridge_merkle_tree::{merkle_proof, merkle_root, verify_proof, MerkleProof},
sp_core::{H160, H256},
sp_runtime::{
@ -47,7 +45,7 @@ use {
Perbill,
},
sp_staking::SessionIndex,
sp_std::{collections::btree_set::BTreeSet, vec::Vec},
sp_std::vec::Vec,
};
/// Trait for checking if a validator has been slashed in a given era
@ -92,14 +90,6 @@ pub mod pallet {
#[pallet::constant]
type HistoryDepth: Get<EraIndex>;
/// The amount of era points given by backing a candidate that is included.
#[pallet::constant]
type BackingPoints: Get<u32>;
/// The amount of era points given by dispute voting on a candidate.
#[pallet::constant]
type DisputeStatementPoints: Get<u32>;
/// Provider to know how may tokens were inflated (added) in a specific era.
type EraInflationProvider: Get<u128>;
@ -108,11 +98,12 @@ 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>;
/// Validator set provider for performance tracking.
/// Requires ValidatorId = AccountId so we can use validators() directly.
type ValidatorSet: frame_support::traits::ValidatorSet<
Self::AccountId,
ValidatorId = Self::AccountId,
>;
/// Check if a validator has been slashed in a given era
type SlashingCheck: SlashingCheck<Self::AccountId>;
@ -134,7 +125,7 @@ pub mod pallet {
/// The remainder (100% - block - liveness) is the unconditional base reward.
type BlockAuthoringWeight: Get<Perbill>;
/// Weight of liveness (heartbeat/block authorship) in the rewards formula.
/// Weight of liveness (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>;
@ -468,9 +459,9 @@ pub mod pallet {
///
/// # 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
/// Uses block authorship as proof of liveness. A validator is considered online if
/// they authored at least one block in the current session. This is simpler and more
/// reliable than ImOnline heartbeats, which have timing issues with session rotation.
///
/// # Weight Validation
///
@ -602,9 +593,11 @@ pub mod pallet {
// 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);
// Liveness score: Use block authorship as proof of liveness.
// A validator who authored at least one block is definitively online.
// This is simpler and more reliable than trying to cache ImOnline state
// which has timing issues with session rotation.
let is_online = blocks_authored > 0;
let liveness_score = if is_online {
Perbill::one()
} else {
@ -750,105 +743,11 @@ pub mod pallet {
}
}
/// Rewards validators for participating in parachains with era points in pallet-staking.
pub struct RewardValidatorsWithEraPoints<C>(core::marker::PhantomData<C>);
impl<C> RewardValidatorsWithEraPoints<C>
where
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(
session_index: SessionIndex,
indices: impl IntoIterator<Item = ValidatorIndex>,
points: u32,
) {
let validators = session_info::AccountKeys::<C>::get(&session_index);
let validators = match validators
.defensive_proof("account_keys are present for dispute_period sessions")
{
Some(validators) => validators,
None => return,
};
// limit rewards to the active validator set
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();
for validator in whitelisted_validators {
active_set.remove(&validator);
}
let rewards = indices
.into_iter()
.filter_map(|i| validators.get(i.0 as usize).cloned())
.filter(|v| active_set.contains(v))
.map(|v| (v, points));
pallet::Pallet::<C>::reward_by_ids(rewards);
}
}
impl<C> runtime_parachains::inclusion::RewardValidators for RewardValidatorsWithEraPoints<C>
where
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();
Self::reward_only_active(session_index, indices, C::BackingPoints::get());
}
fn reward_bitfields(_validators: impl IntoIterator<Item = ValidatorIndex>) {}
}
impl<C> runtime_parachains::disputes::RewardValidators for RewardValidatorsWithEraPoints<C>
where
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,
validators: impl IntoIterator<Item = ValidatorIndex>,
) {
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)
/// - 60% weight: Block production (credited blocks vs fair share)
/// - 30% weight: Liveness (1.0 if authored at least one block, 0.0 otherwise)
/// - 10% weight: Base guarantee (always awarded)
///
/// Wraps an inner SessionManager (typically `NoteHistoricalRoot<ExternalValidators>`) and calls

View file

@ -191,26 +191,6 @@ impl frame_support::traits::ValidatorSet<H160> for MockValidatorSet {
}
}
/// 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<H160> for MockLivenessCheck {
fn contains(validator: &H160) -> 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;
@ -226,13 +206,10 @@ impl pallet_external_validators_rewards::Config for Test {
type RuntimeEvent = RuntimeEvent;
type EraIndexProvider = mock_data::Pallet<Test>;
type HistoryDepth = ConstU32<10>;
type BackingPoints = ConstU32<20>;
type DisputeStatementPoints = ConstU32<20>;
type EraInflationProvider = EraInflationProvider;
type ExternalIndexProvider = TimestampProvider;
type GetWhitelistedValidators = ();
type ValidatorSet = MockValidatorSet;
type LivenessCheck = MockLivenessCheck;
type SlashingCheck = MockSlashingCheck;
type BasePointsPerBlock = BasePointsPerBlock;
type BlockAuthoringWeight = BlockAuthoringWeight;
@ -389,3 +366,12 @@ pub fn run_to_block(n: u64) {
Timestamp::set_timestamp(System::block_number() * BLOCK_TIME + INIT_TIMESTAMP);
}
}
/// Helper function for tests to award session performance points.
pub fn end_session(session_index: u32, validators: Vec<H160>, whitelisted: Vec<H160>) {
ExternalValidatorsRewards::award_session_performance_points(
session_index,
validators,
whitelisted,
);
}

View file

@ -1462,7 +1462,7 @@ fn test_session_performance_60_30_10_formula() {
// MockIsOnline always returns true, so all validators are considered online
// Award session performance points
ExternalValidatorsRewards::award_session_performance_points(
end_session(
1, // session_index
validators.clone(),
vec![], // no whitelisted validators
@ -1473,21 +1473,23 @@ fn test_session_performance_60_30_10_formula() {
// fair_share = 10/4 = 2, max_credited = 2 + 50%×2 = 3
// effective_total_for_other = max(10, 4) = 10
//
// Liveness is determined by block authorship (blocks_authored > 0)
// New formula per validator (with BasePointsPerBlock = 320):
// block_contribution = 60% × credited × 320
// liveness_base_contribution = 40% × 10 × 320 / 4 = 320
// For online validators (authored blocks): liveness_base = 40% × 10 × 320 / 4 = 320
// For offline validators (no blocks): liveness_base = 10% × 10 × 320 / 4 = 80
//
// - Validator 1: 4 blocks → credited=3, block=576, other=320, total=896
// - Validator 2: 4 blocks → credited=3, block=576, other=320, total=896
// - Validator 3: 2 blocks → credited=2, block=384, other=320, total=704
// - Validator 4: 0 blocks → credited=0, block=0, other=320, total=320
// - Validator 1: 4 blocks → online, credited=3, block=576, other=320, total=896
// - Validator 2: 4 blocks → online, credited=3, block=576, other=320, total=896
// - Validator 3: 2 blocks → online, credited=2, block=384, other=320, total=704
// - Validator 4: 0 blocks → offline, credited=0, block=0, other=80, total=80
// Check total points for the active era (era 1)
let era_rewards = pallet_external_validators_rewards::RewardPointsForEra::<Test>::get(1);
assert_eq!(
era_rewards.total,
2816, // 896 + 896 + 704 + 320
"Total points should be 2816"
2576, // 896 + 896 + 704 + 80
"Total points should be 2576"
);
})
}
@ -1519,7 +1521,7 @@ fn test_session_performance_whitelisted_validators_excluded() {
}
// Award session performance points
ExternalValidatorsRewards::award_session_performance_points(1, validators, whitelisted);
end_session(1, validators, whitelisted);
// Fair share and liveness/base both use total validator count:
// 9 blocks total, 3 validators, 2 non-whitelisted
@ -1577,7 +1579,7 @@ fn test_session_performance_whitelisted_fair_share_calculation() {
}
// Award session performance points
ExternalValidatorsRewards::award_session_performance_points(1, validators, whitelisted);
end_session(1, validators, whitelisted);
// Fair share and liveness/base both use total validator count:
// fair_share = 12 total blocks / 4 total validators = 3 blocks
@ -1696,23 +1698,23 @@ fn test_session_performance_zero_total_blocks() {
H160::from_low_u64_be(3),
];
// No blocks authored by anyone
// No blocks authored by anyone - all validators are considered offline
// Award session performance points
ExternalValidatorsRewards::award_session_performance_points(1, validators, vec![]);
end_session(1, validators, vec![]);
// With 0 total blocks, fair_share defaults to 1 (via .max(1))
// effective_total_for_other = max(0, 3) = 3
// Each validator: 0 blocks
// Each validator: 0 blocks → offline (no liveness bonus)
// - block_contribution = 60% × 0 × 320 = 0
// - liveness_base_contribution = 40% × 3 × 320 / 3 = 128
// - total = 128 points
// Total: 3 validators × 128 points = 384 points
// - liveness_base_contribution = 10% × 3 × 320 / 3 = 32 (only base, no liveness)
// - total = 32 points
// Total: 3 validators × 32 points = 96 points
assert_eq!(
pallet_external_validators_rewards::RewardPointsForEra::<Test>::get(1).total,
384,
"Should award liveness + base points even with zero blocks"
96,
"Should award only base points when no blocks authored (all validators offline)"
);
})
}
@ -1745,7 +1747,7 @@ fn test_session_performance_fair_share_capping() {
// effective_total_for_other = max(15, 2) = 15
// Award session performance points
ExternalValidatorsRewards::award_session_performance_points(1, validators, vec![]);
end_session(1, validators, vec![]);
// New formula (with BasePointsPerBlock = 320):
// block_contribution = 60% × credited × 320
@ -1785,7 +1787,7 @@ fn test_session_performance_single_validator() {
ExternalValidatorsRewards::note_block_author(H160::from_low_u64_be(1));
}
ExternalValidatorsRewards::award_session_performance_points(1, validators, vec![]);
end_session(1, validators, vec![]);
// Fair share: 10 / 1 = 10 blocks
// max_credited = 10 + 50%×10 = 15
@ -1818,7 +1820,7 @@ fn test_session_performance_no_active_validators() {
let validators = vec![];
// Award session performance points with empty validator set
ExternalValidatorsRewards::award_session_performance_points(1, validators, vec![]);
end_session(1, validators, vec![]);
// Should handle gracefully without panicking
assert_eq!(
@ -1848,19 +1850,19 @@ fn test_session_performance_checked_math_division() {
H160::from_low_u64_be(3),
];
// Session 1: No blocks produced
ExternalValidatorsRewards::award_session_performance_points(1, validators.clone(), vec![]);
// Session 1: No blocks produced - all validators offline
end_session(1, validators.clone(), vec![]);
// Should not panic, checked_div returns Some or defaults to 1 via .max(1)
// With 0 blocks, effective_total_for_other = max(0, 3) = 3
// Each validator: block_contribution = 0
// liveness_base_contribution = 40% × 3 × 320 / 3 = 128 per validator
// Total for 3 validators = 384 points
// Each validator: block_contribution = 0, offline (no blocks authored)
// liveness_base_contribution = 10% × 3 × 320 / 3 = 32 per validator
// Total for 3 validators = 96 points
let points_after_session1 =
pallet_external_validators_rewards::RewardPointsForEra::<Test>::get(1).total;
assert_eq!(
points_after_session1, 384,
"Should award 384 points (128 per validator) with zero blocks"
points_after_session1, 96,
"Should award 96 points (32 per validator) with zero blocks (all offline)"
);
// Session 2: Author blocks equally among all validators
@ -1870,23 +1872,23 @@ fn test_session_performance_checked_math_division() {
ExternalValidatorsRewards::note_block_author(H160::from_low_u64_be(3));
}
ExternalValidatorsRewards::award_session_performance_points(2, validators, vec![]);
end_session(2, validators, vec![]);
// With 18 blocks (6 per validator):
// fair_share = 18 / 3 = 6, max_credited = 6 + 50%×6 = 9
// effective_total_for_other = max(18, 3) = 18
//
// Each validator: 6 blocks → credited 6
// Each validator: 6 blocks → online, credited 6
// block_contribution = 60% × 6 × 320 = 1152
// liveness_base_contribution = 40% × 18 × 320 / 3 = 768
// Total per validator = 1920
// Total for 3 validators = 5760 points
// Cumulative total = 384 + 5760 = 6144 points
// Cumulative total = 96 + 5760 = 5856 points
let points_after_session2 =
pallet_external_validators_rewards::RewardPointsForEra::<Test>::get(1).total;
assert_eq!(
points_after_session2, 6144,
"Should have 6144 total points (384 from session 1 + 5760 from session 2)"
points_after_session2, 5856,
"Should have 5856 total points (96 from session 1 + 5760 from session 2)"
);
})
}
@ -1911,7 +1913,7 @@ fn test_session_performance_multiple_sessions_cumulative() {
ExternalValidatorsRewards::note_block_author(H160::from_low_u64_be(2));
}
ExternalValidatorsRewards::award_session_performance_points(1, validators.clone(), vec![]);
end_session(1, validators.clone(), vec![]);
let points_after_session1 =
pallet_external_validators_rewards::RewardPointsForEra::<Test>::get(1).total;
@ -1928,7 +1930,7 @@ fn test_session_performance_multiple_sessions_cumulative() {
ExternalValidatorsRewards::note_block_author(H160::from_low_u64_be(2));
}
ExternalValidatorsRewards::award_session_performance_points(2, validators, vec![]);
end_session(2, validators, vec![]);
let points_after_session2 =
pallet_external_validators_rewards::RewardPointsForEra::<Test>::get(1).total;
@ -1960,7 +1962,7 @@ fn test_session_performance_base_reward_points_config() {
ExternalValidatorsRewards::note_block_author(H160::from_low_u64_be(1));
}
ExternalValidatorsRewards::award_session_performance_points(1, validators, vec![]);
end_session(1, validators, vec![]);
// BasePointsPerBlock is 320 (points per block)
// fair_share = 5 blocks, effective_total_for_other = max(5, 1) = 5
@ -2556,7 +2558,7 @@ fn test_session_performance_offline_validator_gets_reduced_points() {
ExternalValidatorsRewards::note_block_author(H160::from_low_u64_be(3));
}
ExternalValidatorsRewards::award_session_performance_points(1, validators, vec![]);
end_session(1, validators, vec![]);
// With 12 blocks total, fair_share = 12 / 3 = 4
// max_credited = 4 + 50%×4 = 6
@ -2622,7 +2624,7 @@ fn test_session_performance_all_validators_offline() {
// No validators author blocks - they are all truly offline
ExternalValidatorsRewards::award_session_performance_points(1, validators, vec![]);
end_session(1, validators, vec![]);
// With 0 blocks total, fair_share = max(0/3, 1) = 1
// effective_total_for_other = max(0, 3) = 3
@ -2672,7 +2674,7 @@ fn test_session_performance_offline_but_authored_blocks() {
ExternalValidatorsRewards::note_block_author(H160::from_low_u64_be(3));
}
ExternalValidatorsRewards::award_session_performance_points(1, validators, vec![]);
end_session(1, validators, vec![]);
// With 18 blocks total, fair_share = 6
// max_credited = 6 + 50%×6 = 9
@ -2722,7 +2724,7 @@ fn test_session_performance_offline_validator_zero_blocks() {
ExternalValidatorsRewards::note_block_author(H160::from_low_u64_be(3));
}
ExternalValidatorsRewards::award_session_performance_points(1, validators, vec![]);
end_session(1, validators, vec![]);
// With 10 blocks total, fair_share = 10 / 3 = 3
// max_credited = 3 + 50%×3 = 4
@ -2775,7 +2777,7 @@ fn test_session_performance_weight_overflow_handled() {
ExternalValidatorsRewards::note_block_author(H160::from_low_u64_be(1));
}
ExternalValidatorsRewards::award_session_performance_points(1, validators, vec![]);
end_session(1, validators, vec![]);
// Verify the formula works with current weights
// fair_share = 10, effective_total_for_other = max(10, 1) = 10
@ -2853,7 +2855,7 @@ fn test_session_performance_slashed_validator_still_gets_points_when_disabled()
ExternalValidatorsRewards::note_block_author(H160::from_low_u64_be(2));
}
ExternalValidatorsRewards::award_session_performance_points(1, validators, vec![]);
end_session(1, validators, vec![]);
// With slashing DISABLED, validator 2 still gets points
// fair_share = 10 / 2 = 5
@ -2910,27 +2912,29 @@ fn test_fair_share_non_integer_division_rounding() {
ExternalValidatorsRewards::note_block_author(H160::from_low_u64_be(1));
}
ExternalValidatorsRewards::award_session_performance_points(1, validators, vec![]);
end_session(1, validators, vec![]);
// New formula with 10 blocks, 3 validators:
// fair_share = 10/3 = 3, max_credited = 3 + 50%×3 = 4
// effective_total_for_other = max(10, 3) = 10
//
// block_contribution = 60% × credited × 320
// liveness_base_contribution = 40% × 10 × 320 / 3 = 1280 / 3 = 426
// Liveness is determined by block authorship (blocks_authored > 0)
//
// Validator 1 (10 blocks): credited=4, block=768, other=426, total=1194
// Validators 2, 3 (0 blocks): block=0, other=426, total=426 each
// Validator 1 (10 blocks): online, credited=4
// block_contribution = 60% × 4 × 320 = 768
// liveness_base_contribution = 40% × 10 × 320 / 3 = 426
// total = 1194
//
// Total = 1194 + 426 + 426 = 2046
// Validators 2, 3 (0 blocks): offline
// block_contribution = 0
// liveness_base_contribution = 10% × 10 × 320 / 3 = 106 (only base, no liveness)
// total = 106 each
//
// This demonstrates the fix for:
// 1. Perbill capping - validator 1 now gets proper over-performance bonus (credited 4 > fair_share 3)
// 2. Fair share truncation - using total_blocks (10) for liveness/base pool, not fair_share×count (9)
// Total = 1194 + 106 + 106 = 1406
let era_rewards = pallet_external_validators_rewards::RewardPointsForEra::<Test>::get(1);
assert_eq!(
era_rewards.total, 2046,
era_rewards.total, 1406,
"Non-integer division should not lose points"
);
})
@ -2966,7 +2970,7 @@ fn test_all_validators_whitelisted_no_panic() {
}
// Should not panic, just skip awarding points
ExternalValidatorsRewards::award_session_performance_points(1, validators, whitelisted);
end_session(1, validators, whitelisted);
let era_rewards = pallet_external_validators_rewards::RewardPointsForEra::<Test>::get(1);
assert_eq!(
@ -3001,27 +3005,29 @@ fn test_blocks_less_than_validators() {
ExternalValidatorsRewards::note_block_author(H160::from_low_u64_be(1));
ExternalValidatorsRewards::note_block_author(H160::from_low_u64_be(1));
ExternalValidatorsRewards::award_session_performance_points(1, validators, vec![]);
end_session(1, validators, vec![]);
// fair_share = 2 / 5 = 0, but .max(1) ensures minimum of 1
// max_credited = 1 + 50%×1 = 1
// effective_total_for_other = max(2, 5) = 5
//
// Liveness is determined by block authorship (blocks_authored > 0)
// Validator 1: 2 blocks, credited = min(2, 1) = 1
// Validator 1: 2 blocks → online, credited = min(2, 1) = 1
// block_contribution = 60% × 1 × 320 = 192
// liveness_base_contribution = 40% × 5 × 320 / 5 = 128
// total = 320
// Validators 2-5: 0 blocks
// Validators 2-5: 0 blocks → offline
// block_contribution = 0
// liveness_base_contribution = 128
// total = 128
// liveness_base_contribution = 10% × 5 × 320 / 5 = 32 (only base)
// total = 32
// Total = 320 + 128×4 = 832
// Total = 320 + 32×4 = 448
let era_rewards = pallet_external_validators_rewards::RewardPointsForEra::<Test>::get(1);
assert_eq!(
era_rewards.total, 832,
era_rewards.total, 448,
"Should handle fewer blocks than validators"
);
})
@ -3056,26 +3062,28 @@ fn test_single_block_many_validators() {
// Only 1 block for 10 validators
ExternalValidatorsRewards::note_block_author(H160::from_low_u64_be(1));
ExternalValidatorsRewards::award_session_performance_points(1, validators, vec![]);
end_session(1, validators, vec![]);
// fair_share = 1 / 10 = 0, but .max(1) ensures minimum of 1
// effective_total_for_other = max(1, 10) = 10
//
// Liveness is determined by block authorship (blocks_authored > 0)
// Validator 1: 1 block, credited = min(1, 1) = 1
// Validator 1: 1 block → online, credited = min(1, 1) = 1
// block_contribution = 60% × 1 × 320 = 192
// liveness_base_contribution = 40% × 10 × 320 / 10 = 128
// total = 320
// Validators 2-10: 0 blocks
// Validators 2-10: 0 blocks → offline
// block_contribution = 0
// liveness_base_contribution = 128
// total = 128 each
// liveness_base_contribution = 10% × 10 × 320 / 10 = 32 (only base)
// total = 32 each
// Total = 320 + 128×9 = 1472
// Total = 320 + 32×9 = 608
let era_rewards = pallet_external_validators_rewards::RewardPointsForEra::<Test>::get(1);
assert_eq!(
era_rewards.total, 1472,
era_rewards.total, 608,
"Should handle 1 block for many validators"
);
})
@ -3116,11 +3124,7 @@ fn test_perbill_precision_many_sessions() {
ExternalValidatorsRewards::note_block_author(H160::from_low_u64_be(3));
}
ExternalValidatorsRewards::award_session_performance_points(
session,
validators.clone(),
vec![],
);
end_session(session, validators.clone(), vec![]);
}
// Verify total points accumulated without overflow or significant precision loss
@ -3231,7 +3235,7 @@ fn test_total_points_sum_equals_expected_pool() {
ExternalValidatorsRewards::note_block_author(H160::from_low_u64_be(4));
}
ExternalValidatorsRewards::award_session_performance_points(1, validators, vec![]);
end_session(1, validators, vec![]);
let era_rewards = pallet_external_validators_rewards::RewardPointsForEra::<Test>::get(1);
@ -3284,7 +3288,7 @@ fn test_total_points_with_uneven_distribution() {
}
// Validator 3 authors no blocks
ExternalValidatorsRewards::award_session_performance_points(1, validators, vec![]);
end_session(1, validators, vec![]);
let era_rewards = pallet_external_validators_rewards::RewardPointsForEra::<Test>::get(1);
@ -3299,22 +3303,22 @@ fn test_total_points_with_uneven_distribution() {
// fair_share = 5, max_credited = 7
// effective_total_for_other = max(15, 3) = 15
//
// Validator 1: 10 blocks → credited = 7 (capped)
// Validator 1: 10 blocks → online, credited = 7 (capped)
// block = 60% × 7 × 320 = 1344
// other = 40% × 15 × 320 / 3 = 640
// total = 1984
//
// Validator 2: 5 blocks → credited = 5
// Validator 2: 5 blocks → online, credited = 5
// block = 60% × 5 × 320 = 960
// other = 640
// total = 1600
//
// Validator 3: 0 blocks
// Validator 3: 0 blocks → offline
// block = 0
// other = 640
// total = 640
// other = 10% × 15 × 320 / 3 = 160 (only base, no liveness)
// total = 160
//
// Total = 1984 + 1600 + 640 = 4224
// Total = 1984 + 1600 + 160 = 3744
assert_eq!(
era_rewards.individual.get(&H160::from_low_u64_be(1)),
@ -3328,10 +3332,10 @@ fn test_total_points_with_uneven_distribution() {
);
assert_eq!(
era_rewards.individual.get(&H160::from_low_u64_be(3)),
Some(&640),
"Validator 3 should have 640 points"
Some(&160),
"Validator 3 should have 160 points (offline, only base)"
);
assert_eq!(era_rewards.total, 4224, "Total should be 4224 points");
assert_eq!(era_rewards.total, 3744, "Total should be 3744 points");
})
}
@ -3368,7 +3372,7 @@ fn test_whitelisted_overproducer_does_not_affect_nonwhitelisted() {
ExternalValidatorsRewards::note_block_author(H160::from_low_u64_be(4));
}
ExternalValidatorsRewards::award_session_performance_points(1, validators, whitelisted);
end_session(1, validators, whitelisted);
// 47 blocks total, 4 validators (1 non-whitelisted)
// fair_share = 47 / 4 = 11
@ -3423,11 +3427,7 @@ fn test_whitelisted_majority_fair_share_calculation() {
}
}
ExternalValidatorsRewards::award_session_performance_points(
1,
validators.clone(),
whitelisted,
);
end_session(1, validators.clone(), whitelisted);
// 30 blocks total, 10 validators
// fair_share = 30 / 10 = 3
@ -3499,7 +3499,7 @@ fn test_large_block_count_no_overflow() {
large_block_count,
);
ExternalValidatorsRewards::award_session_performance_points(1, validators, vec![]);
end_session(1, validators, vec![]);
// Should not panic
let era_rewards = pallet_external_validators_rewards::RewardPointsForEra::<Test>::get(1);
@ -3553,7 +3553,7 @@ fn test_saturating_arithmetic_protection() {
);
// Should not panic due to saturating arithmetic
ExternalValidatorsRewards::award_session_performance_points(1, validators, vec![]);
end_session(1, validators, vec![]);
let era_rewards = pallet_external_validators_rewards::RewardPointsForEra::<Test>::get(1);
assert!(
@ -3589,7 +3589,7 @@ fn test_multiple_sessions_accumulate_to_era_correctly() {
ExternalValidatorsRewards::note_block_author(H160::from_low_u64_be(1));
ExternalValidatorsRewards::note_block_author(H160::from_low_u64_be(2));
}
ExternalValidatorsRewards::award_session_performance_points(1, validators.clone(), vec![]);
end_session(1, validators.clone(), vec![]);
let points_after_s1 =
pallet_external_validators_rewards::RewardPointsForEra::<Test>::get(1).total;
@ -3604,7 +3604,7 @@ fn test_multiple_sessions_accumulate_to_era_correctly() {
ExternalValidatorsRewards::note_block_author(H160::from_low_u64_be(1));
ExternalValidatorsRewards::note_block_author(H160::from_low_u64_be(2));
}
ExternalValidatorsRewards::award_session_performance_points(2, validators.clone(), vec![]);
end_session(2, validators.clone(), vec![]);
let points_after_s2 =
pallet_external_validators_rewards::RewardPointsForEra::<Test>::get(1).total;
@ -3621,7 +3621,7 @@ fn test_multiple_sessions_accumulate_to_era_correctly() {
for _ in 0..20 {
ExternalValidatorsRewards::note_block_author(H160::from_low_u64_be(2));
}
ExternalValidatorsRewards::award_session_performance_points(3, validators.clone(), vec![]);
end_session(3, validators.clone(), vec![]);
let points_after_s3 =
pallet_external_validators_rewards::RewardPointsForEra::<Test>::get(1).total;
@ -3684,7 +3684,7 @@ fn test_era_end_uses_correct_era_blocks_not_session() {
}
// Award session points
ExternalValidatorsRewards::award_session_performance_points(1, validators.clone(), vec![]);
end_session(1, validators.clone(), vec![]);
// Clear session storage (simulating session end)
// This should NOT affect era inflation calculation

View file

@ -379,7 +379,6 @@ impl pallet_session::Config for Runtime {
type ValidatorIdOf = ConvertInto;
type ShouldEndSession = Babe;
type NextSessionRotation = Babe;
// 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>,
@ -1507,24 +1506,6 @@ impl datahaven_runtime_common::rewards_adapter::RewardsSubmissionConfig for Main
pub type RewardsSendAdapter =
datahaven_runtime_common::rewards_adapter::RewardsSubmissionAdapter<MainnetRewardsConfig>;
/// 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 {
@ -1552,18 +1533,10 @@ impl pallet_external_validators_rewards::Config for Runtime {
type RuntimeEvent = RuntimeEvent;
type EraIndexProvider = ExternalValidators;
type HistoryDepth = ConstU32<64>;
// NOT USED: DataHaven is a solochain with BABE+GRANDPA consensus, not a parachain.
// Backing and dispute points are only relevant for parachain validation.
// These are set to 0 to make it explicit they're unused.
type BackingPoints = ConstU32<0>;
type DisputeStatementPoints = ConstU32<0>;
type EraInflationProvider = ExternalRewardsEraInflationProvider;
type ExternalIndexProvider = ExternalValidators;
type GetWhitelistedValidators = GetWhitelistedValidators;
type ValidatorSet = Session;
type LivenessCheck = ValidatorIsOnline;
type SlashingCheck = ValidatorSlashChecker;
type BasePointsPerBlock = ConstU32<320>;
type BlockAuthoringWeight =

View file

@ -1502,24 +1502,6 @@ impl datahaven_runtime_common::rewards_adapter::RewardsSubmissionConfig for Stag
pub type RewardsSendAdapter =
datahaven_runtime_common::rewards_adapter::RewardsSubmissionAdapter<StagenetRewardsConfig>;
/// 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 {
@ -1547,18 +1529,10 @@ impl pallet_external_validators_rewards::Config for Runtime {
type RuntimeEvent = RuntimeEvent;
type EraIndexProvider = ExternalValidators;
type HistoryDepth = ConstU32<64>;
// NOT USED: DataHaven is a solochain with BABE+GRANDPA consensus, not a parachain.
// Backing and dispute points are only relevant for parachain validation.
// These are set to 0 to make it explicit they're unused.
type BackingPoints = ConstU32<0>;
type DisputeStatementPoints = ConstU32<0>;
type EraInflationProvider = ExternalRewardsEraInflationProvider;
type ExternalIndexProvider = ExternalValidators;
type GetWhitelistedValidators = GetWhitelistedValidators;
type ValidatorSet = Session;
type LivenessCheck = ValidatorIsOnline;
type SlashingCheck = ValidatorSlashChecker;
type BasePointsPerBlock = ConstU32<320>;
type BlockAuthoringWeight =
@ -1572,9 +1546,9 @@ impl pallet_external_validators_rewards::Config for Runtime {
type Hashing = Keccak256;
type Currency = Balances;
type RewardsEthereumSovereignAccount = ExternalValidatorRewardsAccount;
type WeightInfo = stagenet_weights::pallet_external_validators_rewards::WeightInfo<Runtime>;
type SendMessage = RewardsSendAdapter;
type HandleInflation = ExternalRewardsInflationHandler;
type WeightInfo = stagenet_weights::pallet_external_validators_rewards::WeightInfo<Runtime>;
#[cfg(feature = "runtime-benchmarks")]
type BenchmarkHelper = ();
}

View file

@ -1506,24 +1506,6 @@ impl datahaven_runtime_common::rewards_adapter::RewardsSubmissionConfig for Test
pub type RewardsSendAdapter =
datahaven_runtime_common::rewards_adapter::RewardsSubmissionAdapter<TestnetRewardsConfig>;
/// 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 {
@ -1551,18 +1533,10 @@ impl pallet_external_validators_rewards::Config for Runtime {
type RuntimeEvent = RuntimeEvent;
type EraIndexProvider = ExternalValidators;
type HistoryDepth = ConstU32<64>;
// NOT USED: DataHaven is a solochain with BABE+GRANDPA consensus, not a parachain.
// Backing and dispute points are only relevant for parachain validation.
// These are set to 0 to make it explicit they're unused.
type BackingPoints = ConstU32<0>;
type DisputeStatementPoints = ConstU32<0>;
type EraInflationProvider = ExternalRewardsEraInflationProvider;
type ExternalIndexProvider = ExternalValidators;
type GetWhitelistedValidators = GetWhitelistedValidators;
type ValidatorSet = Session;
type LivenessCheck = ValidatorIsOnline;
type SlashingCheck = ValidatorSlashChecker;
type BasePointsPerBlock = ConstU32<320>;
type BlockAuthoringWeight =
@ -1576,9 +1550,9 @@ impl pallet_external_validators_rewards::Config for Runtime {
type Hashing = Keccak256;
type Currency = Balances;
type RewardsEthereumSovereignAccount = ExternalValidatorRewardsAccount;
type WeightInfo = testnet_weights::pallet_external_validators_rewards::WeightInfo<Runtime>;
type SendMessage = RewardsSendAdapter;
type HandleInflation = ExternalRewardsInflationHandler;
type WeightInfo = testnet_weights::pallet_external_validators_rewards::WeightInfo<Runtime>;
#[cfg(feature = "runtime-benchmarks")]
type BenchmarkHelper = ();
}

Binary file not shown.