Add rewards cutover migration and checks

This commit is contained in:
Ahmad Kaouk 2026-03-20 15:28:40 +01:00
parent 03bd305e37
commit 89e2db7ab7
3 changed files with 246 additions and 10 deletions

View file

@ -53,7 +53,7 @@ fn push_unsent_entry<T: Config>(window_start: u32, window_index: u32, duration:
}
#[allow(clippy::multiple_bound_locations)]
#[benchmarks(where T: pallet_balances::Config)]
#[benchmarks(where T: pallet_balances::Config + pallet_timestamp::Config<Moment = u64>)]
mod benchmarks {
use super::*;
@ -72,6 +72,14 @@ mod benchmarks {
T::BenchmarkHelper::setup();
<RewardPointsForEra<T>>::insert(1u32, era_reward_points);
pallet_timestamp::Now::<T>::put(35_000u64);
#[cfg(test)]
crate::mock::Mock::mutate(|mock| {
mock.active_era = Some(pallet_external_validators::traits::ActiveEraInfo {
index: 1,
start: Some(30_000),
});
});
#[block]
{

View file

@ -72,7 +72,7 @@ pub mod pallet {
};
/// The current storage version.
const STORAGE_VERSION: StorageVersion = StorageVersion::new(1);
const STORAGE_VERSION: StorageVersion = StorageVersion::new(2);
pub type RewardPoints = u32;
pub type EraIndex = u32;
@ -194,6 +194,86 @@ pub mod pallet {
fn on_initialize(_n: frame_system::pallet_prelude::BlockNumberFor<T>) -> Weight {
Self::process_unsent_reward_eras()
}
fn on_runtime_upgrade() -> Weight {
let on_chain = Pallet::<T>::on_chain_storage_version();
if on_chain >= STORAGE_VERSION {
return T::DbWeight::get().reads(1);
}
let cutover_era = T::EraIndexProvider::active_era().index.saturating_add(1);
// This upgrade intentionally drops any pre-window retry state and
// defers window accounting until the next full era so the current
// in-flight era is not partially accounted under mixed semantics.
WindowModeStartsAtEra::<T>::put(cutover_era);
NextWindowToSubmit::<T>::kill();
for slot in 0..UNSENT_QUEUE_CAPACITY {
UnsentRewardWindow::<T>::remove(slot);
}
UnsentRewardHead::<T>::put(0);
UnsentRewardTail::<T>::put(0);
STORAGE_VERSION.put::<Pallet<T>>();
log::info!(
target: "ext_validators_rewards",
"Migrated rewards pallet to storage version 2. Window mode will start at era {}.",
cutover_era,
);
T::DbWeight::get().reads_writes(2, 69)
}
#[cfg(feature = "try-runtime")]
fn pre_upgrade() -> Result<Vec<u8>, sp_runtime::TryRuntimeError> {
let on_chain = Pallet::<T>::on_chain_storage_version();
let active_era = T::EraIndexProvider::active_era().index;
let needs_upgrade = on_chain < STORAGE_VERSION;
Ok((needs_upgrade, active_era).encode())
}
#[cfg(feature = "try-runtime")]
fn post_upgrade(state: Vec<u8>) -> Result<(), sp_runtime::TryRuntimeError> {
let (needs_upgrade, active_era): (bool, EraIndex) = Decode::decode(&mut &state[..])
.map_err(|_| "Failed to decode pre-upgrade state")?;
if !needs_upgrade {
frame_support::ensure!(
Pallet::<T>::on_chain_storage_version() >= STORAGE_VERSION,
"Storage version regressed on a no-op upgrade",
);
return Ok(());
}
frame_support::ensure!(
Pallet::<T>::on_chain_storage_version() == STORAGE_VERSION,
"Rewards pallet storage version was not upgraded",
);
frame_support::ensure!(
WindowModeStartsAtEra::<T>::get() == active_era.saturating_add(1),
"Window cutover era was not initialized correctly",
);
frame_support::ensure!(
NextWindowToSubmit::<T>::get() == 0,
"NextWindowToSubmit was not reset",
);
frame_support::ensure!(
UnsentRewardHead::<T>::get() == 0,
"UnsentRewardHead was not reset",
);
frame_support::ensure!(
UnsentRewardTail::<T>::get() == 0,
"UnsentRewardTail was not reset",
);
frame_support::ensure!(
UnsentRewardWindow::<T>::iter().next().is_none(),
"UnsentRewardWindow entries were not cleared",
);
Ok(())
}
}
#[pallet::call]
@ -350,6 +430,11 @@ pub mod pallet {
#[pallet::storage]
pub type NextWindowToSubmit<T: Config> = StorageValue<_, u32, ValueQuery>;
/// Era at which window mode becomes active after a live upgrade.
/// `0` means window mode is active immediately (fresh chains/tests).
#[pallet::storage]
pub type WindowModeStartsAtEra<T: Config> = StorageValue<_, EraIndex, ValueQuery>;
/// Maximum number of unsent reward entries in the ring buffer.
pub const UNSENT_QUEUE_CAPACITY: u32 = 64;
@ -380,6 +465,11 @@ pub mod pallet {
T::UnixTime::now().as_secs().saturated_into::<u32>()
}
fn window_mode_is_active(era_index: EraIndex) -> bool {
let start_era = WindowModeStartsAtEra::<T>::get();
start_era == 0 || era_index >= start_era
}
fn rewards_window_config() -> (u32, u32) {
(
T::RewardsWindowGenesisTimestamp::get(),
@ -575,6 +665,7 @@ pub mod pallet {
let now = Self::now_seconds();
let (genesis, interval) = Self::rewards_window_config();
let window_start = Self::window_start_for(now, genesis, interval);
let window_mode_active = Self::window_mode_is_active(active_era.index);
RewardPointsForEra::<T>::mutate(active_era.index, |era_rewards| {
for (validator, points) in points.into_iter() {
@ -582,13 +673,15 @@ pub mod pallet {
.saturating_accrue(points);
era_rewards.total.saturating_accrue(points);
let operator = Self::account_to_h160(&validator);
WindowOperatorPoints::<T>::mutate(window_start, |operators| {
operators
.entry(operator)
.and_modify(|existing| *existing = existing.saturating_add(points))
.or_insert(points);
});
if window_mode_active {
let operator = Self::account_to_h160(&validator);
WindowOperatorPoints::<T>::mutate(window_start, |operators| {
operators
.entry(operator)
.and_modify(|existing| *existing = existing.saturating_add(points))
.or_insert(points);
});
}
}
})
}
@ -1120,6 +1213,15 @@ pub mod pallet {
impl<T: Config> OnEraEnd for Pallet<T> {
fn on_era_end(era_index: EraIndex) {
if !Self::window_mode_is_active(era_index) {
log::info!(
target: "ext_validators_rewards",
"Skipping transition-era rewards for era {} until window mode cutover",
era_index,
);
return;
}
// Calculate performance-scaled inflation based on blocks produced.
let base_inflation = T::EraInflationProvider::get();
let scaled_inflation = Self::calculate_scaled_inflation(era_index, base_inflation);

View file

@ -16,7 +16,10 @@
use {
crate::{self as pallet_external_validators_rewards, mock::*},
frame_support::{assert_noop, assert_ok, traits::fungible::Mutate},
frame_support::{
assert_noop, assert_ok,
traits::{fungible::Mutate, GetStorageVersion, Hooks, StorageVersion},
},
pallet_external_validators::traits::{ActiveEraInfo, OnEraEnd, OnEraStart},
sp_core::H160,
sp_std::collections::btree_map::BTreeMap,
@ -66,6 +69,129 @@ fn can_reward_validators() {
})
}
#[test]
fn runtime_upgrade_schedules_window_mode_for_next_era_and_resets_retry_state() {
new_test_ext().execute_with(|| {
Mock::mutate(|mock| {
mock.active_era = Some(ActiveEraInfo {
index: 7,
start: Some(70_000),
})
});
StorageVersion::new(1).put::<crate::Pallet<Test>>();
crate::WindowModeStartsAtEra::<Test>::put(0);
crate::NextWindowToSubmit::<Test>::put(123);
crate::UnsentRewardWindow::<Test>::insert(
5,
crate::QueuedRewardsWindow {
window_start: 100,
window_index: 10,
duration: 10,
},
);
crate::UnsentRewardHead::<Test>::put(5);
crate::UnsentRewardTail::<Test>::put(9);
let _ = <crate::Pallet<Test> as Hooks<u64>>::on_runtime_upgrade();
assert_eq!(crate::WindowModeStartsAtEra::<Test>::get(), 8);
assert_eq!(crate::NextWindowToSubmit::<Test>::get(), 0);
assert_eq!(crate::UnsentRewardWindow::<Test>::iter().count(), 0);
assert_eq!(crate::UnsentRewardHead::<Test>::get(), 0);
assert_eq!(crate::UnsentRewardTail::<Test>::get(), 0);
assert_eq!(
<crate::Pallet<Test>>::on_chain_storage_version(),
StorageVersion::new(2)
);
})
}
#[test]
fn transition_era_does_not_write_window_points_before_cutover() {
new_test_ext().execute_with(|| {
run_to_block(5); // now = 35s
RewardsWindowGenesisTimestamp::set(20);
RewardsWindowDuration::set(10);
crate::WindowModeStartsAtEra::<Test>::put(2);
Mock::mutate(|mock| {
mock.active_era = Some(ActiveEraInfo {
index: 1,
start: Some(20_000),
})
});
ExternalValidatorsRewards::reward_by_ids([(H160::from_low_u64_be(1), 10)]);
assert_eq!(
crate::RewardPointsForEra::<Test>::get(1).total,
10,
"era accounting should still accumulate during the discarded transition era"
);
assert!(
crate::WindowOperatorPoints::<Test>::get(30).is_empty(),
"window accounting must remain off until the cutover era"
);
Mock::mutate(|mock| {
mock.active_era = Some(ActiveEraInfo {
index: 2,
start: Some(40_000),
})
});
ExternalValidatorsRewards::reward_by_ids([(H160::from_low_u64_be(1), 10)]);
assert_eq!(
crate::WindowOperatorPoints::<Test>::get(30).get(&H160::from_low_u64_be(1)),
Some(&10),
"window accounting should start once the cutover era is reached"
);
})
}
#[test]
fn transition_era_on_era_end_is_skipped_before_cutover() {
new_test_ext().execute_with(|| {
run_to_block(5); // now = 35s
RewardsWindowGenesisTimestamp::set(20);
RewardsWindowDuration::set(10);
crate::WindowModeStartsAtEra::<Test>::put(2);
Mock::mutate(|mock| {
mock.active_era = Some(ActiveEraInfo {
index: 1,
start: Some(20_000),
})
});
ExternalValidatorsRewards::reward_by_ids([(H160::from_low_u64_be(1), 10)]);
for _ in 0..600 {
ExternalValidatorsRewards::note_block_author(H160::from_low_u64_be(1));
}
System::reset_events();
ExternalValidatorsRewards::on_era_end(1);
assert!(
!System::events().iter().any(|record| matches!(
&record.event,
RuntimeEvent::ExternalValidatorsRewards(
crate::Event::RewardsWindowSubmitted { .. }
| crate::Event::RewardsWindowSubmissionFailed { .. }
| crate::Event::RewardsWindowSkipped { .. }
)
)),
"transition era should not emit window-processing events before cutover"
);
assert_eq!(crate::WindowInflationAmount::<Test>::iter().count(), 0);
assert_eq!(crate::NextWindowToSubmit::<Test>::get(), 0);
})
}
#[test]
fn window_mode_attributes_points_to_aligned_window() {
new_test_ext().execute_with(|| {