fix(security): address critical audit findings in presale and validator-pool pallets

presale:
- Split unbounded finalize_presale distribution into batched batch_distribute()
  extrinsic (same pattern as batch_refund_failed_presale) to prevent block weight
  exhaustion with many contributors
- Fix u128 overflow in calculate_reward_dynamic() by using
  multiply_by_rational_with_rounding() for safe intermediate multiplication
- Fix pre-existing batch_refund test assertion (platform fee deduction was not
  accounted for in expected refund amount)

validator-pool:
- Bound PoolMembers::iter() with .take(MaxPoolSize) in select_validators_for_era()
  to prevent unbounded iteration in on_initialize
- Fix on_initialize weight accounting to include all DB reads/writes from
  do_new_era() and select_validators_for_era() (was only counting 2 reads)
This commit is contained in:
2026-03-21 15:33:25 +03:00
parent 420ed35169
commit f1a7a7f872
3 changed files with 192 additions and 85 deletions
+19 -4
View File
@@ -354,20 +354,33 @@ pub mod pezpallet {
#[pezpallet::hooks]
impl<T: Config> Hooks<BlockNumberFor<T>> for Pezpallet<T> {
fn on_initialize(block_number: BlockNumberFor<T>) -> Weight {
let mut weight = Weight::zero();
// Always account for the era_start + era_length reads
let mut weight = T::DbWeight::get().reads(2);
// Check if we need to transition to new era
let era_start = Self::era_start();
let era_length = Self::era_length();
if block_number >= era_start + era_length && era_length > Zero::zero() {
weight = weight.saturating_add(T::DbWeight::get().reads(2));
// Account for all DB operations in do_new_era + select_validators_for_era:
// - PoolMembers::iter() reads up to MaxPoolSize entries
// - SelectionHistory::get() per member
// - PerformanceMetrics::get() per member
// - CurrentEra, EraStart, CurrentValidatorSet writes
// - SelectionHistory::mutate per selected validator
let pool_size = Self::pool_size();
weight = weight.saturating_add(
T::DbWeight::get().reads(pool_size as u64 * 2) // iter + history per member
);
weight = weight.saturating_add(
T::DbWeight::get().writes(3 + pool_size as u64) // era state + history updates
);
// Trigger new era if enough time has passed
if Self::do_new_era().is_err() {
// Log error but don't panic
}
weight = weight.saturating_add(T::WeightInfo::force_new_era(Self::pool_size()));
weight = weight.saturating_add(T::WeightInfo::force_new_era(pool_size));
}
weight
@@ -730,7 +743,9 @@ pub mod pezpallet {
let mut random_index = 0u32;
// Collect eligible validators by category
for (validator, category) in PoolMembers::<T>::iter() {
// Bounded by MaxPoolSize to prevent unbounded iteration in on_initialize
let max_pool = T::MaxPoolSize::get() as usize;
for (validator, category) in PoolMembers::<T>::iter().take(max_pool) {
// Skip if selected in last 3 eras (rotation rule)
let history = SelectionHistory::<T>::get(&validator);
let current_era = Self::current_era();