Skip to content

Commit 317d6fe

Browse files
[AHM/Staking] Allow own stake to be collected in all pages (#10051)
TLDR: Thanks to a number of reports from Kusama validator, we have been investigating an issue where the self stake of some validators is not present in `ErasStakersPaged` and `ErasStakersOverview`, and consequently not earning staking rewards. After investigation, it was clear that the `polkadot-staking-miner` is doing the right thing, and the self-stake is indeed submitted to the chain as a part of the staking election result. The root cause in how `pallet-staking-async` ingests the staking election result. This code **made a (wrong) assumption that self-stake is only present in the first page of a multi-page election. This PR fixes this issue.** --- - [x] (nice to have) revisit try-state checks of the pallet, `ErasStakersPaged`, `ErasStakersOverview` and `ErasTotalStake` are triple checked for correctness - [ ] (nice to have) fix related displays in PJS: polkadot-js/apps#11930 - [ ] backport to 2507 and 2509 - Westend - [ ] Find a similar issue in westend for monitoring (paritytech/devops#4402) - [ ] Upgrade westend to a branch/release containing this fix - [ ] Upgrade westend with this fix, observe test examples are resolved - [ ] Bump backported version in `fellowship/runtimes` - [ ] Release 1.9.3 for Kusama with this fix - [ ] Ensure known test cases are fixed - One example: `F2WyUUFXLYnBg6acv7t2KFzH6D7CyNcvC4mRCwUdsHTUB4t` (in election round 46, likely repeating in subsequent ones too) - This PR will be part of Polkadot release 2.0.0, the Polkadot AHM. - [ ] Forum post with further explanation once PR is merged. --------- Co-authored-by: cmd[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent db5c89f commit 317d6fe

File tree

6 files changed

+556
-210
lines changed

6 files changed

+556
-210
lines changed

prdoc/pr_10051.prdoc

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
title: '[AHM/Staking] Allow own stake to be collected in all pages'
2+
doc:
3+
- audience: Runtime Dev
4+
description: |-
5+
TLDR: Thanks to a number of reports from Kusama validator, we have been investigating an issue where the self stake of some validators is not present in `ErasStakersPaged` and `ErasStakersOverview`. After investigation, it was clear that the `polkadot-staking-miner` is doing the right thing, and the self-stake is indeed submitted to the chain as a part of the election result. The root cause in how `pallet-staking-async` ingests the paginated election result. This code **made a (wrong) assumption that self-stake is only present in the first page of a multi-page election. This PR fixes this issue.**
6+
crates:
7+
- name: pallet-staking-async
8+
bump: patch

substrate/frame/staking-async/src/benchmarking.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@ pub use frame_benchmarking::{
2929
};
3030
use frame_election_provider_support::SortedListProvider;
3131
use frame_support::{
32-
assert_ok, pallet_prelude::*, storage::bounded_vec::BoundedVec, traits::TryCollect,
32+
assert_ok,
33+
pallet_prelude::*,
34+
storage::bounded_vec::BoundedVec,
35+
traits::{fungible::Inspect, TryCollect},
3336
};
3437
use frame_system::RawOrigin;
3538
use pallet_staking_async_rc_client as rc_client;
@@ -1226,14 +1229,15 @@ mod benchmarks {
12261229
.map(|validator_index| account::<T::AccountId>("validator", validator_index, SEED))
12271230
.for_each(|validator| {
12281231
let exposure = sp_staking::Exposure::<T::AccountId, BalanceOf<T>> {
1229-
own: BalanceOf::<T>::max_value(),
1230-
total: BalanceOf::<T>::max_value(),
1232+
own: T::Currency::minimum_balance(),
1233+
total: T::Currency::minimum_balance() *
1234+
(exposed_nominators_per_validator + 1).into(),
12311235
others: (0..exposed_nominators_per_validator)
12321236
.map(|n| {
12331237
let nominator = account::<T::AccountId>("nominator", n, SEED);
12341238
IndividualExposure {
12351239
who: nominator,
1236-
value: BalanceOf::<T>::max_value(),
1240+
value: T::Currency::minimum_balance(),
12371241
}
12381242
})
12391243
.collect::<Vec<_>>(),

substrate/frame/staking-async/src/pallet/impls.rs

Lines changed: 50 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1949,78 +1949,62 @@ impl<T: Config> Pallet<T> {
19491949
}
19501950

19511951
/// Invariants:
1952-
/// * ActiveEra is Some.
1953-
/// * For each paged era exposed validator, check if the exposure total is sane (exposure.total
1954-
/// = exposure.own + exposure.own).
1955-
/// * Paged exposures metadata (`ErasStakersOverview`) matches the paged exposures state.
1952+
/// Nothing to do if ActiveEra is not set.
1953+
/// For each page in `ErasStakersPaged`, `page_total` must be set.
1954+
/// For each metadata:
1955+
/// * page_count is correct
1956+
/// * nominator_count is correct
1957+
/// * total is own + sum of pages
1958+
/// `ErasTotalStake`` must be correct
19561959
fn check_paged_exposures() -> Result<(), TryRuntimeError> {
1957-
use alloc::collections::btree_map::BTreeMap;
1958-
use sp_staking::PagedExposureMetadata;
1959-
1960-
// Sanity check for the paged exposure of the active era.
1961-
let mut exposures: BTreeMap<T::AccountId, PagedExposureMetadata<BalanceOf<T>>> =
1962-
BTreeMap::new();
1963-
// If the pallet is not initialized, we return immediately from pallet's do_try_state() and
1964-
// we don't call this method. Otherwise, Eras::do_try_state enforces that both ActiveEra
1965-
// and CurrentEra are Some. Thus, we should never hit this error.
1966-
let era = ActiveEra::<T>::get()
1967-
.ok_or(TryRuntimeError::Other("ActiveEra must be set when checking paged exposures"))?
1968-
.index;
1969-
1970-
let accumulator_default = PagedExposureMetadata {
1971-
total: Zero::zero(),
1972-
own: Zero::zero(),
1973-
nominator_count: 0,
1974-
page_count: 0,
1975-
};
1976-
1977-
ErasStakersPaged::<T>::iter_prefix((era,))
1978-
.map(|((validator, _page), expo)| {
1979-
ensure!(
1980-
expo.page_total ==
1981-
expo.others.iter().map(|e| e.value).fold(Zero::zero(), |acc, x| acc + x),
1982-
"wrong total exposure for the page.",
1983-
);
1984-
1985-
let metadata = exposures.get(&validator).unwrap_or(&accumulator_default);
1986-
exposures.insert(
1987-
validator,
1988-
PagedExposureMetadata {
1989-
total: metadata.total + expo.page_total,
1990-
own: metadata.own,
1991-
nominator_count: metadata.nominator_count + expo.others.len() as u32,
1992-
page_count: metadata.page_count + 1,
1993-
},
1994-
);
1995-
1996-
Ok(())
1960+
let Some(era) = ActiveEra::<T>::get().map(|a| a.index) else { return Ok(()) };
1961+
let overview_and_pages = ErasStakersOverview::<T>::iter_prefix(era)
1962+
.map(|(validator, metadata)| {
1963+
let pages = ErasStakersPaged::<T>::iter_prefix((era, validator))
1964+
.map(|(_idx, page)| page)
1965+
.collect::<Vec<_>>();
1966+
(metadata, pages)
19971967
})
1998-
.collect::<Result<(), TryRuntimeError>>()?;
1968+
.collect::<Vec<_>>();
19991969

2000-
exposures
2001-
.iter()
2002-
.map(|(validator, metadata)| {
2003-
let actual_overview = ErasStakersOverview::<T>::get(era, validator);
1970+
ensure!(
1971+
overview_and_pages.iter().flat_map(|(_m, pages)| pages).all(|page| {
1972+
let expected = page
1973+
.others
1974+
.iter()
1975+
.map(|e| e.value)
1976+
.fold(BalanceOf::<T>::zero(), |acc, x| acc + x);
1977+
page.page_total == expected
1978+
}),
1979+
"found wrong page_total"
1980+
);
20041981

2005-
ensure!(actual_overview.is_some(), "No overview found for a paged exposure");
2006-
let actual_overview = actual_overview.unwrap();
1982+
ensure!(
1983+
overview_and_pages.iter().all(|(metadata, pages)| {
1984+
let page_count_good = metadata.page_count == pages.len() as u32;
1985+
let nominator_count_good = metadata.nominator_count ==
1986+
pages.iter().map(|p| p.others.len() as u32).fold(0u32, |acc, x| acc + x);
1987+
let total_good = metadata.total ==
1988+
metadata.own +
1989+
pages
1990+
.iter()
1991+
.fold(BalanceOf::<T>::zero(), |acc, page| acc + page.page_total);
1992+
1993+
page_count_good && nominator_count_good && total_good
1994+
}),
1995+
"found bad metadata"
1996+
);
20071997

2008-
ensure!(
2009-
actual_overview.total == metadata.total + actual_overview.own,
2010-
"Exposure metadata does not have correct total exposed stake."
2011-
);
2012-
ensure!(
2013-
actual_overview.nominator_count == metadata.nominator_count,
2014-
"Exposure metadata does not have correct count of nominators."
2015-
);
2016-
ensure!(
2017-
actual_overview.page_count == metadata.page_count,
2018-
"Exposure metadata does not have correct count of pages."
2019-
);
1998+
ensure!(
1999+
overview_and_pages
2000+
.iter()
2001+
.map(|(metadata, _pages)| metadata.total)
2002+
.fold(BalanceOf::<T>::zero(), |acc, x| acc + x) ==
2003+
ErasTotalStake::<T>::get(era),
2004+
"found bad eras total stake"
2005+
);
20202006

2021-
Ok(())
2022-
})
2023-
.collect::<Result<(), TryRuntimeError>>()
2007+
Ok(())
20242008
}
20252009

20262010
/// Ensures offence pipeline and slashing is in a healthy state.

substrate/frame/staking-async/src/session_rotation.rs

Lines changed: 88 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -127,16 +127,19 @@ impl<T: Config> Eras<T> {
127127

128128
/// Get exposure for a validator at a given era and page.
129129
///
130+
/// This is mainly used for rewards and slashing. Validator's self-stake is only returned in
131+
/// page 0.
132+
///
130133
/// This builds a paged exposure from `PagedExposureMetadata` and `ExposurePage` of the
131-
/// validator. For older non-paged exposure, it returns the clipped exposure directly.
134+
/// validator.
132135
pub(crate) fn get_paged_exposure(
133136
era: EraIndex,
134137
validator: &T::AccountId,
135138
page: Page,
136139
) -> Option<PagedExposure<T::AccountId, BalanceOf<T>>> {
137140
let overview = <ErasStakersOverview<T>>::get(&era, validator)?;
138141

139-
// validator stake is added only in page zero
142+
// validator stake is added only in page zero.
140143
let validator_stake = if page == 0 { overview.own } else { Zero::zero() };
141144

142145
// since overview is present, paged exposure will always be present except when a
@@ -230,57 +233,96 @@ impl<T: Config> Eras<T> {
230233
mut exposure: Exposure<T::AccountId, BalanceOf<T>>,
231234
) {
232235
let page_size = T::MaxExposurePageSize::get().defensive_max(1);
236+
if cfg!(debug_assertions) {
237+
// sanitize the exposure in case some test data is wrong.
238+
let expected_total = exposure
239+
.others
240+
.iter()
241+
.map(|ie| ie.value)
242+
.fold::<BalanceOf<T>, _>(Default::default(), |acc, x| acc + x)
243+
.saturating_add(exposure.own);
244+
debug_assert_eq!(expected_total, exposure.total, "exposure total must equal own + sum(others) for (era: {:?}, validator: {:?}, exposure: {:?})", era, validator, exposure);
245+
}
233246

234-
if let Some(stored_overview) = ErasStakersOverview::<T>::get(era, &validator) {
235-
let last_page_idx = stored_overview.page_count.saturating_sub(1);
236-
247+
if let Some(overview) = ErasStakersOverview::<T>::get(era, &validator) {
248+
// collect some info from the un-touched overview for later use.
249+
let last_page_idx = overview.page_count.saturating_sub(1);
237250
let mut last_page =
238251
ErasStakersPaged::<T>::get((era, validator, last_page_idx)).unwrap_or_default();
239252
let last_page_empty_slots =
240253
T::MaxExposurePageSize::get().saturating_sub(last_page.others.len() as u32);
241254

242-
// splits the exposure so that `exposures_append` will fit within the last exposure
243-
// page, up to the max exposure page size. The remaining individual exposures in
244-
// `exposure` will be added to new pages.
245-
let exposures_append = exposure.split_others(last_page_empty_slots);
246-
247-
ErasStakersOverview::<T>::mutate(era, &validator, |stored| {
248-
// new metadata is updated based on 3 different set of exposures: the
249-
// current one, the exposure split to be "fitted" into the current last page and
250-
// the exposure set that will be appended from the new page onwards.
251-
let new_metadata =
252-
stored.defensive_unwrap_or_default().update_with::<T::MaxExposurePageSize>(
253-
[&exposures_append, &exposure]
254-
.iter()
255-
.fold(Default::default(), |total, expo| {
256-
total.saturating_add(expo.total.saturating_sub(expo.own))
257-
}),
258-
[&exposures_append, &exposure]
259-
.iter()
260-
.fold(Default::default(), |count, expo| {
261-
count.saturating_add(expo.others.len() as u32)
262-
}),
255+
// update nominator-count, page-count, and total stake in overview (done in
256+
// `update_with`).
257+
let new_stake_added = exposure.total;
258+
let new_nominators_added = exposure.others.len() as u32;
259+
let mut updated_overview = overview
260+
.update_with::<T::MaxExposurePageSize>(new_stake_added, new_nominators_added);
261+
262+
// update own stake, if applicable.
263+
match (updated_overview.own.is_zero(), exposure.own.is_zero()) {
264+
(true, false) => {
265+
// first time we see own exposure -- good.
266+
// note: `total` is already updated above.
267+
updated_overview.own = exposure.own;
268+
},
269+
(true, true) | (false, true) => {
270+
// no new own exposure is added, nothing to do
271+
},
272+
(false, false) => {
273+
debug_assert!(
274+
false,
275+
"validator own stake already set in overview for (era: {:?}, validator: {:?}, current overview: {:?}, new exposure: {:?})",
276+
era,
277+
validator,
278+
updated_overview,
279+
exposure,
263280
);
264-
*stored = new_metadata.into();
265-
});
281+
defensive!("duplicate validator self stake in election");
282+
},
283+
};
284+
285+
ErasStakersOverview::<T>::insert(era, &validator, updated_overview);
286+
// we are done updating the overview now, `updated_overview` should not be used anymore.
287+
// We've updated:
288+
// * nominator count
289+
// * total stake
290+
// * own stake (if applicable)
291+
// * page count
292+
//
293+
// next step:
294+
// * new-keys or updates in `ErasStakersPaged`
295+
//
296+
// we don't need the information about own stake anymore -- drop it.
297+
exposure.total = exposure.total.saturating_sub(exposure.own);
298+
exposure.own = Zero::zero();
299+
300+
// splits the exposure so that `append_to_last_page` will fit within the last exposure
301+
// page, up to the max exposure page size. The remaining individual exposures in
302+
// `put_in_new_pages` will be added to new pages.
303+
let append_to_last_page = exposure.split_others(last_page_empty_slots);
304+
let put_in_new_pages = exposure;
305+
306+
// handle last page first.
266307

267308
// fill up last page with exposures.
268-
last_page.page_total = last_page
269-
.page_total
270-
.saturating_add(exposures_append.total)
271-
.saturating_sub(exposures_append.own);
272-
last_page.others.extend(exposures_append.others);
309+
last_page.page_total = last_page.page_total.saturating_add(append_to_last_page.total);
310+
last_page.others.extend(append_to_last_page.others);
273311
ErasStakersPaged::<T>::insert((era, &validator, last_page_idx), last_page);
274312

275313
// now handle the remaining exposures and append the exposure pages. The metadata update
276314
// has been already handled above.
277-
let (_, exposure_pages) = exposure.into_pages(page_size);
278-
279-
exposure_pages.into_iter().enumerate().for_each(|(idx, paged_exposure)| {
280-
let append_at =
281-
(last_page_idx.saturating_add(1).saturating_add(idx as u32)) as Page;
282-
<ErasStakersPaged<T>>::insert((era, &validator, append_at), paged_exposure);
283-
});
315+
let (_unused_metadata, put_in_new_pages_chunks) =
316+
put_in_new_pages.into_pages(page_size);
317+
318+
put_in_new_pages_chunks
319+
.into_iter()
320+
.enumerate()
321+
.for_each(|(idx, paged_exposure)| {
322+
let append_at =
323+
(last_page_idx.saturating_add(1).saturating_add(idx as u32)) as Page;
324+
<ErasStakersPaged<T>>::insert((era, &validator, append_at), paged_exposure);
325+
});
284326
} else {
285327
// expected page count is the number of nominators divided by the page size, rounded up.
286328
let expected_page_count = exposure
@@ -1003,16 +1045,17 @@ impl<T: Config> EraElectionPlanner<T> {
10031045
exposures.into_iter().for_each(|(stash, exposure)| {
10041046
log!(
10051047
trace,
1006-
"stored exposure for stash {:?} and {:?} backers",
1048+
"storing exposure for stash {:?} with {:?} own-stake and {:?} backers",
10071049
stash,
1050+
exposure.own,
10081051
exposure.others.len()
10091052
);
10101053
// build elected stash.
10111054
elected_stashes_page.push(stash.clone());
1012-
// accumulate total stake.
1055+
// accumulate total stake and backer count for bookkeeping.
10131056
total_stake_page = total_stake_page.saturating_add(exposure.total);
1014-
// set or update staker exposure for this era.
10151057
total_backers += exposure.others.len() as u32;
1058+
// set or update staker exposure for this era.
10161059
Eras::<T>::upsert_exposure(new_planned_era, &stash, exposure);
10171060
});
10181061

@@ -1025,6 +1068,8 @@ impl<T: Config> EraElectionPlanner<T> {
10251068
Eras::<T>::add_total_stake(new_planned_era, total_stake_page);
10261069

10271070
// collect or update the pref of all winners.
1071+
// TODO: rather inefficient, we can do this once at the last page across all entries in
1072+
// `ElectableStashes`.
10281073
for stash in &elected_stashes {
10291074
let pref = Validators::<T>::get(stash);
10301075
Eras::<T>::set_validator_prefs(new_planned_era, stash, pref);

0 commit comments

Comments
 (0)