diff --git a/operator/pallets/external-validators-rewards/src/benchmarking.rs b/operator/pallets/external-validators-rewards/src/benchmarking.rs index 2b06aca1..2fd3701d 100644 --- a/operator/pallets/external-validators-rewards/src/benchmarking.rs +++ b/operator/pallets/external-validators-rewards/src/benchmarking.rs @@ -53,7 +53,7 @@ fn push_unsent_entry(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)] mod benchmarks { use super::*; @@ -72,6 +72,14 @@ mod benchmarks { T::BenchmarkHelper::setup(); >::insert(1u32, era_reward_points); + pallet_timestamp::Now::::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] { diff --git a/operator/pallets/external-validators-rewards/src/lib.rs b/operator/pallets/external-validators-rewards/src/lib.rs index 457998f7..a4342504 100644 --- a/operator/pallets/external-validators-rewards/src/lib.rs +++ b/operator/pallets/external-validators-rewards/src/lib.rs @@ -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) -> Weight { Self::process_unsent_reward_eras() } + + fn on_runtime_upgrade() -> Weight { + let on_chain = Pallet::::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::::put(cutover_era); + NextWindowToSubmit::::kill(); + for slot in 0..UNSENT_QUEUE_CAPACITY { + UnsentRewardWindow::::remove(slot); + } + UnsentRewardHead::::put(0); + UnsentRewardTail::::put(0); + STORAGE_VERSION.put::>(); + + 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, sp_runtime::TryRuntimeError> { + let on_chain = Pallet::::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) -> 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::::on_chain_storage_version() >= STORAGE_VERSION, + "Storage version regressed on a no-op upgrade", + ); + return Ok(()); + } + + frame_support::ensure!( + Pallet::::on_chain_storage_version() == STORAGE_VERSION, + "Rewards pallet storage version was not upgraded", + ); + frame_support::ensure!( + WindowModeStartsAtEra::::get() == active_era.saturating_add(1), + "Window cutover era was not initialized correctly", + ); + frame_support::ensure!( + NextWindowToSubmit::::get() == 0, + "NextWindowToSubmit was not reset", + ); + frame_support::ensure!( + UnsentRewardHead::::get() == 0, + "UnsentRewardHead was not reset", + ); + frame_support::ensure!( + UnsentRewardTail::::get() == 0, + "UnsentRewardTail was not reset", + ); + frame_support::ensure!( + UnsentRewardWindow::::iter().next().is_none(), + "UnsentRewardWindow entries were not cleared", + ); + + Ok(()) + } } #[pallet::call] @@ -350,6 +430,11 @@ pub mod pallet { #[pallet::storage] pub type NextWindowToSubmit = 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 = 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::() } + fn window_mode_is_active(era_index: EraIndex) -> bool { + let start_era = WindowModeStartsAtEra::::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::::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::::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::::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 OnEraEnd for Pallet { 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); diff --git a/operator/pallets/external-validators-rewards/src/tests.rs b/operator/pallets/external-validators-rewards/src/tests.rs index 5a71ceaa..453248d8 100644 --- a/operator/pallets/external-validators-rewards/src/tests.rs +++ b/operator/pallets/external-validators-rewards/src/tests.rs @@ -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::WindowModeStartsAtEra::::put(0); + crate::NextWindowToSubmit::::put(123); + crate::UnsentRewardWindow::::insert( + 5, + crate::QueuedRewardsWindow { + window_start: 100, + window_index: 10, + duration: 10, + }, + ); + crate::UnsentRewardHead::::put(5); + crate::UnsentRewardTail::::put(9); + + let _ = as Hooks>::on_runtime_upgrade(); + + assert_eq!(crate::WindowModeStartsAtEra::::get(), 8); + assert_eq!(crate::NextWindowToSubmit::::get(), 0); + assert_eq!(crate::UnsentRewardWindow::::iter().count(), 0); + assert_eq!(crate::UnsentRewardHead::::get(), 0); + assert_eq!(crate::UnsentRewardTail::::get(), 0); + assert_eq!( + >::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::::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::::get(1).total, + 10, + "era accounting should still accumulate during the discarded transition era" + ); + assert!( + crate::WindowOperatorPoints::::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::::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::::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::::iter().count(), 0); + assert_eq!(crate::NextWindowToSubmit::::get(), 0); + }) +} + #[test] fn window_mode_attributes_points_to_aligned_window() { new_test_ext().execute_with(|| {