Fix window-based rewards retry flow

This commit is contained in:
Ahmad Kaouk 2026-03-20 11:36:46 +01:00
parent 21ca8072f8
commit 03bd305e37
3 changed files with 363 additions and 296 deletions

View file

@ -43,9 +43,13 @@ fn create_funded_user<T: Config + pallet_balances::Config>(
user
}
/// Helper: insert a single entry into the ring buffer at slot 0.
fn push_unsent_entry<T: Config>(era_index: u32, timestamp: u32, inflation: u128) {
ExternalValidatorsRewards::<T>::unsent_queue_push((era_index, timestamp, inflation));
/// Helper: insert a single queued window into the ring buffer at slot 0.
fn push_unsent_entry<T: Config>(window_start: u32, window_index: u32, duration: u32) {
ExternalValidatorsRewards::<T>::unsent_queue_push(QueuedRewardsWindow {
window_start,
window_index,
duration,
});
}
#[allow(clippy::multiple_bound_locations)]
@ -77,17 +81,20 @@ mod benchmarks {
Ok(())
}
/// Helper to populate reward points for an era with 1000 validators.
fn setup_era_reward_points<T: Config + pallet_balances::Config>(era_index: u32) {
let mut era_reward_points = EraRewardPoints::default();
era_reward_points.total = 20 * 1000;
/// Helper to populate persisted state for a closed window with 1000 operators.
fn setup_window_reward_state<T: Config + pallet_balances::Config>(
window_start: u32,
inflation_amount: u128,
) {
let mut operator_points = sp_std::collections::btree_map::BTreeMap::new();
for i in 0..1000 {
let account_id = create_funded_user::<T>("candidate", i, 100);
era_reward_points.individual.insert(account_id, 20);
let _ = create_funded_user::<T>("candidate", i, 100);
operator_points.insert(sp_core::H160::from_low_u64_be(i as u64 + 1), 20);
}
<RewardPointsForEra<T>>::insert(era_index, era_reward_points);
<WindowOperatorPoints<T>>::insert(window_start, operator_points);
<WindowInflationAmount<T>>::insert(window_start, inflation_amount);
}
// on_initialize: unsent queue is empty (2 reads for head+tail)
@ -104,11 +111,10 @@ mod benchmarks {
Ok(())
}
// on_initialize: oldest entry has pruned reward points
// on_initialize: oldest queued window no longer has persisted state
#[benchmark]
fn process_unsent_reward_eras_expired() -> Result<(), BenchmarkError> {
// Push an entry whose reward points do NOT exist in storage
push_unsent_entry::<T>(999, 0, 42);
push_unsent_entry::<T>(999, 99, 10);
#[block]
{
@ -126,9 +132,9 @@ mod benchmarks {
fn process_unsent_reward_eras_success() -> Result<(), BenchmarkError> {
frame_system::Pallet::<T>::set_block_number(0u32.into());
T::BenchmarkHelper::setup();
setup_era_reward_points::<T>(1);
setup_window_reward_state::<T>(0, 42);
push_unsent_entry::<T>(1, 0, 42);
push_unsent_entry::<T>(0, 0, 10);
#[block]
{
@ -145,9 +151,9 @@ mod benchmarks {
fn process_unsent_reward_eras_failed() -> Result<(), BenchmarkError> {
frame_system::Pallet::<T>::set_block_number(0u32.into());
T::BenchmarkHelper::setup();
setup_era_reward_points::<T>(1);
setup_window_reward_state::<T>(0, 42);
push_unsent_entry::<T>(1, 0, 42);
push_unsent_entry::<T>(0, 0, 10);
#[block]
{
@ -157,20 +163,20 @@ mod benchmarks {
Ok(())
}
// Governance extrinsic: retry a specific unsent era
// Governance extrinsic: retry a specific unsent window
#[benchmark]
fn retry_unsent_reward_era() -> Result<(), BenchmarkError> {
fn retry_unsent_reward_window() -> Result<(), BenchmarkError> {
frame_system::Pallet::<T>::set_block_number(0u32.into());
T::BenchmarkHelper::setup();
setup_era_reward_points::<T>(1);
setup_window_reward_state::<T>(0, 42);
push_unsent_entry::<T>(1, 0, 42);
push_unsent_entry::<T>(0, 0, 10);
let origin =
T::GovernanceOrigin::try_successful_origin().map_err(|_| BenchmarkError::Weightless)?;
#[extrinsic_call]
_(origin as T::RuntimeOrigin, 1u32);
_(origin as T::RuntimeOrigin, 0u32);
assert!(ExternalValidatorsRewards::<T>::unsent_queue_is_empty());

View file

@ -37,7 +37,7 @@ use {
crate::types::{HandleInflation, RewardsPeriodUtils, SendMessage},
frame_support::traits::{Get, UnixTime, ValidatorSet},
pallet_external_validators::traits::{ExternalIndexProvider, OnEraEnd, OnEraStart},
parity_scale_codec::{Decode, Encode},
parity_scale_codec::{Decode, Encode, MaxEncodedLen},
sp_core::{H160, H256},
sp_runtime::{
traits::{Hash, SaturatedConversion},
@ -199,46 +199,50 @@ pub mod pallet {
#[pallet::call]
impl<T: Config> Pallet<T> {
/// Governance escape hatch: manually retry sending a rewards message for
/// an era that is stuck in the unsent queue.
/// a closed window that is stuck in the unsent queue.
#[pallet::call_index(0)]
#[pallet::weight(T::WeightInfo::retry_unsent_reward_era())]
pub fn retry_unsent_reward_era(
pub fn retry_unsent_reward_window(
origin: OriginFor<T>,
era_index: EraIndex,
window_start: u32,
) -> DispatchResult {
T::GovernanceOrigin::ensure_origin(origin)?;
// Scan the ring buffer for the requested era
// Scan the ring buffer for the requested window
let head = UnsentRewardHead::<T>::get();
let tail = UnsentRewardTail::<T>::get();
let mut found = None;
let mut slot = head;
while slot != tail {
if let Some(entry @ (idx, _, _)) = UnsentRewardEra::<T>::get(slot) {
if idx == era_index {
if let Some(entry) = UnsentRewardWindow::<T>::get(slot) {
if entry.window_start == window_start {
found = Some((slot, entry));
break;
}
}
slot = (slot + 1) % UNSENT_QUEUE_CAPACITY;
}
let (slot, (_, timestamp, inflation)) = found.ok_or(Error::<T>::EraNotInUnsentQueue)?;
let (slot, window) = found.ok_or(Error::<T>::WindowNotInUnsentQueue)?;
let reward_points = RewardPointsForEra::<T>::get(era_index);
let info = reward_points
.generate_era_rewards_info(era_index, inflation, timestamp)
.ok_or(Error::<T>::RewardPointsPruned)?;
let info = Self::window_rewards_info(
window.window_start,
window.window_index,
window.duration,
)
.ok_or(Error::<T>::WindowRewardsMissing)?;
let message_id =
Self::send_rewards_message(&info).ok_or(Error::<T>::MessageSendFailed)?;
Self::clear_window(window.window_start);
Self::unsent_queue_remove_slot(slot);
Self::deposit_event(Event::RewardsMessageRetried {
Self::deposit_event(Event::RewardsWindowRetried {
message_id,
era_index,
window_start: window.window_start,
window_index: window.window_index,
total_points: info.total_points,
inflation_amount: inflation,
inflation_amount: info.inflation_amount,
});
Ok(())
@ -256,7 +260,7 @@ pub mod pallet {
total_points: u128,
inflation_amount: u128,
},
/// Window submission failed. Window data has been cleared.
/// Window submission failed on the initial attempt.
RewardsWindowSubmissionFailed {
window_start: u32,
window_index: u32,
@ -266,27 +270,32 @@ pub mod pallet {
window_start: u32,
window_index: u32,
},
/// The rewards message failed to send; era queued for retry.
RewardsMessageSendFailed { era_index: EraIndex },
/// A previously failed rewards message was retried and sent successfully.
RewardsMessageRetried {
/// A previously failed rewards window was retried and sent successfully.
RewardsWindowRetried {
message_id: H256,
era_index: EraIndex,
window_start: u32,
window_index: u32,
total_points: u128,
inflation_amount: u128,
},
/// An unsent era was dropped because its reward points have been pruned.
UnsentEraExpired { era_index: EraIndex },
/// The unsent queue is full; this era could not be enqueued for retry.
UnsentQueueFull { era_index: EraIndex },
/// A queued window was dropped because its stored rewards data is no longer available.
UnsentWindowExpired {
window_start: u32,
window_index: u32,
},
/// The unsent queue is full; this failed window could not be enqueued for retry.
UnsentWindowQueueFull {
window_start: u32,
window_index: u32,
},
}
#[pallet::error]
pub enum Error<T> {
/// The specified era is not in the unsent queue.
EraNotInUnsentQueue,
/// Reward points for the era have been pruned from storage.
RewardPointsPruned,
/// The specified window is not in the unsent queue.
WindowNotInUnsentQueue,
/// Rewards data for the window is no longer available.
WindowRewardsMissing,
/// The message delivery still failed on retry.
MessageSendFailed,
}
@ -298,42 +307,6 @@ pub mod pallet {
pub individual: BTreeMap<AccountId, RewardPoints>,
}
impl<AccountId: Ord + sp_runtime::traits::Debug + Parameter> EraRewardPoints<AccountId> {
/// Generate utils needed for EigenLayer rewards submission from era data.
/// Used by the unsent-era retry mechanism to reconstruct a `RewardsPeriodUtils`
/// from stored era reward points.
pub fn generate_era_rewards_info(
&self,
era_index: EraIndex,
inflation_amount: u128,
era_start_timestamp: u32,
) -> Option<RewardsPeriodUtils> {
let mut individual_points = Vec::with_capacity(self.individual.len());
for (account_id, reward_points) in self.individual.iter() {
// Convert AccountId to H160 for EigenLayer rewards submission.
// In DataHaven, AccountId is H160, so encode() produces exactly 20 bytes.
individual_points
.push((H160::from_slice(&account_id.encode()[..20]), *reward_points));
}
let total_points: u128 = individual_points.iter().map(|(_, pts)| *pts as u128).sum();
if total_points.is_zero() {
return None;
}
Some(RewardsPeriodUtils {
period_index: era_index,
period_start: era_start_timestamp,
duration: 0,
total_points,
individual_points,
inflation_amount,
})
}
}
impl<AccountId> Default for EraRewardPoints<AccountId> {
fn default() -> Self {
EraRewardPoints {
@ -380,20 +353,18 @@ pub mod pallet {
/// Maximum number of unsent reward entries in the ring buffer.
pub const UNSENT_QUEUE_CAPACITY: u32 = 64;
/// Ring buffer of eras whose rewards messages failed to send.
/// Each slot stores (era_index, era_start_timestamp, scaled_inflation).
/// Metadata for a failed rewards window kept in the retry ring buffer.
#[derive(RuntimeDebug, Encode, Decode, MaxEncodedLen, PartialEq, Eq, TypeInfo, Clone, Copy)]
pub struct QueuedRewardsWindow {
pub window_start: u32,
pub window_index: u32,
pub duration: u32,
}
/// Ring buffer of windows whose rewards messages failed to send.
/// Keyed by slot index [0, UNSENT_QUEUE_CAPACITY).
#[pallet::storage]
pub type UnsentRewardEra<T: Config> = StorageMap<
_,
Twox64Concat,
u32,
(
EraIndex,
/* era_start_timestamp */ u32,
/* scaled_inflation */ u128,
),
>;
pub type UnsentRewardWindow<T: Config> = StorageMap<_, Twox64Concat, u32, QueuedRewardsWindow>;
/// Ring buffer head: next slot to be processed by `on_initialize`.
#[pallet::storage]
@ -420,6 +391,10 @@ pub mod pallet {
genesis + (timestamp.saturating_sub(genesis) / interval) * interval
}
fn window_index_for(window_start: u32, genesis: u32, interval: u32) -> u32 {
window_start.saturating_sub(genesis) / interval
}
fn account_to_h160(account_id: &T::AccountId) -> H160 {
H160::from_slice(&account_id.encode()[..20])
}
@ -442,6 +417,29 @@ pub mod pallet {
earliest
}
fn window_rewards_info(
window_start: u32,
window_index: u32,
duration: u32,
) -> Option<RewardsPeriodUtils> {
let inflation_amount = WindowInflationAmount::<T>::get(window_start);
let operator_points = WindowOperatorPoints::<T>::get(window_start);
let total_points: u128 = operator_points.values().map(|p| *p as u128).sum();
if total_points == 0 || inflation_amount == 0 {
return None;
}
Some(RewardsPeriodUtils {
period_index: window_index,
period_start: window_start,
duration,
total_points,
individual_points: operator_points.into_iter().collect(),
inflation_amount,
})
}
fn allocate_era_inflation_to_windows(
era_start: u32,
era_end: u32,
@ -503,7 +501,7 @@ pub mod pallet {
let inflation_amount = WindowInflationAmount::<T>::get(next_window);
let operator_points = WindowOperatorPoints::<T>::get(next_window);
let total_points: u128 = operator_points.values().map(|p| *p as u128).sum();
let window_index = next_window.saturating_sub(genesis) / interval;
let window_index = Self::window_index_for(next_window, genesis, interval);
if total_points == 0 || inflation_amount == 0 {
Self::clear_window(next_window);
@ -539,6 +537,28 @@ pub mod pallet {
window_start: next_window,
window_index,
});
let queued_window = QueuedRewardsWindow {
window_start: next_window,
window_index,
duration: interval,
};
if Self::unsent_queue_push(queued_window) {
next_window = next_window.saturating_add(interval);
continue;
}
log::error!(
target: "ext_validators_rewards",
"Unsent reward queue full, cannot enqueue window {}",
next_window,
);
Self::deposit_event(Event::UnsentWindowQueueFull {
window_start: next_window,
window_index,
});
break;
}
}
@ -618,9 +638,9 @@ pub mod pallet {
tail.wrapping_sub(head) % UNSENT_QUEUE_CAPACITY
}
/// Push a new entry into the ring buffer.
/// Push a new window into the ring buffer.
/// Returns `true` on success, `false` if the buffer is full.
pub(crate) fn unsent_queue_push(entry: (EraIndex, u32, u128)) -> bool {
pub(crate) fn unsent_queue_push(entry: QueuedRewardsWindow) -> bool {
let head = UnsentRewardHead::<T>::get();
let tail = UnsentRewardTail::<T>::get();
let next_tail = (tail + 1) % UNSENT_QUEUE_CAPACITY;
@ -628,7 +648,7 @@ pub mod pallet {
// Buffer full
return false;
}
UnsentRewardEra::<T>::insert(tail, entry);
UnsentRewardWindow::<T>::insert(tail, entry);
UnsentRewardTail::<T>::put(next_tail);
true
}
@ -645,13 +665,13 @@ pub mod pallet {
break;
}
// Move next → cur
if let Some(entry) = UnsentRewardEra::<T>::get(next) {
UnsentRewardEra::<T>::insert(cur, entry);
if let Some(entry) = UnsentRewardWindow::<T>::get(next) {
UnsentRewardWindow::<T>::insert(cur, entry);
}
cur = next;
}
// Remove the now-duplicate last entry and shrink tail
UnsentRewardEra::<T>::remove(cur);
UnsentRewardWindow::<T>::remove(cur);
let new_tail = if tail == 0 {
UNSENT_QUEUE_CAPACITY - 1
} else {
@ -677,9 +697,9 @@ pub mod pallet {
// ── Core retry logic ──────────────────────────────────────────────
/// Process at most one unsent reward era per block.
/// Process at most one unsent reward window per block.
/// On failure the head pointer advances to the next entry so a single
/// stuck era does not block retries for subsequent eras.
/// stuck window does not block retries for subsequent windows.
pub(crate) fn process_unsent_reward_eras() -> Weight {
let head = UnsentRewardHead::<T>::get();
let tail = UnsentRewardTail::<T>::get();
@ -688,55 +708,63 @@ pub mod pallet {
return T::WeightInfo::process_unsent_reward_eras_empty();
}
let Some((era_index, timestamp, inflation)) = UnsentRewardEra::<T>::get(head) else {
let Some(window) = UnsentRewardWindow::<T>::get(head) else {
// Slot unexpectedly empty — advance head past it
UnsentRewardHead::<T>::put((head + 1) % UNSENT_QUEUE_CAPACITY);
return T::WeightInfo::process_unsent_reward_eras_empty();
};
// Check if reward points are still available
let reward_points = RewardPointsForEra::<T>::get(era_index);
let info =
match reward_points.generate_era_rewards_info(era_index, inflation, timestamp) {
Some(info) => info,
None => {
// Reward points have been pruned — discard this entry
log::warn!(
target: "ext_validators_rewards",
"Unsent era {era_index} expired: reward points pruned",
);
UnsentRewardEra::<T>::remove(head);
UnsentRewardHead::<T>::put((head + 1) % UNSENT_QUEUE_CAPACITY);
Self::deposit_event(Event::UnsentEraExpired { era_index });
return T::WeightInfo::process_unsent_reward_eras_expired();
}
};
let info = match Self::window_rewards_info(
window.window_start,
window.window_index,
window.duration,
) {
Some(info) => info,
None => {
log::warn!(
target: "ext_validators_rewards",
"Unsent window {} expired: rewards state missing",
window.window_start,
);
Self::clear_window(window.window_start);
UnsentRewardWindow::<T>::remove(head);
UnsentRewardHead::<T>::put((head + 1) % UNSENT_QUEUE_CAPACITY);
Self::deposit_event(Event::UnsentWindowExpired {
window_start: window.window_start,
window_index: window.window_index,
});
return T::WeightInfo::process_unsent_reward_eras_expired();
}
};
// Attempt to resend
match Self::send_rewards_message(&info) {
Some(message_id) => {
UnsentRewardEra::<T>::remove(head);
Self::clear_window(window.window_start);
UnsentRewardWindow::<T>::remove(head);
UnsentRewardHead::<T>::put((head + 1) % UNSENT_QUEUE_CAPACITY);
Self::deposit_event(Event::RewardsMessageRetried {
Self::deposit_event(Event::RewardsWindowRetried {
message_id,
era_index,
window_start: window.window_start,
window_index: window.window_index,
total_points: info.total_points,
inflation_amount: inflation,
inflation_amount: info.inflation_amount,
});
T::WeightInfo::process_unsent_reward_eras_success()
}
None => {
// Move the failed entry to the back of the queue so the
// next block tries a different era (avoids head-of-line
// next block tries a different window (avoids head-of-line
// blocking). The entry is not lost — it will be retried
// after all other pending entries.
UnsentRewardEra::<T>::remove(head);
UnsentRewardWindow::<T>::remove(head);
UnsentRewardHead::<T>::put((head + 1) % UNSENT_QUEUE_CAPACITY);
UnsentRewardEra::<T>::insert(tail, (era_index, timestamp, inflation));
UnsentRewardWindow::<T>::insert(tail, window);
UnsentRewardTail::<T>::put((tail + 1) % UNSENT_QUEUE_CAPACITY);
log::warn!(
target: "ext_validators_rewards",
"Retry for unsent era {era_index} still failing, moved to back of queue",
"Retry for unsent window {} still failing, moved to back of queue",
window.window_start,
);
T::WeightInfo::process_unsent_reward_eras_failed()
}
@ -1060,17 +1088,27 @@ pub mod pallet {
RewardPointsForEra::<T>::remove(era_index_to_delete);
BlocksProducedInEra::<T>::remove(era_index_to_delete);
// Proactively clean up any unsent entries whose reward points
// have been pruned (this era and any older ones still lingering).
// Proactively clean up any unsent entries whose window state has
// been removed while they were waiting for retry.
let head = UnsentRewardHead::<T>::get();
let mut tail = UnsentRewardTail::<T>::get();
let mut slot = head;
while slot != tail {
if let Some((idx, _, _)) = UnsentRewardEra::<T>::get(slot) {
if idx <= era_index_to_delete {
if let Some(window) = UnsentRewardWindow::<T>::get(slot) {
if Self::window_rewards_info(
window.window_start,
window.window_index,
window.duration,
)
.is_none()
{
Self::clear_window(window.window_start);
Self::unsent_queue_remove_slot(slot);
tail = UnsentRewardTail::<T>::get();
Self::deposit_event(Event::UnsentEraExpired { era_index: idx });
Self::deposit_event(Event::UnsentWindowExpired {
window_start: window.window_start,
window_index: window.window_index,
});
// Don't advance slot — next entry slid into this position
continue;
}

View file

@ -424,15 +424,14 @@ fn window_mode_delivery_failure_emits_submission_failed_event() {
"should not have emitted RewardsWindowSubmitted on delivery failure"
);
// Storage should still be cleared even on failure
// Failed window state should be retained for retry
assert!(
pallet_external_validators_rewards::WindowOperatorPoints::<Test>::get(30).is_empty(),
"window points should be cleared after failed submission"
!pallet_external_validators_rewards::WindowOperatorPoints::<Test>::get(30).is_empty(),
"window points should be retained after failed submission"
);
assert_eq!(
pallet_external_validators_rewards::WindowInflationAmount::<Test>::get(30),
0,
"window inflation should be cleared after failed submission"
assert!(
pallet_external_validators_rewards::WindowInflationAmount::<Test>::get(30) > 0,
"window inflation should be retained after failed submission"
);
// NextWindowToSubmit should still advance
@ -440,6 +439,7 @@ fn window_mode_delivery_failure_emits_submission_failed_event() {
pallet_external_validators_rewards::NextWindowToSubmit::<Test>::get(),
40
);
assert_eq!(unsent_len(), 1, "failed window should be queued for retry");
})
}
@ -4096,14 +4096,30 @@ fn test_era_end_uses_correct_era_blocks_not_session() {
// Retry mechanism tests (ring-buffer storage)
// ═══════════════════════════════════════════════════════════════════════════
/// Helper: push an entry into the unsent ring buffer via the pallet API.
fn push_unsent(era_index: u32, timestamp: u32, inflation: u128) {
/// Helper: push a window entry into the unsent ring buffer via the pallet API.
fn push_unsent(window_start: u32, window_index: u32, duration: u32) {
assert!(
ExternalValidatorsRewards::unsent_queue_push((era_index, timestamp, inflation)),
ExternalValidatorsRewards::unsent_queue_push(crate::QueuedRewardsWindow {
window_start,
window_index,
duration,
}),
"unsent_queue_push should succeed"
);
}
/// Helper: seed persisted rewards state for a closed window.
fn seed_window(window_start: u32, inflation_amount: u128, operator_points: &[(u64, u32)]) {
crate::WindowInflationAmount::<Test>::insert(window_start, inflation_amount);
crate::WindowOperatorPoints::<Test>::insert(
window_start,
operator_points
.iter()
.map(|(operator, points)| (H160::from_low_u64_be(*operator), *points))
.collect::<BTreeMap<_, _>>(),
);
}
/// Helper: return the number of entries in the unsent ring buffer.
fn unsent_len() -> u32 {
ExternalValidatorsRewards::unsent_queue_len()
@ -4115,7 +4131,7 @@ fn unsent_is_empty() -> bool {
}
#[test]
fn send_failure_emits_window_submission_failed() {
fn send_failure_emits_window_submission_failed_and_queues_window() {
new_test_ext().execute_with(|| {
run_to_block(1);
@ -4144,9 +4160,6 @@ fn send_failure_emits_window_submission_failed() {
System::reset_events();
ExternalValidatorsRewards::on_era_end(1);
// Unsent queue should NOT be populated (window approach doesn't use it)
assert!(unsent_is_empty());
// Verify window submission failure event
let window_index = window_start.saturating_sub(genesis) / window_duration;
System::assert_has_event(RuntimeEvent::ExternalValidatorsRewards(
@ -4155,11 +4168,21 @@ fn send_failure_emits_window_submission_failed() {
window_index,
},
));
assert_eq!(unsent_len(), 1, "failed window should be queued for retry");
assert!(
!crate::WindowOperatorPoints::<Test>::get(window_start).is_empty(),
"failed window points must be retained for retry"
);
assert!(
crate::WindowInflationAmount::<Test>::get(window_start) > 0,
"failed window inflation must be retained for retry"
);
})
}
#[test]
fn on_initialize_retries_and_succeeds() {
fn failed_window_is_retried_on_initialize() {
new_test_ext().execute_with(|| {
run_to_block(1);
@ -4168,13 +4191,26 @@ fn on_initialize_retries_and_succeeds() {
index: 1,
start: Some(30_000),
});
mock.send_message_fails = true;
});
// Set up reward points for era 1
ExternalValidatorsRewards::reward_by_ids([(H160::from_low_u64_be(1), 100)]);
for _ in 0..600 {
ExternalValidatorsRewards::note_block_author(H160::from_low_u64_be(1));
}
// Manually populate the unsent queue
push_unsent(1, 30, 42);
let duration = RewardsWindowDuration::get();
let genesis = RewardsWindowGenesisTimestamp::get();
let now = (Timestamp::get() / 1000) as u32;
let window_start = now - (now - genesis) % duration;
let window_index = window_start.saturating_sub(genesis) / duration;
Timestamp::set_timestamp(((window_start + duration + 1) as u64) * 1000);
ExternalValidatorsRewards::on_era_end(1);
assert_eq!(unsent_len(), 1);
Mock::mutate(|mock| mock.send_message_fails = false);
// Sending should succeed (send_message_fails is false by default)
System::reset_events();
@ -4185,9 +4221,49 @@ fn on_initialize_retries_and_succeeds() {
// Verify retry event
System::assert_has_event(RuntimeEvent::ExternalValidatorsRewards(
crate::Event::RewardsMessageRetried {
crate::Event::RewardsWindowRetried {
message_id: Default::default(),
era_index: 1,
window_start,
window_index,
total_points: 100,
inflation_amount: 30,
},
));
assert!(
crate::WindowOperatorPoints::<Test>::get(window_start).is_empty(),
"window points should be cleared after successful retry"
);
assert_eq!(
crate::WindowInflationAmount::<Test>::get(window_start),
0,
"window inflation should be cleared after successful retry"
);
})
}
#[test]
fn on_initialize_retries_and_succeeds() {
new_test_ext().execute_with(|| {
run_to_block(1);
let window_start = 30;
let window_index = 3;
let duration = RewardsWindowDuration::get();
seed_window(window_start, 42, &[(1, 100)]);
push_unsent(window_start, window_index, duration);
System::reset_events();
ExternalValidatorsRewards::process_unsent_reward_eras();
assert!(unsent_is_empty());
System::assert_has_event(RuntimeEvent::ExternalValidatorsRewards(
crate::Event::RewardsWindowRetried {
message_id: Default::default(),
window_start,
window_index,
total_points: 100,
inflation_amount: 42,
},
@ -4201,75 +4277,64 @@ fn on_initialize_moves_failed_entry_to_back() {
run_to_block(1);
Mock::mutate(|mock| {
mock.active_era = Some(ActiveEraInfo {
index: 2,
start: Some(30_000),
});
mock.send_message_fails = true;
});
// Set up reward points for eras 1 and 2
ExternalValidatorsRewards::reward_by_ids([(H160::from_low_u64_be(1), 100)]);
Mock::mutate(|mock| {
mock.active_era = Some(ActiveEraInfo {
index: 1,
start: Some(30_000),
});
});
ExternalValidatorsRewards::reward_by_ids([(H160::from_low_u64_be(1), 200)]);
let duration = RewardsWindowDuration::get();
seed_window(30, 42, &[(1, 100)]);
seed_window(40, 84, &[(1, 200)]);
// Push two entries: era 1 then era 2
push_unsent(1, 30, 42);
push_unsent(2, 30, 84);
push_unsent(30, 3, duration);
push_unsent(40, 4, duration);
// First call: tries era 1, fails, moves era 1 to back of queue
ExternalValidatorsRewards::process_unsent_reward_eras();
// Queue length stays the same (entry moved, not removed)
assert_eq!(unsent_len(), 2);
// Second call: tries era 2 (NOT era 1 again), fails, moves era 2 to back
// First call: tries window 30, fails, moves it to the back of the queue
ExternalValidatorsRewards::process_unsent_reward_eras();
assert_eq!(unsent_len(), 2);
// Second call: tries window 40, fails, moves it to the back
ExternalValidatorsRewards::process_unsent_reward_eras();
assert_eq!(unsent_len(), 2);
// Re-enable sending
Mock::mutate(|mock| mock.send_message_fails = false);
// Third call: era 1 (now at front again), succeeds
// Third call: window 30 (now at front again), succeeds
System::reset_events();
ExternalValidatorsRewards::process_unsent_reward_eras();
assert_eq!(unsent_len(), 1);
System::assert_has_event(RuntimeEvent::ExternalValidatorsRewards(
crate::Event::RewardsMessageRetried {
crate::Event::RewardsWindowRetried {
message_id: Default::default(),
era_index: 1,
total_points: 200,
window_start: 30,
window_index: 3,
total_points: 100,
inflation_amount: 42,
},
));
// Fourth call: era 2, succeeds
// Fourth call: window 40 succeeds
ExternalValidatorsRewards::process_unsent_reward_eras();
assert!(unsent_is_empty());
})
}
#[test]
fn on_initialize_removes_expired_era() {
fn on_initialize_removes_expired_window() {
new_test_ext().execute_with(|| {
run_to_block(1);
// Populate unsent queue with era 999 but do NOT add RewardPointsForEra for it
push_unsent(999, 0, 42);
let duration = RewardsWindowDuration::get();
push_unsent(999, 99, duration);
System::reset_events();
ExternalValidatorsRewards::process_unsent_reward_eras();
// Entry should be removed
assert!(unsent_is_empty());
// Verify expired event
System::assert_has_event(RuntimeEvent::ExternalValidatorsRewards(
crate::Event::UnsentEraExpired { era_index: 999 },
crate::Event::UnsentWindowExpired {
window_start: 999,
window_index: 99,
},
));
})
}
@ -4296,31 +4361,16 @@ fn on_initialize_processes_only_head() {
new_test_ext().execute_with(|| {
run_to_block(1);
Mock::mutate(|mock| {
mock.active_era = Some(ActiveEraInfo {
index: 3,
start: Some(30_000),
});
});
let duration = RewardsWindowDuration::get();
seed_window(30, 42, &[(1, 100)]);
seed_window(40, 84, &[(2, 200)]);
// Set up reward points for both eras
ExternalValidatorsRewards::reward_by_ids([(H160::from_low_u64_be(1), 100)]);
Mock::mutate(|mock| {
mock.active_era = Some(ActiveEraInfo {
index: 2,
start: Some(30_000),
});
});
ExternalValidatorsRewards::reward_by_ids([(H160::from_low_u64_be(2), 200)]);
// Push two entries
push_unsent(3, 30, 42);
push_unsent(2, 20, 84);
push_unsent(30, 3, duration);
push_unsent(40, 4, duration);
System::reset_events();
ExternalValidatorsRewards::process_unsent_reward_eras();
// Only the head entry (era 3) should be processed (and removed on success)
assert_eq!(unsent_len(), 1);
})
}
@ -4330,33 +4380,25 @@ fn retry_extrinsic_success() {
new_test_ext().execute_with(|| {
run_to_block(1);
Mock::mutate(|mock| {
mock.active_era = Some(ActiveEraInfo {
index: 1,
start: Some(30_000),
});
});
// Set up reward points
ExternalValidatorsRewards::reward_by_ids([(H160::from_low_u64_be(1), 100)]);
// Populate unsent queue
push_unsent(1, 30, 42);
let window_start = 30;
let window_index = 3;
let duration = RewardsWindowDuration::get();
seed_window(window_start, 42, &[(1, 100)]);
push_unsent(window_start, window_index, duration);
System::reset_events();
assert_ok!(ExternalValidatorsRewards::retry_unsent_reward_era(
assert_ok!(ExternalValidatorsRewards::retry_unsent_reward_window(
RuntimeOrigin::root(),
1
window_start
));
// Queue should be empty
assert!(unsent_is_empty());
// Verify retry event
System::assert_has_event(RuntimeEvent::ExternalValidatorsRewards(
crate::Event::RewardsMessageRetried {
crate::Event::RewardsWindowRetried {
message_id: Default::default(),
era_index: 1,
window_start,
window_index,
total_points: 100,
inflation_amount: 42,
},
@ -4365,28 +4407,27 @@ fn retry_extrinsic_success() {
}
#[test]
fn retry_extrinsic_era_not_in_queue() {
fn retry_extrinsic_window_not_in_queue() {
new_test_ext().execute_with(|| {
run_to_block(1);
assert_noop!(
ExternalValidatorsRewards::retry_unsent_reward_era(RuntimeOrigin::root(), 1),
crate::Error::<Test>::EraNotInUnsentQueue
ExternalValidatorsRewards::retry_unsent_reward_window(RuntimeOrigin::root(), 1),
crate::Error::<Test>::WindowNotInUnsentQueue
);
})
}
#[test]
fn retry_extrinsic_pruned_data() {
fn retry_extrinsic_missing_window_state() {
new_test_ext().execute_with(|| {
run_to_block(1);
// Queue an era but don't create reward points for it
push_unsent(999, 0, 42);
push_unsent(999, 99, RewardsWindowDuration::get());
assert_noop!(
ExternalValidatorsRewards::retry_unsent_reward_era(RuntimeOrigin::root(), 999),
crate::Error::<Test>::RewardPointsPruned
ExternalValidatorsRewards::retry_unsent_reward_window(RuntimeOrigin::root(), 999),
crate::Error::<Test>::WindowRewardsMissing
);
})
}
@ -4397,7 +4438,7 @@ fn retry_extrinsic_requires_root() {
run_to_block(1);
assert_noop!(
ExternalValidatorsRewards::retry_unsent_reward_era(
ExternalValidatorsRewards::retry_unsent_reward_window(
RuntimeOrigin::signed(H160::from_low_u64_be(1)),
1
),
@ -4407,7 +4448,7 @@ fn retry_extrinsic_requires_root() {
}
#[test]
fn send_failure_clears_window_and_advances_pointer() {
fn send_failure_retains_window_and_advances_pointer() {
new_test_ext().execute_with(|| {
run_to_block(1);
@ -4436,45 +4477,45 @@ fn send_failure_clears_window_and_advances_pointer() {
System::reset_events();
ExternalValidatorsRewards::on_era_end(1);
// Even though send failed, the window data should be cleared
// and the next_window pointer should have advanced
let next_window = crate::NextWindowToSubmit::<Test>::get();
assert!(
next_window > window_start,
"NextWindowToSubmit should advance past the failed window"
);
// Window storage should be cleared
assert!(
crate::WindowOperatorPoints::<Test>::get(window_start).is_empty(),
"Window operator points should be cleared after failed submission"
!crate::WindowOperatorPoints::<Test>::get(window_start).is_empty(),
"Window operator points should be retained after failed submission"
);
assert!(
crate::WindowInflationAmount::<Test>::get(window_start) > 0,
"Window inflation should be retained after failed submission"
);
assert_eq!(
crate::WindowInflationAmount::<Test>::get(window_start),
0,
"Window inflation should be cleared after failed submission"
unsent_len(),
1,
"failed window should be present in retry queue"
);
})
}
#[test]
fn on_era_start_prunes_unsent_entry() {
fn on_era_start_prunes_unsent_window_without_state() {
new_test_ext().execute_with(|| {
run_to_block(1);
// Set up: era 1 has an unsent entry
push_unsent(1, 0, 42);
push_unsent(30, 3, RewardsWindowDuration::get());
// HistoryDepth is 10, so era 11 should prune era 1
System::reset_events();
ExternalValidatorsRewards::on_era_start(11, 0, 11);
// Unsent entry should be removed
assert!(unsent_is_empty());
// Verify expired event
System::assert_has_event(RuntimeEvent::ExternalValidatorsRewards(
crate::Event::UnsentEraExpired { era_index: 1 },
crate::Event::UnsentWindowExpired {
window_start: 30,
window_index: 3,
},
));
})
}
@ -4484,26 +4525,15 @@ fn retry_extrinsic_send_still_fails() {
new_test_ext().execute_with(|| {
run_to_block(1);
Mock::mutate(|mock| {
mock.active_era = Some(ActiveEraInfo {
index: 1,
start: Some(30_000),
});
mock.send_message_fails = true;
});
// Set up reward points
ExternalValidatorsRewards::reward_by_ids([(H160::from_low_u64_be(1), 100)]);
// Populate unsent queue
push_unsent(1, 30, 42);
Mock::mutate(|mock| mock.send_message_fails = true);
seed_window(30, 42, &[(1, 100)]);
push_unsent(30, 3, RewardsWindowDuration::get());
assert_noop!(
ExternalValidatorsRewards::retry_unsent_reward_era(RuntimeOrigin::root(), 1),
ExternalValidatorsRewards::retry_unsent_reward_window(RuntimeOrigin::root(), 30),
crate::Error::<Test>::MessageSendFailed
);
// Queue should still have the entry
assert_eq!(unsent_len(), 1);
})
}
@ -4513,57 +4543,50 @@ fn head_of_line_blocking_avoided() {
new_test_ext().execute_with(|| {
run_to_block(1);
// Set up reward points for eras 1, 2, 3
for era in 1..=3u32 {
Mock::mutate(|mock| {
mock.active_era = Some(ActiveEraInfo {
index: era,
start: Some(30_000),
});
});
ExternalValidatorsRewards::reward_by_ids([(H160::from_low_u64_be(1), 100)]);
}
let duration = RewardsWindowDuration::get();
seed_window(30, 10, &[(1, 100)]);
seed_window(40, 20, &[(1, 100)]);
seed_window(50, 30, &[(1, 100)]);
// Push eras 1, 2, 3 into the queue
push_unsent(1, 30, 10);
push_unsent(2, 30, 20);
push_unsent(3, 30, 30);
push_unsent(30, 3, duration);
push_unsent(40, 4, duration);
push_unsent(50, 5, duration);
// Make sending fail
Mock::mutate(|mock| mock.send_message_fails = true);
// Block 1: tries era 1, fails, advances head → era 2
// Block 1: tries window 30, fails, advances head → window 40
ExternalValidatorsRewards::process_unsent_reward_eras();
// Block 2: tries era 2, fails, advances head → era 3
// Block 2: tries window 40, fails, advances head → window 50
ExternalValidatorsRewards::process_unsent_reward_eras();
// Now re-enable sending
Mock::mutate(|mock| mock.send_message_fails = false);
// Block 3: tries era 3, succeeds
// Block 3: tries window 50, succeeds
System::reset_events();
ExternalValidatorsRewards::process_unsent_reward_eras();
System::assert_has_event(RuntimeEvent::ExternalValidatorsRewards(
crate::Event::RewardsMessageRetried {
crate::Event::RewardsWindowRetried {
message_id: Default::default(),
era_index: 3,
window_start: 50,
window_index: 5,
total_points: 100,
inflation_amount: 30,
},
));
// Block 4: wraps around to era 1, succeeds
// Block 4: wraps around to window 30, succeeds
ExternalValidatorsRewards::process_unsent_reward_eras();
System::assert_has_event(RuntimeEvent::ExternalValidatorsRewards(
crate::Event::RewardsMessageRetried {
crate::Event::RewardsWindowRetried {
message_id: Default::default(),
era_index: 1,
window_start: 30,
window_index: 3,
total_points: 100,
inflation_amount: 10,
},
));
// Block 5: era 2, succeeds
// Block 5: window 40 succeeds
ExternalValidatorsRewards::process_unsent_reward_eras();
assert!(unsent_is_empty());
})