From 537ce8bfa9a4c5adee10787574d229d32693cb07 Mon Sep 17 00:00:00 2001 From: jaypan Date: Fri, 3 Jul 2026 18:13:28 +0200 Subject: [PATCH 1/9] feat(parachain-staking): inline auto-restake of delegator rewards Delegator staking rewards are restaked automatically at payout time instead of landing in free balance; each reward is added to the delegator's bonded stake and their collator. Collator rewards stay liquid. Two-phase (preflight -> transfer -> infallible commit) payout in on_finalize; increase_lock_from_reward leaves pending Unstaking untouched; all delegators of a collator share one candidate (batch-flushed once). on_initialize conditionally reserves the payout weight; do_try_state asserts totals + the STAKING_ID lock invariant (incl. fully-exited accounts). New DelegatorRewardRestaked / DelegatorRewardPaidNotRestaked events; reward for round R restakes in R+1, compounds from R+2. Design: inline restake at payout over a deferred queue -- no new storage/migration/keeper; per-block delegator count already bounded by MaxDelegatorsPerCollator. --- pallets/parachain-staking/AUTO_RESTAKE.md | 54 +++ pallets/parachain-staking/Cargo.toml | 1 + pallets/parachain-staking/src/lib.rs | 322 ++++++++++++++-- pallets/parachain-staking/src/tests.rs | 383 +++++++++++++++++++- pallets/parachain-staking/src/weightinfo.rs | 1 + pallets/parachain-staking/src/weights.rs | 13 + precompiles/parachain-staking/src/tests.rs | 53 +++ 7 files changed, 784 insertions(+), 43 deletions(-) create mode 100644 pallets/parachain-staking/AUTO_RESTAKE.md diff --git a/pallets/parachain-staking/AUTO_RESTAKE.md b/pallets/parachain-staking/AUTO_RESTAKE.md new file mode 100644 index 000000000..a9ea8e65b --- /dev/null +++ b/pallets/parachain-staking/AUTO_RESTAKE.md @@ -0,0 +1,54 @@ +# Delegator auto-restake (inline) + +Delegator staking rewards are **restaked automatically** at payout time: instead of +landing in the delegator's free (spendable) balance, each reward is added to the +delegator's bonded stake and to the collator they delegate. All delegators +auto-compound; there is no keeper transaction and no per-delegator opt-in. + +Collator rewards are unchanged — they are still paid as liquid balance. + +## Behaviour change + +| Before | After | +|---|---| +| Delegator reward → free (spendable) balance | Delegator reward → locked stake (restaked onto the same collator) | +| Delegator's stake constant between manual `delegator_stake_more` calls | Delegator's stake grows every round their collator is paid | + +A reward earned for round `R` is paid during round `R+1` and, because the payout runs +in `on_finalize` (after that block's `on_initialize` snapshot), the restaked stake is +first captured by the round `R+2` snapshot — so it begins earning compounding rewards +from round `R+2`. + +## Withdrawing rewards (users) + +Rewards are no longer liquid by default. To turn restaked rewards back into spendable +tokens, unstake with `delegator_stake_less(collator, amount)`. The unstaked amount is +subject to the normal unbonding delay (`StakeDuration`: **14 days** on peaq, **7 days** +on krest) and then `unlock_unstaked`. Note `MaxUnstakeRequests` (10, of which 9 are +usable manually) caps how many pending unstakes you can have at once. + +## Events (indexers / wallets / tax tooling) + +Note the reward is still **transferred** from the pot into the delegator's account +(free balance goes up, and a `Balances` transfer still occurs), but it is immediately +**locked** — so the delegator's *spendable* balance does not change. Indexers that +track rewards by spendable/usable balance, or by the staking events, must adapt. +Three staking-pallet events now distinguish the cases: + +| Event | Meaning | +|---|---| +| `DelegatorRewardRestaked(delegator, collator, amount)` | Delegator reward was restaked (auto-compounded). | +| `DelegatorRewardPaidNotRestaked(delegator, collator, amount)` | Delegator reward was paid as liquid balance because it could not be restaked (delegator no longer delegates the collator, candidate leaving, or inconsistent state). | +| `Rewarded(account, amount)` | **Collators only** now. Delegators no longer emit `Rewarded`. | + +**Indexer migration:** any pipeline that tracked delegator rewards via `Rewarded` or via +delegator free-balance increases must switch to `DelegatorRewardRestaked` / +`DelegatorRewardPaidNotRestaked`. A delegator's free balance still increases (the +reward is transferred in), but it is locked, so spendable balance does not change. + +## Invariants / safety + +- Restaking a reward raises the `STAKING_ID` lock by exactly the reward and does **not** + consume any pending `Unstaking` (a reward must never cancel a queued unstake). +- `try_state` (test / try-runtime) checks that every candidate's `total` equals its + self-stake plus the sum of its delegators' stakes, catching any partial-write bug. diff --git a/pallets/parachain-staking/Cargo.toml b/pallets/parachain-staking/Cargo.toml index 2c1dd8395..df48a258e 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/lib.rs b/pallets/parachain-staking/src/lib.rs index c3363bf40..9b25c440c 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,29 @@ 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 +563,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 +1829,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 +1841,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,13 +2661,153 @@ 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 do_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() { + 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, + )); + } + } + 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)); @@ -2797,10 +2952,73 @@ 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 +3030,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 +3048,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::do_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 diff --git a/pallets/parachain-staking/src/tests.rs b/pallets/parachain-staking/src/tests.rs index 0b3dff9f6..21c4bfff1 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,363 @@ 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 4c8420d15..a3ad7b00f 100644 --- a/pallets/parachain-staking/src/weightinfo.rs +++ b/pallets/parachain-staking/src/weightinfo.rs @@ -24,4 +24,5 @@ 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; } diff --git a/pallets/parachain-staking/src/weights.rs b/pallets/parachain-staking/src/weights.rs index 89557e0b0..dd2625db9 100644 --- a/pallets/parachain-staking/src/weights.rs +++ b/pallets/parachain-staking/src/weights.rs @@ -537,4 +537,17 @@ impl crate::WeightInfo for WeightInfo { .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(1)) } + /// NOTE(T8): placeholder until benchmarked via the frame-benchmarking CLI. Bounds the + /// per-delegator restake footprint so on_initialize reserves conservatively. + fn payout_collator(n: u32, ) -> Weight { + // proof_size is conservative: ~10 KiB base (candidate + top-candidate set) plus + // ~2 KiB/delegator (DelegatorState + Locks + Account). Real numbers via node CLI. + // Writes: the shared candidate + top-candidate set are written ONCE (batch flush, + // folded into the base), so per-delegator writes are only DelegatorState + Account + + // Locks; the 4n bound keeps a margin over that. Reads stay conservative at 8n. + Weight::from_parts(50_000_000, 10_000) + .saturating_add(Weight::from_parts(0, 2_000).saturating_mul(n.into())) + .saturating_add(T::DbWeight::get().reads(6u64.saturating_add(8u64.saturating_mul(n.into())))) + .saturating_add(T::DbWeight::get().writes(6u64.saturating_add(4u64.saturating_mul(n.into())))) + } } diff --git a/precompiles/parachain-staking/src/tests.rs b/precompiles/parachain-staking/src/tests.rs index de1b2c92d..af05b98f6 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), + }, + ]); + }) +} From ac3338f04e7bc41cb580a83474e57b92a3c0009c Mon Sep 17 00:00:00 2001 From: jaypan Date: Fri, 3 Jul 2026 18:13:28 +0200 Subject: [PATCH 2/9] test(parachain-staking): benchmark payout + snapshot blocks; fix stale benchmarks Add payout_collator (1 collator x n delegators = the per-block payout worst case) and prepare_delayed_rewards (n collators x m delegators = the session-boundary snapshot block, the heaviest single block in the flow) benchmarks, so both heavy blocks are measurable for weight/PoV. weights.rs keeps a conservative placeholder for payout_collator until the CLI numbers are generated. Fix two pre-existing benchmarks broken by round rotation moving to pallet_session: remove on_initialize_round_update, repair execute_leave_candidates (bump Round.current directly). --- pallets/parachain-staking/src/benchmarking.rs | 93 ++++++++++++++++--- 1 file changed, 79 insertions(+), 14 deletions(-) diff --git a/pallets/parachain-staking/src/benchmarking.rs b/pallets/parachain-staking/src/benchmarking.rs index 91bd636a9..ccd3a2878 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,73 @@ 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!( From 4f31b13e76b3a7d529a7cb65a8ad491e485c1110 Mon Sep 17 00:00:00 2001 From: jaypan Date: Wed, 8 Jul 2026 10:36:41 +0200 Subject: [PATCH 3/9] docs(parachain-staking): remove AUTO_RESTAKE.md --- pallets/parachain-staking/AUTO_RESTAKE.md | 54 ----------------------- 1 file changed, 54 deletions(-) delete mode 100644 pallets/parachain-staking/AUTO_RESTAKE.md diff --git a/pallets/parachain-staking/AUTO_RESTAKE.md b/pallets/parachain-staking/AUTO_RESTAKE.md deleted file mode 100644 index a9ea8e65b..000000000 --- a/pallets/parachain-staking/AUTO_RESTAKE.md +++ /dev/null @@ -1,54 +0,0 @@ -# Delegator auto-restake (inline) - -Delegator staking rewards are **restaked automatically** at payout time: instead of -landing in the delegator's free (spendable) balance, each reward is added to the -delegator's bonded stake and to the collator they delegate. All delegators -auto-compound; there is no keeper transaction and no per-delegator opt-in. - -Collator rewards are unchanged — they are still paid as liquid balance. - -## Behaviour change - -| Before | After | -|---|---| -| Delegator reward → free (spendable) balance | Delegator reward → locked stake (restaked onto the same collator) | -| Delegator's stake constant between manual `delegator_stake_more` calls | Delegator's stake grows every round their collator is paid | - -A reward earned for round `R` is paid during round `R+1` and, because the payout runs -in `on_finalize` (after that block's `on_initialize` snapshot), the restaked stake is -first captured by the round `R+2` snapshot — so it begins earning compounding rewards -from round `R+2`. - -## Withdrawing rewards (users) - -Rewards are no longer liquid by default. To turn restaked rewards back into spendable -tokens, unstake with `delegator_stake_less(collator, amount)`. The unstaked amount is -subject to the normal unbonding delay (`StakeDuration`: **14 days** on peaq, **7 days** -on krest) and then `unlock_unstaked`. Note `MaxUnstakeRequests` (10, of which 9 are -usable manually) caps how many pending unstakes you can have at once. - -## Events (indexers / wallets / tax tooling) - -Note the reward is still **transferred** from the pot into the delegator's account -(free balance goes up, and a `Balances` transfer still occurs), but it is immediately -**locked** — so the delegator's *spendable* balance does not change. Indexers that -track rewards by spendable/usable balance, or by the staking events, must adapt. -Three staking-pallet events now distinguish the cases: - -| Event | Meaning | -|---|---| -| `DelegatorRewardRestaked(delegator, collator, amount)` | Delegator reward was restaked (auto-compounded). | -| `DelegatorRewardPaidNotRestaked(delegator, collator, amount)` | Delegator reward was paid as liquid balance because it could not be restaked (delegator no longer delegates the collator, candidate leaving, or inconsistent state). | -| `Rewarded(account, amount)` | **Collators only** now. Delegators no longer emit `Rewarded`. | - -**Indexer migration:** any pipeline that tracked delegator rewards via `Rewarded` or via -delegator free-balance increases must switch to `DelegatorRewardRestaked` / -`DelegatorRewardPaidNotRestaked`. A delegator's free balance still increases (the -reward is transferred in), but it is locked, so spendable balance does not change. - -## Invariants / safety - -- Restaking a reward raises the `STAKING_ID` lock by exactly the reward and does **not** - consume any pending `Unstaking` (a reward must never cancel a queued unstake). -- `try_state` (test / try-runtime) checks that every candidate's `total` equals its - self-stake plus the sum of its delegators' stakes, catching any partial-write bug. From 472802203430a4e15ed303d8f169df02501cb297 Mon Sep 17 00:00:00 2001 From: jaypan Date: Wed, 8 Jul 2026 10:48:59 +0200 Subject: [PATCH 4/9] Cargo fmt update --- pallets/parachain-staking/src/lib.rs | 27 +++++++++++++++++++++----- pallets/parachain-staking/src/tests.rs | 20 +++++++++++++------ 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/pallets/parachain-staking/src/lib.rs b/pallets/parachain-staking/src/lib.rs index 9b25c440c..35c39f233 100644 --- a/pallets/parachain-staking/src/lib.rs +++ b/pallets/parachain-staking/src/lib.rs @@ -551,7 +551,8 @@ pub mod pallet { .reads_writes(6u64, (MaxSelectedCandidates::::get() + 2).into()); payout.max(cleanup) } else { - // No payout pending -> payout_collator returns after reading DelayedPayoutInfo + Round. + // No payout pending -> payout_collator returns after reading DelayedPayoutInfo + + // Round. T::DbWeight::get().reads(3) } } @@ -2758,10 +2759,21 @@ pub mod pallet { // 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), + 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) + return Self::pay_delegator_reward_not_restaked( + pot, + collator_id, + delegator_id, + reward, + ) } // PHASE 2: commit (move the reward in, then lock + restake it) @@ -2788,7 +2800,11 @@ pub mod pallet { } 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)); + 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 @@ -2996,7 +3012,8 @@ pub mod pallet { // 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) { + if DelegatorState::::contains_key(&who) || CandidatePool::::contains_key(&who) + { continue } let unstaking_sum = unstaking diff --git a/pallets/parachain-staking/src/tests.rs b/pallets/parachain-staking/src/tests.rs index 21c4bfff1..e7ebe4384 100644 --- a/pallets/parachain-staking/src/tests.rs +++ b/pallets/parachain-staking/src/tests.rs @@ -1611,31 +1611,36 @@ fn coinbase_rewards_many_blocks_simple_check() { assert_eq!( Balances::free_balance(1), genesis_reward_1 + - normal_odd_reward_1 + normal_even_reward_1 + + normal_odd_reward_1 + + normal_even_reward_1 + 40_000_000 * DECIMALS ); assert_eq!( Balances::free_balance(2), genesis_reward_2 + - normal_odd_reward_2 + normal_even_reward_2 + + normal_odd_reward_2 + + normal_even_reward_2 + 40_000_000 * DECIMALS ); assert_eq!( Balances::free_balance(3), genesis_reward_3 + - normal_odd_reward_3 + normal_even_reward_3 + + normal_odd_reward_3 + + normal_even_reward_3 + 40_000_000 * DECIMALS ); assert_eq!( Balances::free_balance(4), genesis_reward_4 + - normal_odd_reward_4 + normal_even_reward_4 + + normal_odd_reward_4 + + normal_even_reward_4 + 20_000_000 * DECIMALS ); assert_eq!( Balances::free_balance(5), genesis_reward_5 + - normal_odd_reward_5 + normal_even_reward_5 + + normal_odd_reward_5 + + normal_even_reward_5 + 20_000_000 * DECIMALS ); @@ -4354,6 +4359,9 @@ fn auto_restake_skips_zero_reward() { .iter() .filter(|e| matches!(e, Event::DelegatorRewardRestaked(2, 1, _))) .count(); - assert_eq!(restakes_after, restakes_before, "zero rewards must not emit restake events"); + assert_eq!( + restakes_after, restakes_before, + "zero rewards must not emit restake events" + ); }); } From a8b9b3f77aa3eb47e767479f8fabbce8a932e957 Mon Sep 17 00:00:00 2001 From: jaypan Date: Wed, 8 Jul 2026 10:56:08 +0200 Subject: [PATCH 5/9] cargo fmt update --- pallets/parachain-staking/src/tests.rs | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/pallets/parachain-staking/src/tests.rs b/pallets/parachain-staking/src/tests.rs index e7ebe4384..2c16691b6 100644 --- a/pallets/parachain-staking/src/tests.rs +++ b/pallets/parachain-staking/src/tests.rs @@ -1611,36 +1611,31 @@ fn coinbase_rewards_many_blocks_simple_check() { assert_eq!( Balances::free_balance(1), genesis_reward_1 + - normal_odd_reward_1 + - normal_even_reward_1 + + normal_odd_reward_1 + normal_even_reward_1 + 40_000_000 * DECIMALS ); assert_eq!( Balances::free_balance(2), genesis_reward_2 + - normal_odd_reward_2 + - normal_even_reward_2 + + normal_odd_reward_2 + normal_even_reward_2 + 40_000_000 * DECIMALS ); assert_eq!( Balances::free_balance(3), genesis_reward_3 + - normal_odd_reward_3 + - normal_even_reward_3 + + normal_odd_reward_3 + normal_even_reward_3 + 40_000_000 * DECIMALS ); assert_eq!( Balances::free_balance(4), genesis_reward_4 + - normal_odd_reward_4 + - normal_even_reward_4 + + normal_odd_reward_4 + normal_even_reward_4 + 20_000_000 * DECIMALS ); assert_eq!( Balances::free_balance(5), genesis_reward_5 + - normal_odd_reward_5 + - normal_even_reward_5 + + normal_odd_reward_5 + normal_even_reward_5 + 20_000_000 * DECIMALS ); From f0fd599ed83304b159c266247776781d3c543633 Mon Sep 17 00:00:00 2001 From: jaypan Date: Wed, 8 Jul 2026 11:14:53 +0200 Subject: [PATCH 6/9] perf(parachain-staking): wire benchmarked weights for reward payout and snapshot Replace the hand-estimated payout_collator weight and the manual reads_writes plumbing in prepare_delayed_rewards with the numbers generated by the frame-benchmarking CLI (delegator component measured to 100). - weightinfo.rs: declare prepare_delayed_rewards(n, m) in the trait - weights.rs: benchmarked payout_collator, add prepare_delayed_rewards - lib.rs: prepare_delayed_rewards now returns WeightInfo::prepare_delayed_rewards(n, m); the manual read/write tally (and the helper weight returns it summed) are dropped in favor of the measured weight. --- pallets/parachain-staking/src/lib.rs | 22 ++++++------ pallets/parachain-staking/src/weightinfo.rs | 1 + pallets/parachain-staking/src/weights.rs | 38 +++++++++++++++------ 3 files changed, 39 insertions(+), 22 deletions(-) diff --git a/pallets/parachain-staking/src/lib.rs b/pallets/parachain-staking/src/lib.rs index 35c39f233..58d05fa14 100644 --- a/pallets/parachain-staking/src/lib.rs +++ b/pallets/parachain-staking/src/lib.rs @@ -3156,8 +3156,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; @@ -3166,24 +3169,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 { @@ -3191,9 +3192,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/weightinfo.rs b/pallets/parachain-staking/src/weightinfo.rs index a3ad7b00f..c327ee6fd 100644 --- a/pallets/parachain-staking/src/weightinfo.rs +++ b/pallets/parachain-staking/src/weightinfo.rs @@ -25,4 +25,5 @@ pub trait WeightInfo { 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 dd2625db9..c15a7adf6 100644 --- a/pallets/parachain-staking/src/weights.rs +++ b/pallets/parachain-staking/src/weights.rs @@ -537,17 +537,33 @@ impl crate::WeightInfo for WeightInfo { .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(1)) } - /// NOTE(T8): placeholder until benchmarked via the frame-benchmarking CLI. Bounds the - /// per-delegator restake footprint so on_initialize reserves conservatively. fn payout_collator(n: u32, ) -> Weight { - // proof_size is conservative: ~10 KiB base (candidate + top-candidate set) plus - // ~2 KiB/delegator (DelegatorState + Locks + Account). Real numbers via node CLI. - // Writes: the shared candidate + top-candidate set are written ONCE (batch flush, - // folded into the base), so per-delegator writes are only DelegatorState + Account + - // Locks; the 4n bound keeps a margin over that. Reads stay conservative at 8n. - Weight::from_parts(50_000_000, 10_000) - .saturating_add(Weight::from_parts(0, 2_000).saturating_mul(n.into())) - .saturating_add(T::DbWeight::get().reads(6u64.saturating_add(8u64.saturating_mul(n.into())))) - .saturating_add(T::DbWeight::get().writes(6u64.saturating_add(4u64.saturating_mul(n.into())))) + // 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())) } } From dbd3f286e7bbcd7c114926d713d22547487acd7d Mon Sep 17 00:00:00 2001 From: jaypan Date: Thu, 9 Jul 2026 11:29:22 +0200 Subject: [PATCH 7/9] fix(parachain-staking): make on_finalize reward payout panic-free and observable The reward payout runs in on_finalize (Mandatory): it must neither panic nor silently lose funds. Harden the path: - Log every failed pot -> account transfer (restake, plain-payout fallback, and collator reward) instead of skipping the reward with no signal. The failure is still handled (skip, never propagate); it is now visible. - Replace the expect() in get_delgators_reward_per_session with truncate_from: the vec can never exceed MaxDelegatorsPerCollator (1:1 map over a bounded snapshot), but truncate_from cannot panic in the Mandatory hook and logs the provably-impossible overflow. - Use saturating add/sub in the reward math so arithmetic can never panic on overflow (values are bounded by issuance; belt-and-braces). No happy-path behavior change; 78 pallet tests still pass. --- pallets/parachain-staking/src/lib.rs | 44 ++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/pallets/parachain-staking/src/lib.rs b/pallets/parachain-staking/src/lib.rs index 58d05fa14..ed5036bee 100644 --- a/pallets/parachain-staking/src/lib.rs +++ b/pallets/parachain-staking/src/lib.rs @@ -2778,6 +2778,14 @@ pub mod pallet { // 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 @@ -2821,12 +2829,24 @@ pub mod pallet { 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, + ); } } @@ -2886,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) @@ -2916,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)), } } } @@ -2953,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`. From 7e3fc60fb403065653671173f348c8df38f24cac Mon Sep 17 00:00:00 2001 From: jaypan Date: Mon, 13 Jul 2026 11:13:58 +0200 Subject: [PATCH 8/9] refactor(parachain-staking): rename do_delegator_reward -> restake_delegator_reward The entry point for restaking a delegator's reward was named generically; rename it to state the intent -- restake the reward into the delegation -- so it reads naturally next to the fallback pay_delegator_reward_not_restaked. --- pallets/parachain-staking/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pallets/parachain-staking/src/lib.rs b/pallets/parachain-staking/src/lib.rs index ed5036bee..41c81e4f5 100644 --- a/pallets/parachain-staking/src/lib.rs +++ b/pallets/parachain-staking/src/lib.rs @@ -2745,7 +2745,7 @@ pub mod pallet { /// /// 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 do_delegator_reward( + fn restake_delegator_reward( pot: &T::AccountId, candidate: &mut CandidateOf, collator_id: &T::AccountId, @@ -3109,7 +3109,7 @@ pub mod pallet { now_rewards.into_iter().for_each(|x| match candidate.as_mut() { Some(c) => - Self::do_delegator_reward(&pot, c, &author, &x.owner, x.amount), + Self::restake_delegator_reward(&pot, c, &author, &x.owner, x.amount), None => Self::pay_delegator_reward_not_restaked( &pot, &author, &x.owner, x.amount, ), From 8bcaae120ffd66ccdc028f07ed80eedc21942a1d Mon Sep 17 00:00:00 2001 From: jaypan Date: Mon, 13 Jul 2026 11:13:58 +0200 Subject: [PATCH 9/9] style(parachain-staking): expand payout_collator benchmark call block Match the multi-line }: { ... } form used by the prepare_delayed_rewards benchmark; the benchmarks! macro body is not rustfmt-formatted. --- pallets/parachain-staking/src/benchmarking.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pallets/parachain-staking/src/benchmarking.rs b/pallets/parachain-staking/src/benchmarking.rs index ccd3a2878..6618d8751 100644 --- a/pallets/parachain-staking/src/benchmarking.rs +++ b/pallets/parachain-staking/src/benchmarking.rs @@ -670,7 +670,9 @@ benchmarks! { }); // current round must be non-zero, else payout_collator early-returns Round::::mutate(|r| { r.current = round + 1; }); - }: { Pallet::::payout_collator() } + }: { + Pallet::::payout_collator(); + } verify { // the collator's snapshot was consumed (its delegators were paid + restaked) assert!(AtStake::::get(round, &collator).is_none());