diff --git a/pallets/parachain-staking/Cargo.toml b/pallets/parachain-staking/Cargo.toml index 2c1dd839..df48a258 100644 --- a/pallets/parachain-staking/Cargo.toml +++ b/pallets/parachain-staking/Cargo.toml @@ -44,6 +44,7 @@ runtime-benchmarks = [ "frame-system/runtime-benchmarks", ] std = [ + "frame-benchmarking?/std", "frame-support/std", "frame-system/std", "log/std", diff --git a/pallets/parachain-staking/src/benchmarking.rs b/pallets/parachain-staking/src/benchmarking.rs index 91bd636a..6618d875 100644 --- a/pallets/parachain-staking/src/benchmarking.rs +++ b/pallets/parachain-staking/src/benchmarking.rs @@ -28,7 +28,7 @@ use frame_system::{pallet_prelude::BlockNumberFor, Pallet as System, RawOrigin}; use pallet_session::Pallet as Session; use sp_runtime::{ traits::{One, SaturatedConversion, StaticLookup}, - Permill, + Permill, Saturating, }; use sp_std::{convert::TryInto, vec::Vec}; @@ -139,13 +139,11 @@ benchmarks! { assert_eq!(>::get().current, 0u32); } - on_initialize_round_update { - let round = >::get(); - assert_eq!(round.current, 0u32); - }: { Pallet::::on_initialize(round.length) } - verify { - assert_eq!(>::get().current, 1u32); - } + // NOTE: the `on_initialize_round_update` benchmark was removed -- round rotation moved + // from this pallet's on_initialize to pallet_session (start_session), so on_initialize no + // longer advances the round and the old benchmark asserted behaviour that cannot happen. + // The WeightInfo::on_initialize_round_update method is left in place (now unused) rather + // than removed here, as that touches the trait contract beyond this change. force_new_round { let round = >::get(); @@ -287,12 +285,12 @@ benchmarks! { // go to block in which we can exit assert_ok!(>::init_leave_candidates(RawOrigin::Signed(candidate.clone()).into())); - for i in 1..=T::ExitQueueDelay::get() { - let round = >::get(); - let now = round.first + round.length; - System::::set_block_number(now); - Pallet::::on_initialize(now); - } + // Round rotation now lives in pallet_session, so this pallet's on_initialize no longer + // advances the round. Bump the round counter directly by ExitQueueDelay so the exit + // delay elapses and the candidate can execute its leave (can_exit checks Round.current). + >::mutate(|round| { + round.current = round.current.saturating_add(T::ExitQueueDelay::get()); + }); let unlookup_candidate = T::Lookup::unlookup(candidate.clone()); }: _(RawOrigin::Signed(candidate.clone()), unlookup_candidate) @@ -610,6 +608,75 @@ benchmarks! { // let state = >::get(&collator).unwrap(); // assert!(state.delegators.into_iter().any(|x| x.owner == delegator); // } + + // Session-boundary (snapshot) cost: at each rotation, prepare_delayed_rewards snapshots ALL + // selected collators (n), each carrying its delegators (m), into AtStake in ONE block -- the + // heaviest single block in the payout flow (payout_collator, by contrast, is one collator per + // block). Benchmarking this gives the 64x100 worst-case block weight/PoV for block-stuck + // analysis. NOTE: this measures pre-existing session-rotation code, not the restake feature. + prepare_delayed_rewards { + let n in (T::MinCollators::get()) .. T::MaxTopCandidates::get(); + let m in 0 .. T::MaxDelegatorsPerCollator::get(); + + // n collators, each with m delegators, in the live CandidatePool + let candidates = setup_collator_candidates::(n, None); + for (i, c) in candidates.iter().enumerate() { + fill_delegators::(m, c.clone(), i.saturated_into::()); + } + + // seed the previous round (old_round = 1): each collator's snapshot + one authored + // block, so get_total_collator_staking_num has data to sum during the call + let old_round: u32 = 1; + for c in candidates.iter() { + let state = CandidatePool::::get(c).unwrap(); + AtStake::::insert(old_round, c, state); + CollatorBlocks::::insert(old_round, c, 1u32); + } + let pot = Pallet::::account_id(); + T::Currency::make_free_balance_be(&pot, T::MinCollatorCandidateStake::get()); + // current round = old_round + 1: snapshots `candidates` into the new round and computes + // old_round's payout totals -- the heaviest per-block work in the whole flow + Round::::mutate(|r| { r.current = old_round + 1; }); + }: { + Pallet::::prepare_delayed_rewards(&candidates, old_round + 1); + } + verify { + assert!(DelayedPayoutInfo::::exists()); + } + + payout_collator { + let n in 0 .. T::MaxDelegatorsPerCollator::get(); + + // one authoring collator with `n` delegators (present in live state + the snapshot) + let candidates = setup_collator_candidates::(T::MinCollators::get(), None); + let collator = candidates[0].clone(); + fill_delegators::(n, collator.clone(), 0u32); + + // stand up the payout state for round 1: snapshot, one authored block, delayed info + let round: u32 = 1; + let state = CandidatePool::::get(&collator).unwrap(); + let total_stake = state.total; + AtStake::::insert(round, &collator, state); + CollatorBlocks::::insert(round, &collator, 1u32); + + // fund the pot generously so every delegator earns a nonzero (restakeable) reward + let issuance = T::MinCollatorCandidateStake::get(); + let pot = Pallet::::account_id(); + T::Currency::make_free_balance_be(&pot, issuance.saturating_add(issuance)); + DelayedPayoutInfo::::put(crate::types::DelayedPayoutInfoT { + round, + total_stake, + total_issuance: issuance, + }); + // current round must be non-zero, else payout_collator early-returns + Round::::mutate(|r| { r.current = round + 1; }); + }: { + Pallet::::payout_collator(); + } + verify { + // the collator's snapshot was consumed (its delegators were paid + restaked) + assert!(AtStake::::get(round, &collator).is_none()); + } } impl_benchmark_test_suite!( diff --git a/pallets/parachain-staking/src/lib.rs b/pallets/parachain-staking/src/lib.rs index c3363bf4..41c81e4f 100644 --- a/pallets/parachain-staking/src/lib.rs +++ b/pallets/parachain-staking/src/lib.rs @@ -509,6 +509,13 @@ pub mod pallet { /// A collator or a delegator has received a reward. /// \[account, amount of reward\] Rewarded(T::AccountId, BalanceOf), + /// A delegator's reward was restaked into their delegation (auto-compound). + /// \[delegator, collator candidate, reward amount\] + DelegatorRewardRestaked(T::AccountId, T::AccountId, BalanceOf), + /// A delegator's reward was paid out without restaking, because it could not be + /// safely restaked onto the collator (delegator gone, candidate leaving, or + /// inconsistent state). \[delegator, collator candidate, reward amount\] + DelegatorRewardPaidNotRestaked(T::AccountId, T::AccountId, BalanceOf), /// The maximum number of collator candidates selected in future /// validation rounds has changed. \[old value, new value\] MaxSelectedCandidatesSet(u32, u32), @@ -524,10 +531,30 @@ pub mod pallet { #[pallet::hooks] impl Hooks> for Pallet { fn on_initialize(_now: BlockNumberFor) -> frame_support::weights::Weight { - // on_finalize weight - // At worst, we have to make 'MaxSelectedCandidates + 2' number of deletions from - // AtStake - T::DbWeight::get().reads_writes(6u64, (MaxSelectedCandidates::::get() + 2).into()) + // The on_finalize payout only does heavy work on a block with a pending payout + // (DelayedPayoutInfo set): it pays one collator and restakes up to + // MaxDelegatorsPerCollator delegators, or (once every author is paid) clears the + // round's AtStake snapshots. Reserve that cost ONLY on those blocks; idle blocks + // must not permanently lose the capacity. + // + // The exists() gate is correct only because Session's on_initialize (which sets + // DelayedPayoutInfo at a session boundary) runs BEFORE this pallet's -- true on + // peaq (Session = 21 < ParachainStaking = 23). Reversing that order would + // under-reserve on a boundary block. + if DelayedPayoutInfo::::exists() { + // Reserve the larger of the two on_finalize branches: paying one collator + + // MaxDelegatorsPerCollator restakes, or clearing MaxSelectedCandidates snapshots. + let payout = ::WeightInfo::payout_collator( + T::MaxDelegatorsPerCollator::get(), + ); + let cleanup = T::DbWeight::get() + .reads_writes(6u64, (MaxSelectedCandidates::::get() + 2).into()); + payout.max(cleanup) + } else { + // No payout pending -> payout_collator returns after reading DelayedPayoutInfo + + // Round. + T::DbWeight::get().reads(3) + } } fn on_runtime_upgrade() -> frame_support::weights::Weight { @@ -537,6 +564,11 @@ pub mod pallet { fn on_finalize(_n: BlockNumberFor) { Self::payout_collator(); } + + #[cfg(feature = "try-runtime")] + fn try_state(_n: BlockNumberFor) -> Result<(), sp_runtime::TryRuntimeError> { + Self::do_try_state().map_err(Into::into) + } } /// The maximum number of collator candidates selected at each round. @@ -1798,7 +1830,7 @@ pub mod pallet { let candidate = T::Lookup::lookup(candidate)?; let mut delegations = DelegatorState::::get(&delegator).ok_or(Error::::DelegatorNotFound)?; - let mut collator = + let collator = CandidatePool::::get(&candidate).ok_or(Error::::CandidateNotFound)?; ensure!(!collator.is_leaving(), Error::::CannotDelegateIfLeaving); let _delegator_total = delegations @@ -1810,25 +1842,9 @@ pub mod pallet { // update lock let unstaking_len = Self::increase_lock(&delegator, delegations.total, more)?; - let CandidateOf:: { stake: before_stake, total: before_total, .. } = collator; - collator.inc_delegator(delegator.clone(), more); - let after = collator.total; - - // update top candidates and total amount at stake - let n = if collator.is_active() { - Self::update_top_candidates( - candidate.clone(), - before_stake, - // safe because total >= stake - before_total - before_stake, - collator.stake, - collator.total - collator.stake, - ) - } else { - 0u32 - }; - - CandidatePool::::insert(&candidate, collator); + let before_total = collator.total; + let (n, after) = + Self::commit_candidate_delegation_increase(&candidate, &delegator, collator, more); DelegatorState::::insert(&delegator, delegations); Self::deposit_event(Event::DelegatorStakedMore( @@ -2646,16 +2662,191 @@ pub mod pallet { Ok(unstaking_len) } - /// Process the coinbase rewards for the production of a new block. - /// - /// # - /// Weight: O(1) - /// - Reads: Balance - /// - Writes: Balance - /// # + /// Raise the `STAKING_ID` lock to cover `new_active_total` plus any pending + /// unstaking, WITHOUT consuming `Unstaking`. Used when restaking a reward: + /// unlike `increase_lock`, the new money must not cancel a queued unstake. + /// + /// Precondition: `new_active_total` MUST already include the amount being restaked + /// (i.e. call this AFTER `inc_delegation`), or the lock is set too low. + fn increase_lock_from_reward(who: &T::AccountId, new_active_total: BalanceOf) { + let pending_unstaking = >::get(who) + .iter() + .fold(BalanceOf::::zero(), |acc, (_, amount)| acc.saturating_add(*amount)); + let new_lock = new_active_total.saturating_add(pending_unstaking); + T::Currency::set_lock(STAKING_ID, who, new_lock, WithdrawReasons::all()); + } + + /// Fold a delegation increase into the candidate record: bump the candidate, refresh + /// the top-candidate set, and persist `CandidatePool`. Returns (top-candidate weight + /// count, candidate's new total). Shared by `delegator_stake_more` and the reward + /// restake path -- they differ only in how the lock is raised, whether they touch + /// `Unstaking`, and which event they emit. Caller writes `DelegatorState` + event. + fn commit_candidate_delegation_increase( + candidate: &T::AccountId, + delegator: &T::AccountId, + mut collator: CandidateOf, + more: BalanceOf, + ) -> (u32, BalanceOf) { + let CandidateOf:: { stake: before_stake, total: before_total, .. } = collator; + collator.inc_delegator(delegator.clone(), more); + let after = collator.total; + // inc_delegator is a silent no-op if the coupled invariant is violated (the + // delegator is absent from the candidate's set although the delegator side lists + // this candidate). The Mandatory payout hook cannot revert, so surface a divergence + // via a log instead of letting it pass unnoticed. No panic: a debug_assert could + // halt a debug-built runtime inside on_finalize, which this design forbids. + if after != before_total.saturating_add(more) { + log::warn!( + target: "parachain-staking", + "candidate-side delegation increase did not grow total by the expected \ + amount; coupled delegator/candidate invariant may be violated", + ); + } + let n = if collator.is_active() { + Self::update_top_candidates( + candidate.clone(), + before_stake, + // safe because total >= stake + before_total - before_stake, + collator.stake, + collator.total - collator.stake, + ) + } else { + 0u32 + }; + CandidatePool::::insert(candidate, collator); + (n, after) + } + + /// Pay a delegator's reward by restaking it into their delegation, or fall back to + /// a plain payout when it cannot be safely restaked. + /// + /// Two-phase: PHASE 1 preflights every fallible check (reads only, no writes, no + /// transfer); PHASE 2 moves the reward then commits infallibly. Ordering matters + /// because on_finalize is `Mandatory` and cannot revert or panic -- a "transfer + /// then fail mid-commit" split-brain must be impossible. + /// + /// T4 reject guards (each falls back to a plain, non-restaked payout): missing + /// `DelegatorState`; missing `CandidatePool` or candidate `is_leaving()`; delegator + /// no longer delegates this candidate. These mirror `delegator_stake_more`. + /// Deliberately NOT mirrored: its free-balance check (the reward is transferred in + /// first, so balance always covers the new lock) and its MaxDelegationsPerRound + /// counter (that caps *user* delegations per round; a reward restake is not one). + /// Arithmetic uses saturating adds: a reward bounded by pot issuance added to an + /// existing stake cannot overflow u128 in practice, so there is no overflow branch. + /// Restake one delegator's reward into the shared in-memory `candidate`, or fall back + /// to a plain payout when it cannot be restaked. The caller owns `candidate` and flushes + /// it (top-candidate re-rank + CandidatePool write) exactly ONCE after the whole delegator + /// loop -- see `payout_collator`. Because every delegator in a payout delegates the SAME + /// collator, batching that flush avoids an O(m) full-struct write per delegator while + /// keeping per-delegator atomicity: a delegator's transfer, lock, candidate mutation and + /// DelegatorState write commit together, and a transfer failure cleanly skips that + /// delegator (`inc_delegator` runs only after the transfer succeeds). + /// + /// The caller has already confirmed the candidate is present and not leaving, so PHASE 1 + /// here only checks the delegator side (exists and still delegates this candidate). + fn restake_delegator_reward( + pot: &T::AccountId, + candidate: &mut CandidateOf, + collator_id: &T::AccountId, + delegator_id: &T::AccountId, + reward: BalanceOf, + ) { + // Skip zero rewards entirely to avoid state churn (no transfer, no restake, no event). + if reward.is_zero() { + return + } + // PHASE 1: preflight (reads + checks only; no writes, no transfer) + let mut delegations = match DelegatorState::::get(delegator_id) { + Some(d) => d, + None => + return Self::pay_delegator_reward_not_restaked( + pot, + collator_id, + delegator_id, + reward, + ), + }; + if delegations.inc_delegation(collator_id.clone(), reward).is_none() { + return Self::pay_delegator_reward_not_restaked( + pot, + collator_id, + delegator_id, + reward, + ) + } + + // PHASE 2: commit (move the reward in, then lock + restake it) + if T::Currency::transfer(pot, delegator_id, reward, KeepAlive).is_err() { + // on_finalize is Mandatory and cannot propagate: a failed transfer skips this + // delegator (no lock/restake/state change). Log so an underfunded pot is visible + // instead of silently dropping the reward. + log::error!( + target: "parachain-staking", + "restake payout: transfer pot -> delegator {:?} failed (reward {:?}); skipped", + delegator_id, reward, + ); + return + } + // Raise the lock by exactly the reward, leaving pending Unstaking untouched + // (reward is new money; it must not cancel a queued unstake). + Self::increase_lock_from_reward(delegator_id, delegations.total); + + // PHASE 1 checked delegator-side membership (inc_delegation); the candidate-side + // inc_delegator is assumed to match via the coupled invariant every extrinsic + // maintains (a delegation exists on both sides or neither). inc_delegator is a silent + // no-op on divergence; a Mandatory hook cannot revert, so we surface (not fix) it via + // a log. The caller flushes `candidate` once after the loop. + let before = candidate.total; + candidate.inc_delegator(delegator_id.clone(), reward); + if candidate.total != before.saturating_add(reward) { + log::warn!( + target: "parachain-staking", + "restake: candidate-side total did not grow by the reward; coupled \ + delegator/candidate invariant may be violated", + ); + } + DelegatorState::::insert(delegator_id, delegations); + // Distinct event so indexers can tell restaked from plain-paid rewards. + Self::deposit_event(Event::DelegatorRewardRestaked( + delegator_id.clone(), + collator_id.clone(), + reward, + )); + } + + /// Pay a delegator's reward as a plain balance transfer (no restake), emitting the + /// distinct fallback event. Used when the reward cannot be safely restaked. + fn pay_delegator_reward_not_restaked( + pot: &T::AccountId, + collator_id: &T::AccountId, + delegator_id: &T::AccountId, + reward: BalanceOf, + ) { + if T::Currency::transfer(pot, delegator_id, reward, KeepAlive).is_ok() { + Self::deposit_event(Event::DelegatorRewardPaidNotRestaked( + delegator_id.clone(), + collator_id.clone(), + reward, + )); + } else { + log::error!( + target: "parachain-staking", + "payout: transfer pot -> delegator {:?} failed (reward {:?}); skipped", + delegator_id, reward, + ); + } + } + fn do_reward(pot: &T::AccountId, who: &T::AccountId, reward: BalanceOf) { if let Ok(_success) = T::Currency::transfer(pot, who, reward, KeepAlive) { Self::deposit_event(Event::Rewarded(who.clone(), reward)); + } else { + log::error!( + target: "parachain-staking", + "collator payout: transfer pot -> {:?} failed (reward {:?}); skipped", + who, reward, + ); } } @@ -2715,7 +2906,7 @@ pub mod pallet { ) -> Reward> { let delegator_sum = (&stake.delegators) .into_iter() - .fold(T::CurrencyBalance::from(0u128), |acc, x| acc + x.amount); + .fold(T::CurrencyBalance::from(0u128), |acc, x| acc.saturating_add(x.amount)); // issue_number = block_num * (state.total - delegator_sum) / total_staking_in_session let nominator = T::CurrencyBalance::from(block_num) @@ -2745,8 +2936,8 @@ pub mod pallet { } else { Reward { owner: stake.id.clone(), - amount: percentage * issue_number + - stake.commission.mul(delegator_percentage * issue_number), + amount: (percentage * issue_number) + .saturating_add(stake.commission.mul(delegator_percentage * issue_number)), } } } @@ -2782,14 +2973,26 @@ pub mod pallet { } else { Reward { owner: x.owner.clone(), - amount: percentage * issue_number - - stake.commission.mul(percentage * issue_number), + amount: (percentage * issue_number) + .saturating_sub(stake.commission.mul(percentage * issue_number)), } } }) .collect::>>>(); - inner.try_into().expect("Did not extend vec q.e.d.") + // `inner` is a 1:1 map over `stake.delegators` (bounded by MaxDelegatorsPerCollator), + // so it can never exceed the bound. truncate_from never panics (unlike expect); this + // runs in on_finalize (Mandatory), which must not panic. Log the impossible case. + let expected_len = inner.len(); + let bounded = BoundedVec::<_, T::MaxDelegatorsPerCollator>::truncate_from(inner); + if bounded.len() != expected_len { + log::error!( + target: "parachain-staking", + "delegator reward vec exceeded MaxDelegatorsPerCollator; {} dropped", + expected_len.saturating_sub(bounded.len()), + ); + } + bounded } /// Get a unique, inaccessible account id from the `PotId`. @@ -2797,10 +3000,74 @@ pub mod pallet { T::PotId::get().into_account_truncating() } + /// AC-8 invariant (partial-write safety net): every candidate's `total` must equal + /// its self-stake plus the sum of its delegators' stakes. A restake that updated + /// some fields but not the candidate total / a delegator amount would break this. + /// Callable from tests and the try-runtime `try_state` hook. + #[cfg(any(test, feature = "try-runtime"))] + pub(crate) fn do_try_state() -> Result<(), &'static str> { + for (_id, candidate) in CandidatePool::::iter() { + let delegator_sum = (&candidate.delegators) + .into_iter() + .fold(BalanceOf::::zero(), |acc, s| acc.saturating_add(s.amount)); + ensure!( + candidate.total == candidate.stake.saturating_add(delegator_sum), + "candidate.total must equal self stake + sum(delegator stakes)" + ); + } + for (who, state) in DelegatorState::::iter() { + let deleg_sum = (&state.delegations) + .into_iter() + .fold(BalanceOf::::zero(), |acc, s| acc.saturating_add(s.amount)); + ensure!( + state.total == deleg_sum, + "delegator.total must equal sum(per-collator stakes)" + ); + // The reward restake raises the lock and the delegation total in separate + // steps; this catches any divergence (the spec's primary invariant). + let unstaking_sum = >::get(&who) + .iter() + .fold(BalanceOf::::zero(), |acc, (_, amt)| acc.saturating_add(*amt)); + let staking_lock = pallet_balances::Pallet::::locks(&who) + .iter() + .find(|l| l.id == STAKING_ID) + .map(|l| l.amount) + .unwrap_or_else(Zero::zero); + ensure!( + staking_lock == state.total.saturating_add(unstaking_sum).into(), + "STAKING_ID lock must equal active total + pending unstaking" + ); + } + // The loop above only checks accounts still in DelegatorState. An account that + // fully revoked its delegation is removed from DelegatorState but keeps pending + // Unstaking until unlock_unstaked; its only remaining STAKING_ID lock must equal + // that pending amount. Candidates are skipped (their lock also covers self-stake, + // which is not verified here). + for (who, unstaking) in >::iter() { + if DelegatorState::::contains_key(&who) || CandidatePool::::contains_key(&who) + { + continue + } + let unstaking_sum = unstaking + .iter() + .fold(BalanceOf::::zero(), |acc, (_, amt)| acc.saturating_add(*amt)); + let staking_lock = pallet_balances::Pallet::::locks(&who) + .iter() + .find(|l| l.id == STAKING_ID) + .map(|l| l.amount) + .unwrap_or_else(Zero::zero); + ensure!( + staking_lock == unstaking_sum.into(), + "fully-exited account's STAKING_ID lock must equal pending unstaking" + ); + } + Ok(()) + } + /// Handles staking reward payout for previous session for one collator and their delegators /// At Worst: 5 DB Reads and 'MaxSelectedCandidate + 1' DB Writes /// Complexity: O(n) - fn payout_collator() { + pub(crate) fn payout_collator() { // if there's no previous round, i.e, genesis round, then skip if Self::round().current.is_zero() { return @@ -2812,7 +3079,7 @@ pub mod pallet { { let pot = Self::account_id(); // get collator's staking info - if let Some(state) = AtStake::::take(payout_info.round, author) { + if let Some(state) = AtStake::::take(payout_info.round, &author) { // calculate reward for collator from previous round let now_reward = Self::get_collator_reward_per_session( &state, @@ -2830,9 +3097,43 @@ pub mod pallet { payout_info.total_issuance, ); - now_rewards.into_iter().for_each(|x| { - Self::do_reward(&pot, &x.owner, x.amount); + // Read the shared candidate ONCE: every delegator in this payout + // delegates the same collator, so all restakes accumulate into one + // in-memory struct and it is flushed once below (batch -- Spec §3), + // instead of an O(m) full-struct write per delegator. A missing or + // leaving candidate cannot receive restakes -> each delegator is paid + // plainly. + let mut candidate = + CandidatePool::::get(&author).filter(|c| !c.is_leaving()); + let before = candidate.as_ref().map(|c| (c.stake, c.total)); + + now_rewards.into_iter().for_each(|x| match candidate.as_mut() { + Some(c) => + Self::restake_delegator_reward(&pot, c, &author, &x.owner, x.amount), + None => Self::pay_delegator_reward_not_restaked( + &pot, &author, &x.owner, x.amount, + ), }); + + // Flush the shared candidate ONCE: re-rank it in the top set and write + // the pool a single time, only when a restake actually grew its total. + if let (Some(candidate), Some((before_stake, before_total))) = + (candidate, before) + { + if candidate.total != before_total { + if candidate.is_active() { + Self::update_top_candidates( + author.clone(), + before_stake, + // safe because total >= stake + before_total - before_stake, + candidate.stake, + candidate.total - candidate.stake, + ); + } + CandidatePool::::insert(&author, candidate); + } + } } } else { // Kill storage @@ -2887,8 +3188,11 @@ pub mod pallet { collators: &[T::AccountId], session_index: SessionIndex, ) -> Weight { - let mut reads = Weight::from_parts(1_u64, 0); - let mut writes = Weight::from_parts(1_u64, 0); + // Worst-case components for the weight: n selected collators snapshotted, each + // with up to MaxDelegatorsPerCollator delegators. Weight comes from the + // prepare_delayed_rewards benchmark (WeightInfo), not a manual read/write count. + let n = collators.len() as u32; + let m = T::MaxDelegatorsPerCollator::get(); // get updated RoundInfo let round = >::get().current; @@ -2897,24 +3201,22 @@ pub mod pallet { for collator in collators.iter() { if let Some(collator_state) = CandidatePool::::get(collator) { >::insert(round, collator, collator_state); - reads = reads.saturating_add(Weight::from_parts(1_u64, 0)); - writes = reads.saturating_add(Weight::from_parts(1_u64, 0)); } } // if prepare_delayed_rewards is called by SessionManager::new_session_genesis, we skip // this part if session_index.is_zero() { - return T::DbWeight::get().reads_writes(reads.ref_time(), writes.ref_time()); + return ::WeightInfo::prepare_delayed_rewards(n, m); } let old_round = round - 1; - // Get total collator staking number of round that is ending - let (in_reads, total_stake) = Self::get_total_collator_staking_num(old_round); + // Get total collator staking number of round that is ending. The weight returned by + // this helper is ignored here -- prepare_delayed_rewards's benchmark already covers it. + let (_, total_stake) = Self::get_total_collator_staking_num(old_round); // Get total issuance of round that is ending - let (issuance_weight, total_issuance) = Self::pot_issuance(); - reads = reads.saturating_add(in_reads).saturating_add(issuance_weight); + let (_, total_issuance) = Self::pot_issuance(); // take snapshot of previous session's staking totals for payout calculation DelayedPayoutInfo::::put(DelayedPayoutInfoT { @@ -2922,9 +3224,8 @@ pub mod pallet { total_stake, total_issuance, }); - writes = writes.saturating_add(Weight::from_parts(1_u64, 0)); - T::DbWeight::get().reads_writes(reads.ref_time(), writes.ref_time()) + ::WeightInfo::prepare_delayed_rewards(n, m) } } diff --git a/pallets/parachain-staking/src/tests.rs b/pallets/parachain-staking/src/tests.rs index 0b3dff9f..2c16691b 100644 --- a/pallets/parachain-staking/src/tests.rs +++ b/pallets/parachain-staking/src/tests.rs @@ -3843,6 +3843,11 @@ fn check_snapshot() { // check states at round 1, session 2 roll_to(10, authors.clone()); // CollatorBlocks + // snapshot for round 2 (taken at this round boundary) == live candidate_pool now; + // from round 2 on it includes restaked rewards, so capture it here instead of + // comparing to the frozen genesis candidate. + let snap_2_c1 = StakePallet::candidate_pool(1).unwrap(); + let snap_2_c2 = StakePallet::candidate_pool(2).unwrap(); assert_eq!(StakePallet::collator_blocks(1, 1), author_blocks); assert_eq!(StakePallet::collator_blocks(1, 2), author_blocks_alt); // Snapshot - AtStake @@ -3859,15 +3864,17 @@ fn check_snapshot() { // check states at round 2, session 3 roll_to(15, authors.clone()); // CollatorBlocks + let snap_3_c1 = StakePallet::candidate_pool(1).unwrap(); + let snap_3_c2 = StakePallet::candidate_pool(2).unwrap(); assert_eq!(StakePallet::collator_blocks(2, 1), author_blocks_alt); assert_eq!(StakePallet::collator_blocks(2, 2), author_blocks); // Snapshot - AtStake - assert_eq!(StakePallet::at_stake(2, 1).unwrap(), candidate_1); - assert_eq!(StakePallet::at_stake(2, 2).unwrap(), candidate_2); + assert_eq!(StakePallet::at_stake(2, 1).unwrap(), snap_2_c1); + assert_eq!(StakePallet::at_stake(2, 2).unwrap(), snap_2_c2); // check delayed payout info let delayed_payout_info = StakePallet::delayed_payout_info().unwrap(); - let total_stake = author_blocks_alt as u128 * author_1_total_stake + - author_blocks as u128 * author_2_total_stake; + let total_stake = author_blocks_alt as u128 * snap_2_c1.total + + author_blocks as u128 * snap_2_c2.total; assert_eq!(delayed_payout_info.total_stake, total_stake); // TODO total issuance is 1 token more than expected // assert_eq!(delayed_payout_info.total_issuance, BLOCK_REWARD_IN_NORMAL_SESSION); @@ -3879,12 +3886,12 @@ fn check_snapshot() { assert_eq!(StakePallet::collator_blocks(3, 1), author_blocks); assert_eq!(StakePallet::collator_blocks(3, 2), author_blocks_alt); // Snapshot - AtStake - assert_eq!(StakePallet::at_stake(3, 1).unwrap(), candidate_1); - assert_eq!(StakePallet::at_stake(3, 2).unwrap(), candidate_2); + assert_eq!(StakePallet::at_stake(3, 1).unwrap(), snap_3_c1); + assert_eq!(StakePallet::at_stake(3, 2).unwrap(), snap_3_c2); // check delayed payout info let delayed_payout_info = StakePallet::delayed_payout_info().unwrap(); - let total_stake = author_blocks as u128 * author_1_total_stake + - author_blocks_alt as u128 * author_2_total_stake; + let total_stake = author_blocks as u128 * snap_3_c1.total + + author_blocks_alt as u128 * snap_3_c2.total; assert_eq!(delayed_payout_info.total_stake, total_stake); // TODO total issuance is 1 token more than expected // assert_eq!(delayed_payout_info.total_issuance, BLOCK_REWARD_IN_NORMAL_SESSION); @@ -3990,3 +3997,366 @@ fn check_snapshot_is_cleared() { assert_eq!(at_stake.len(), 0); }); } + +// =================================================================== +// Delegator auto-restake (inline) — AC tests +// Spec: ~/my-todos/work/peaq-delegator-auto-restake-spec-todo.html +// =================================================================== + +// AC-1: a delegator's staking reward is restaked into their bonded stake at +// payout time, instead of landing as liquid free balance. +#[test] +fn auto_restake_adds_delegator_reward_to_stake() { + let stake = 10_000_000 * DECIMALS; + ExtBuilder::default() + .with_balances(vec![(1, stake), (2, stake)]) + .with_collators(vec![(1, stake)]) + .with_delegators(vec![(2, 1, stake)]) + .build() + .execute_with(|| { + // collator 1 authors every block; roll_to tops up the pot + note_author + let authors: Vec> = (0u64..100u64).map(|_| Some(1u64)).collect(); + + let before_total = StakePallet::delegator_state(2).unwrap().total; + assert_eq!(before_total, stake); + + // advance across a round boundary so delegator 2's reward is paid + roll_to(10, authors); + + // AC-1: the reward must be restaked into the delegator's bonded stake, + // not left liquid. + let after_total = StakePallet::delegator_state(2).unwrap().total; + assert!( + after_total > before_total, + "delegator reward should be restaked into stake: before={before_total}, after={after_total}" + ); + }); +} + +// AC-2 (critical): restaking a reward must NOT consume the delegator's pending +// `Unstaking`. The reward is new money; it must raise the lock by exactly the reward +// and leave any queued unstake untouched (catches accidental reuse of `increase_lock`). +#[test] +fn auto_restake_does_not_consume_pending_unstaking() { + let stake = 10_000_000 * DECIMALS; + ExtBuilder::default() + .with_balances(vec![(1, stake), (2, stake)]) + .with_collators(vec![(1, stake)]) + .with_delegators(vec![(2, 1, stake)]) + .build() + .execute_with(|| { + // delegator 2 unstakes part of their delegation -> one pending Unstaking entry + assert_ok!(StakePallet::delegator_stake_less(RuntimeOrigin::signed(2), 1, stake / 2)); + let unstaking_before = StakePallet::unstaking(2); + assert_eq!(unstaking_before.len(), 1); + let total_before = StakePallet::delegator_state(2).unwrap().total; + + let authors: Vec> = (0u64..100u64).map(|_| Some(1u64)).collect(); + roll_to(10, authors); + + // reward was restaked (active total grew) ... + assert!(StakePallet::delegator_state(2).unwrap().total > total_before); + // ... but the pending Unstaking must be byte-for-byte unchanged. + assert_eq!( + StakePallet::unstaking(2), + unstaking_before, + "restake must not consume pending Unstaking" + ); + // lock invariant (lock == active total + pending unstaking) holds with unstaking + assert_ok!(StakePallet::do_try_state()); + }); +} + +// AC-1/T6: a successful restake emits the distinct DelegatorRewardRestaked event. +#[test] +fn auto_restake_emits_restaked_event() { + let stake = 10_000_000 * DECIMALS; + ExtBuilder::default() + .with_balances(vec![(1, stake), (2, stake)]) + .with_collators(vec![(1, stake)]) + .with_delegators(vec![(2, 1, stake)]) + .build() + .execute_with(|| { + let authors: Vec> = (0u64..100u64).map(|_| Some(1u64)).collect(); + roll_to(10, authors); + assert!( + events().iter().any(|e| matches!(e, Event::DelegatorRewardRestaked(2, 1, _))), + "restake should emit DelegatorRewardRestaked" + ); + }); +} + +// AC-3: if the delegator can no longer be restaked onto the collator (here: they +// revoked the delegation after the snapshot was taken), the reward is paid out as a +// plain balance via the distinct fallback event, and no restake happens. +#[test] +fn auto_restake_falls_back_to_plain_payout_when_delegation_revoked() { + let stake = 10_000_000 * DECIMALS; + ExtBuilder::default() + .with_balances(vec![(1, stake), (2, stake)]) + .with_collators(vec![(1, stake)]) + .with_delegators(vec![(2, 1, stake)]) + .build() + .execute_with(|| { + let authors: Vec> = (0u64..100u64).map(|_| Some(1u64)).collect(); + // delegator 2 is in the genesis snapshot, then revokes before payout + assert_ok!(StakePallet::revoke_delegation(RuntimeOrigin::signed(2), 1)); + assert!(StakePallet::delegator_state(2).is_none()); + let free_before = Balances::free_balance(2); + + roll_to(10, authors); + + // reward paid as plain balance ... + assert!(Balances::free_balance(2) > free_before); + // ... via the distinct fallback event, with no restake. + assert!( + events() + .iter() + .any(|e| matches!(e, Event::DelegatorRewardPaidNotRestaked(2, 1, _))), + "fallback should emit DelegatorRewardPaidNotRestaked" + ); + assert!( + !events().iter().any(|e| matches!(e, Event::DelegatorRewardRestaked(2, _, _))), + "revoked delegator must not be restaked" + ); + }); +} + +// gap: exercise do_try_state's fully-exited-account branch. A delegator that fully revoked +// has no DelegatorState but still holds pending Unstaking, and its STAKING_ID lock must equal +// that unstaking. Prove the check both PASSES on the valid state AND FIRES on a corrupted lock +// (otherwise the branch could pass vacuously and catch nothing). +#[test] +fn do_try_state_catches_fully_exited_account_lock_mismatch() { + let stake = 10_000_000 * DECIMALS; + ExtBuilder::default() + .with_balances(vec![(1, stake), (2, stake)]) + .with_collators(vec![(1, stake)]) + .with_delegators(vec![(2, 1, stake)]) + .build() + .execute_with(|| { + // delegator 2 fully revokes -> removed from DelegatorState, stake -> pending Unstaking + assert_ok!(StakePallet::revoke_delegation(RuntimeOrigin::signed(2), 1)); + assert!(StakePallet::delegator_state(2).is_none()); + // valid: lock == pending unstaking, so the fully-exited-account branch holds + assert_ok!(StakePallet::do_try_state()); + + // corrupt ONLY the STAKING_ID lock so lock != pending unstaking; the branch must fire + let locked = Balances::locks(2) + .iter() + .find(|l| l.id == STAKING_ID) + .map(|l| l.amount) + .expect("revoked delegator keeps a STAKING_ID lock for pending unstaking"); + >::set_lock( + STAKING_ID, + &2, + locked + 1, + frame_support::traits::WithdrawReasons::all(), + ); + assert!( + StakePallet::do_try_state().is_err(), + "do_try_state must catch a fully-exited account whose lock != pending unstaking" + ); + }); +} + +// AC-8: the candidate-total invariant (self stake + sum of delegator stakes) survives +// many rounds of restaking. (AC-6 compounding and AC-7 R->R+2 snapshot ordering are +// covered directly by `check_snapshot`.) +#[test] +fn auto_restake_preserves_candidate_total_invariant() { + let stake = 10_000_000 * DECIMALS; + ExtBuilder::default() + .with_balances(vec![(1, stake), (2, stake), (3, stake)]) + .with_collators(vec![(1, stake)]) + .with_delegators(vec![(2, 1, stake), (3, 1, stake)]) + .build() + .execute_with(|| { + assert_ok!(StakePallet::do_try_state()); + let authors: Vec> = (0u64..100u64).map(|_| Some(1u64)).collect(); + roll_to(20, authors); + assert_ok!(StakePallet::do_try_state()); + }); +} + +// AC-5: with the max number of delegators on one collator, every delegator is restaked +// and the candidate total stays consistent (batch correctness). +#[test] +fn auto_restake_batch_many_delegators_consistent() { + let stake = 10_000_000 * DECIMALS; + ExtBuilder::default() + .with_balances(vec![(1, stake), (2, stake), (3, stake), (4, stake), (5, stake)]) + .with_collators(vec![(1, stake)]) + .with_delegators(vec![(2, 1, stake), (3, 1, stake), (4, 1, stake), (5, 1, stake)]) + .build() + .execute_with(|| { + let cand_before = StakePallet::candidate_pool(1).unwrap().total; + let d2_before = StakePallet::delegator_state(2).unwrap().total; + let authors: Vec> = (0u64..100u64).map(|_| Some(1u64)).collect(); + roll_to(10, authors); + assert!(StakePallet::delegator_state(2).unwrap().total > d2_before); + assert!(StakePallet::delegator_state(5).unwrap().total > d2_before); + assert!(StakePallet::candidate_pool(1).unwrap().total > cand_before); + assert_ok!(StakePallet::do_try_state()); + }); +} + +// Full-session scale (mock-max): several collators, each at MaxDelegatorsPerCollator +// capacity, are all paid one-per-block across whole sessions -- proves the multi-collator +// payout spread + snapshot cleanup + cross-side/lock invariant end-to-end. The mock caps +// delegators at 4/collator, so the runtime-scale 100-delegator-per-block case is covered +// separately by the payout_collator benchmark (which uses the real runtime bound of 100). +#[test] +fn auto_restake_full_session_all_collators_restake() { + let stake = 10_000_000 * DECIMALS; + // 4 collators (1..=4), each with MaxDelegatorsPerCollator (=4) delegators. + // delegator id for collator c, slot d is 10*c + d, keeping every id distinct. + let collators: Vec<(AccountId, Balance)> = (1u64..=4).map(|c| (c, stake)).collect(); + let mut balances: Vec<(AccountId, Balance)> = collators.clone(); + let mut delegators: Vec<(AccountId, AccountId, Balance)> = Vec::new(); + for c in 1u64..=4 { + for d in 1u64..=4 { + let did = 10 * c + d; + balances.push((did, stake)); + delegators.push((did, c, stake)); + } + } + ExtBuilder::default() + .with_balances(balances) + .with_collators(collators) + .with_delegators(delegators.clone()) + .build() + .execute_with(|| { + // genesis selects only MinCollators (=2); select all 4 so each is paid + assert_ok!(StakePallet::set_max_selected_candidates(RuntimeOrigin::root(), 4)); + let cand_before: Vec = + (1u64..=4).map(|c| StakePallet::candidate_pool(c).unwrap().total).collect(); + + // every collator authors in rotation, so each is paid one-per-block each session + let authors: Vec> = (0u64..80).map(|i| Some((i % 4) + 1)).collect(); + roll_to(50, authors); + + // every delegator of every collator restaked at least once (stake strictly grew) + for (did, c, _s) in &delegators { + assert!( + StakePallet::delegator_state(*did).unwrap().total > stake, + "delegator {} of collator {} should have restaked", + did, + c + ); + } + // every collator's candidate total grew from its delegators' restakes + for (idx, c) in (1u64..=4).enumerate() { + assert!( + StakePallet::candidate_pool(c).unwrap().total > cand_before[idx], + "candidate {} total should have grown", + c + ); + } + // cross-side + STAKING_ID lock invariants hold after a full multi-collator session + assert_ok!(StakePallet::do_try_state()); + }); +} + +// AC-9 (regression / scope): collator rewards are NOT restaked (scope is delegator-only). +// The collator's reward is still paid as liquid balance with the plain Rewarded event. +#[test] +fn auto_restake_leaves_collator_reward_untouched() { + let stake = 10_000_000 * DECIMALS; + ExtBuilder::default() + .with_balances(vec![(1, stake), (2, stake)]) + .with_collators(vec![(1, stake), (2, stake)]) + .build() + .execute_with(|| { + let self_stake_before = StakePallet::candidate_pool(1).unwrap().stake; + let free_before = Balances::free_balance(1); + let authors: Vec> = (0u64..100u64).map(|_| Some(1u64)).collect(); + roll_to(10, authors); + assert!(Balances::free_balance(1) > free_before); + assert_eq!(StakePallet::candidate_pool(1).unwrap().stake, self_stake_before); + assert!(events().iter().any(|e| matches!(e, Event::Rewarded(1, _)))); + }); +} + +// AC-BND: a collator with no delegators is paid without panicking, invariant intact. +#[test] +fn auto_restake_collator_with_no_delegators() { + let stake = 10_000_000 * DECIMALS; + ExtBuilder::default() + .with_balances(vec![(1, stake), (2, stake)]) + .with_collators(vec![(1, stake), (2, stake)]) + .build() + .execute_with(|| { + let authors: Vec> = (0u64..100u64).map(|_| Some(1u64)).collect(); + roll_to(10, authors); + assert!(Balances::free_balance(1) > stake); + assert_ok!(StakePallet::do_try_state()); + }); +} + +// AC-IDEM: a delegator is restaked exactly once per round the collator authored, not +// twice (the CollatorBlocks drain makes each round's payout happen once). +#[test] +fn auto_restake_pays_each_round_once() { + let stake = 10_000_000 * DECIMALS; + ExtBuilder::default() + .with_balances(vec![(1, stake), (2, stake)]) + .with_collators(vec![(1, stake)]) + .with_delegators(vec![(2, 1, stake)]) + .build() + .execute_with(|| { + // collator 1 authors one block in round 0 only + let authors: Vec> = + (0u64..=100).map(|i| if i % 5 == 2 { Some(1u64) } else { None }).collect(); + roll_to(BLOCKS_PER_ROUND * 2, authors); + let restakes = events() + .iter() + .filter(|e| matches!(e, Event::DelegatorRewardRestaked(2, 1, _))) + .count(); + assert_eq!(restakes, 1, "delegator must be restaked once per round, not twice"); + }); +} + +// AC-4: a zero reward is skipped entirely -- no restake, no stake churn, no event. +// Forced via 100% commission (delegators earn nothing from rounds snapshotted after it). +#[test] +fn auto_restake_skips_zero_reward() { + let stake = 10_000_000 * DECIMALS; + ExtBuilder::default() + .with_balances(vec![(1, stake), (2, stake)]) + .with_collators(vec![(1, stake)]) + .with_delegators(vec![(2, 1, stake)]) + .build() + .execute_with(|| { + let authors: Vec> = (0u64..100u64).map(|_| Some(1u64)).collect(); + assert_ok!(StakePallet::set_commission( + RuntimeOrigin::signed(1), + Permill::from_percent(100) + )); + + // round 0 uses the genesis snapshot (0% commission) -> one restake + roll_to(10, authors.clone()); + let total_after_round0 = StakePallet::delegator_state(2).unwrap().total; + assert!(total_after_round0 > stake); + let restakes_before = events() + .iter() + .filter(|e| matches!(e, Event::DelegatorRewardRestaked(2, 1, _))) + .count(); + + // later rounds carry 100% commission -> zero delegator reward -> skipped + roll_to(30, authors); + assert_eq!( + StakePallet::delegator_state(2).unwrap().total, + total_after_round0, + "zero rewards must not change stake" + ); + let restakes_after = events() + .iter() + .filter(|e| matches!(e, Event::DelegatorRewardRestaked(2, 1, _))) + .count(); + assert_eq!( + restakes_after, restakes_before, + "zero rewards must not emit restake events" + ); + }); +} diff --git a/pallets/parachain-staking/src/weightinfo.rs b/pallets/parachain-staking/src/weightinfo.rs index 4c8420d1..c327ee6f 100644 --- a/pallets/parachain-staking/src/weightinfo.rs +++ b/pallets/parachain-staking/src/weightinfo.rs @@ -24,4 +24,6 @@ pub trait WeightInfo { fn unlock_unstaked(u: u32) -> Weight; fn set_max_candidate_stake() -> Weight; fn set_commission(n: u32, m: u32) -> Weight; + fn payout_collator(n: u32) -> Weight; + fn prepare_delayed_rewards(n: u32, m: u32) -> Weight; } diff --git a/pallets/parachain-staking/src/weights.rs b/pallets/parachain-staking/src/weights.rs index 89557e0b..c15a7adf 100644 --- a/pallets/parachain-staking/src/weights.rs +++ b/pallets/parachain-staking/src/weights.rs @@ -537,4 +537,33 @@ impl crate::WeightInfo for WeightInfo { .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(1)) } + fn payout_collator(n: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `1830 + n * (473 ±0)` + // Estimated: `8392 + n * (3774 ±0)` + // Minimum execution time: 151_592_000 picoseconds. + Weight::from_parts(212_800_641, 0) + .saturating_add(Weight::from_parts(0, 8392)) + .saturating_add(Weight::from_parts(187_259_436, 0).saturating_mul(n.into())) + .saturating_add(T::DbWeight::get().reads(10)) + .saturating_add(T::DbWeight::get().reads((5_u64).saturating_mul(n.into()))) + .saturating_add(T::DbWeight::get().writes(6)) + .saturating_add(T::DbWeight::get().writes((3_u64).saturating_mul(n.into()))) + .saturating_add(Weight::from_parts(0, 3774).saturating_mul(n.into())) + } + fn prepare_delayed_rewards(n: u32, m: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `0 + m * (6151 ±0) + n * (9894 ±0)` + // Estimated: `3593 + n * (7402 ±0)` + // Minimum execution time: 199_802_000 picoseconds. + Weight::from_parts(199_802_000, 0) + .saturating_add(Weight::from_parts(0, 3593)) + .saturating_add(Weight::from_parts(37_112_768, 0).saturating_mul(n.into())) + .saturating_add(Weight::from_parts(3_796_214, 0).saturating_mul(m.into())) + .saturating_add(T::DbWeight::get().reads(3)) + .saturating_add(T::DbWeight::get().reads((3_u64).saturating_mul(n.into()))) + .saturating_add(T::DbWeight::get().writes(1)) + .saturating_add(T::DbWeight::get().writes((1_u64).saturating_mul(n.into()))) + .saturating_add(Weight::from_parts(0, 7402).saturating_mul(n.into())) + } } diff --git a/precompiles/parachain-staking/src/tests.rs b/precompiles/parachain-staking/src/tests.rs index de1b2c92..af05b98f 100644 --- a/precompiles/parachain-staking/src/tests.rs +++ b/precompiles/parachain-staking/src/tests.rs @@ -343,3 +343,56 @@ fn should_update_total_stake() { ); }) } + +// AC-HUB: the EVM precompile getCollatorList reports a collator's UPDATED total after +// the staking pallet grows CandidatePool[collator].total (as auto-restake does when it +// folds a delegator reward into the collator's backing). Restake growing the total +// correctly is proven in the pallet crate (auto_restake_* / do_try_state); here we +// isolate the downstream-consumer contract: the precompile reads live CandidatePool. +#[test] +fn get_collator_list_reflects_grown_candidate_total() { + ExtBuilder::default() + .with_balances(vec![ + (MockPeaqAccount::Alice, 10), + (MockPeaqAccount::Bob, 100), + (MockPeaqAccount::Charlie, 100), + ]) + .with_collators(vec![(MockPeaqAccount::Alice, 10), (MockPeaqAccount::Charlie, 20)]) + .with_delegators(vec![(MockPeaqAccount::Bob, MockPeaqAccount::Alice, 100)]) + .build() + .execute_with(|| { + // genesis: Alice total = stake 10 + delegation 100 = 110 + assert_eq!( + parachain_staking::CandidatePool::::get(MockPeaqAccount::Alice) + .unwrap() + .total, + 110 + ); + // simulate a restaked reward folded into Alice's backing + parachain_staking::CandidatePool::::mutate(MockPeaqAccount::Alice, |maybe| { + if let Some(candidate) = maybe { + candidate.total += 5; + } + }); + // precompile must report the grown total (115), shape intact + precompiles() + .prepare_test( + MockPeaqAccount::Bob, + MockPeaqAccount::EVMu1Account, + PCall::get_collator_list {}, + ) + .expect_no_logs() + .execute_returns(vec![ + CollatorInfo { + owner: convert_mock_account_by_u8_list(MockPeaqAccount::Alice), + amount: U256::from(115), + commission: U256::from(0), + }, + CollatorInfo { + owner: convert_mock_account_by_u8_list(MockPeaqAccount::Charlie), + amount: U256::from(20), + commission: U256::from(0), + }, + ]); + }) +}