From 8a388a4fb774aab47d77dcd8cd6b6c1a4c8f51b1 Mon Sep 17 00:00:00 2001 From: Kian Paimani <5588131+kianenigma@users.noreply.github.com> Date: Mon, 20 Oct 2025 20:13:45 +0100 Subject: [PATCH] [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: https://github.com/polkadot-js/apps/pull/11930 - [ ] backport to 2507 and 2509 - Westend - [ ] Find a similar issue in westend for monitoring (https://github.com/paritytech/devops/issues/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> (cherry picked from commit 56e4b10bbbbcefcf9e4fe879c88c68902181fc4a) --- prdoc/pr_10051.prdoc | 8 + .../frame/staking-async/src/benchmarking.rs | 12 +- .../frame/staking-async/src/pallet/impls.rs | 116 ++--- .../staking-async/src/session_rotation.rs | 132 +++-- .../src/tests/election_provider.rs | 471 ++++++++++++++---- .../staking-async/src/tests/try_state.rs | 28 ++ 6 files changed, 557 insertions(+), 210 deletions(-) create mode 100644 prdoc/pr_10051.prdoc diff --git a/prdoc/pr_10051.prdoc b/prdoc/pr_10051.prdoc new file mode 100644 index 0000000000000..990944183bcc4 --- /dev/null +++ b/prdoc/pr_10051.prdoc @@ -0,0 +1,8 @@ +title: '[AHM/Staking] Allow own stake to be collected in all pages' +doc: +- audience: Runtime Dev + description: |- + 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.** +crates: +- name: pallet-staking-async + bump: patch diff --git a/substrate/frame/staking-async/src/benchmarking.rs b/substrate/frame/staking-async/src/benchmarking.rs index 481c3c5188eaf..584a56e40c1ec 100644 --- a/substrate/frame/staking-async/src/benchmarking.rs +++ b/substrate/frame/staking-async/src/benchmarking.rs @@ -29,7 +29,10 @@ pub use frame_benchmarking::{ }; use frame_election_provider_support::SortedListProvider; use frame_support::{ - assert_ok, pallet_prelude::*, storage::bounded_vec::BoundedVec, traits::TryCollect, + assert_ok, + pallet_prelude::*, + storage::bounded_vec::BoundedVec, + traits::{fungible::Inspect, TryCollect}, }; use frame_system::RawOrigin; use pallet_staking_async_rc_client as rc_client; @@ -1226,14 +1229,15 @@ mod benchmarks { .map(|validator_index| account::("validator", validator_index, SEED)) .for_each(|validator| { let exposure = sp_staking::Exposure::> { - own: BalanceOf::::max_value(), - total: BalanceOf::::max_value(), + own: T::Currency::minimum_balance(), + total: T::Currency::minimum_balance() * + (exposed_nominators_per_validator + 1).into(), others: (0..exposed_nominators_per_validator) .map(|n| { let nominator = account::("nominator", n, SEED); IndividualExposure { who: nominator, - value: BalanceOf::::max_value(), + value: T::Currency::minimum_balance(), } }) .collect::>(), diff --git a/substrate/frame/staking-async/src/pallet/impls.rs b/substrate/frame/staking-async/src/pallet/impls.rs index 4f9715dd163e4..e5c594f6998da 100644 --- a/substrate/frame/staking-async/src/pallet/impls.rs +++ b/substrate/frame/staking-async/src/pallet/impls.rs @@ -1949,78 +1949,62 @@ impl Pallet { } /// Invariants: - /// * ActiveEra is Some. - /// * For each paged era exposed validator, check if the exposure total is sane (exposure.total - /// = exposure.own + exposure.own). - /// * Paged exposures metadata (`ErasStakersOverview`) matches the paged exposures state. + /// Nothing to do if ActiveEra is not set. + /// For each page in `ErasStakersPaged`, `page_total` must be set. + /// For each metadata: + /// * page_count is correct + /// * nominator_count is correct + /// * total is own + sum of pages + /// `ErasTotalStake`` must be correct fn check_paged_exposures() -> Result<(), TryRuntimeError> { - use alloc::collections::btree_map::BTreeMap; - use sp_staking::PagedExposureMetadata; - - // Sanity check for the paged exposure of the active era. - let mut exposures: BTreeMap>> = - BTreeMap::new(); - // If the pallet is not initialized, we return immediately from pallet's do_try_state() and - // we don't call this method. Otherwise, Eras::do_try_state enforces that both ActiveEra - // and CurrentEra are Some. Thus, we should never hit this error. - let era = ActiveEra::::get() - .ok_or(TryRuntimeError::Other("ActiveEra must be set when checking paged exposures"))? - .index; - - let accumulator_default = PagedExposureMetadata { - total: Zero::zero(), - own: Zero::zero(), - nominator_count: 0, - page_count: 0, - }; - - ErasStakersPaged::::iter_prefix((era,)) - .map(|((validator, _page), expo)| { - ensure!( - expo.page_total == - expo.others.iter().map(|e| e.value).fold(Zero::zero(), |acc, x| acc + x), - "wrong total exposure for the page.", - ); - - let metadata = exposures.get(&validator).unwrap_or(&accumulator_default); - exposures.insert( - validator, - PagedExposureMetadata { - total: metadata.total + expo.page_total, - own: metadata.own, - nominator_count: metadata.nominator_count + expo.others.len() as u32, - page_count: metadata.page_count + 1, - }, - ); - - Ok(()) + let Some(era) = ActiveEra::::get().map(|a| a.index) else { return Ok(()) }; + let overview_and_pages = ErasStakersOverview::::iter_prefix(era) + .map(|(validator, metadata)| { + let pages = ErasStakersPaged::::iter_prefix((era, validator)) + .map(|(_idx, page)| page) + .collect::>(); + (metadata, pages) }) - .collect::>()?; + .collect::>(); - exposures - .iter() - .map(|(validator, metadata)| { - let actual_overview = ErasStakersOverview::::get(era, validator); + ensure!( + overview_and_pages.iter().flat_map(|(_m, pages)| pages).all(|page| { + let expected = page + .others + .iter() + .map(|e| e.value) + .fold(BalanceOf::::zero(), |acc, x| acc + x); + page.page_total == expected + }), + "found wrong page_total" + ); - ensure!(actual_overview.is_some(), "No overview found for a paged exposure"); - let actual_overview = actual_overview.unwrap(); + ensure!( + overview_and_pages.iter().all(|(metadata, pages)| { + let page_count_good = metadata.page_count == pages.len() as u32; + let nominator_count_good = metadata.nominator_count == + pages.iter().map(|p| p.others.len() as u32).fold(0u32, |acc, x| acc + x); + let total_good = metadata.total == + metadata.own + + pages + .iter() + .fold(BalanceOf::::zero(), |acc, page| acc + page.page_total); + + page_count_good && nominator_count_good && total_good + }), + "found bad metadata" + ); - ensure!( - actual_overview.total == metadata.total + actual_overview.own, - "Exposure metadata does not have correct total exposed stake." - ); - ensure!( - actual_overview.nominator_count == metadata.nominator_count, - "Exposure metadata does not have correct count of nominators." - ); - ensure!( - actual_overview.page_count == metadata.page_count, - "Exposure metadata does not have correct count of pages." - ); + ensure!( + overview_and_pages + .iter() + .map(|(metadata, _pages)| metadata.total) + .fold(BalanceOf::::zero(), |acc, x| acc + x) == + ErasTotalStake::::get(era), + "found bad eras total stake" + ); - Ok(()) - }) - .collect::>() + Ok(()) } /// Ensures offence pipeline and slashing is in a healthy state. diff --git a/substrate/frame/staking-async/src/session_rotation.rs b/substrate/frame/staking-async/src/session_rotation.rs index ac6edd9d6cf6b..ff0b1e12d52af 100644 --- a/substrate/frame/staking-async/src/session_rotation.rs +++ b/substrate/frame/staking-async/src/session_rotation.rs @@ -127,8 +127,11 @@ impl Eras { /// Get exposure for a validator at a given era and page. /// + /// This is mainly used for rewards and slashing. Validator's self-stake is only returned in + /// page 0. + /// /// This builds a paged exposure from `PagedExposureMetadata` and `ExposurePage` of the - /// validator. For older non-paged exposure, it returns the clipped exposure directly. + /// validator. pub(crate) fn get_paged_exposure( era: EraIndex, validator: &T::AccountId, @@ -136,7 +139,7 @@ impl Eras { ) -> Option>> { let overview = >::get(&era, validator)?; - // validator stake is added only in page zero + // validator stake is added only in page zero. let validator_stake = if page == 0 { overview.own } else { Zero::zero() }; // since overview is present, paged exposure will always be present except when a @@ -230,57 +233,97 @@ impl Eras { mut exposure: Exposure>, ) { let page_size = T::MaxExposurePageSize::get().defensive_max(1); + if cfg!(debug_assertions) && cfg!(not(feature = "runtime-benchmarks")) { + // sanitize the exposure in case some test data from this pallet is wrong. + // ignore benchmarks as other pallets might do weird things. + let expected_total = exposure + .others + .iter() + .map(|ie| ie.value) + .fold::, _>(Default::default(), |acc, x| acc + x) + .saturating_add(exposure.own); + debug_assert_eq!(expected_total, exposure.total, "exposure total must equal own + sum(others) for (era: {:?}, validator: {:?}, exposure: {:?})", era, validator, exposure); + } - if let Some(stored_overview) = ErasStakersOverview::::get(era, &validator) { - let last_page_idx = stored_overview.page_count.saturating_sub(1); - + if let Some(overview) = ErasStakersOverview::::get(era, &validator) { + // collect some info from the un-touched overview for later use. + let last_page_idx = overview.page_count.saturating_sub(1); let mut last_page = ErasStakersPaged::::get((era, validator, last_page_idx)).unwrap_or_default(); let last_page_empty_slots = T::MaxExposurePageSize::get().saturating_sub(last_page.others.len() as u32); - // splits the exposure so that `exposures_append` will fit within the last exposure - // page, up to the max exposure page size. The remaining individual exposures in - // `exposure` will be added to new pages. - let exposures_append = exposure.split_others(last_page_empty_slots); - - ErasStakersOverview::::mutate(era, &validator, |stored| { - // new metadata is updated based on 3 different set of exposures: the - // current one, the exposure split to be "fitted" into the current last page and - // the exposure set that will be appended from the new page onwards. - let new_metadata = - stored.defensive_unwrap_or_default().update_with::( - [&exposures_append, &exposure] - .iter() - .fold(Default::default(), |total, expo| { - total.saturating_add(expo.total.saturating_sub(expo.own)) - }), - [&exposures_append, &exposure] - .iter() - .fold(Default::default(), |count, expo| { - count.saturating_add(expo.others.len() as u32) - }), + // update nominator-count, page-count, and total stake in overview (done in + // `update_with`). + let new_stake_added = exposure.total; + let new_nominators_added = exposure.others.len() as u32; + let mut updated_overview = overview + .update_with::(new_stake_added, new_nominators_added); + + // update own stake, if applicable. + match (updated_overview.own.is_zero(), exposure.own.is_zero()) { + (true, false) => { + // first time we see own exposure -- good. + // note: `total` is already updated above. + updated_overview.own = exposure.own; + }, + (true, true) | (false, true) => { + // no new own exposure is added, nothing to do + }, + (false, false) => { + debug_assert!( + false, + "validator own stake already set in overview for (era: {:?}, validator: {:?}, current overview: {:?}, new exposure: {:?})", + era, + validator, + updated_overview, + exposure, ); - *stored = new_metadata.into(); - }); + defensive!("duplicate validator self stake in election"); + }, + }; + + ErasStakersOverview::::insert(era, &validator, updated_overview); + // we are done updating the overview now, `updated_overview` should not be used anymore. + // We've updated: + // * nominator count + // * total stake + // * own stake (if applicable) + // * page count + // + // next step: + // * new-keys or updates in `ErasStakersPaged` + // + // we don't need the information about own stake anymore -- drop it. + exposure.total = exposure.total.saturating_sub(exposure.own); + exposure.own = Zero::zero(); + + // splits the exposure so that `append_to_last_page` will fit within the last exposure + // page, up to the max exposure page size. The remaining individual exposures in + // `put_in_new_pages` will be added to new pages. + let append_to_last_page = exposure.split_others(last_page_empty_slots); + let put_in_new_pages = exposure; + + // handle last page first. // fill up last page with exposures. - last_page.page_total = last_page - .page_total - .saturating_add(exposures_append.total) - .saturating_sub(exposures_append.own); - last_page.others.extend(exposures_append.others); + last_page.page_total = last_page.page_total.saturating_add(append_to_last_page.total); + last_page.others.extend(append_to_last_page.others); ErasStakersPaged::::insert((era, &validator, last_page_idx), last_page); // now handle the remaining exposures and append the exposure pages. The metadata update // has been already handled above. - let (_, exposure_pages) = exposure.into_pages(page_size); - - exposure_pages.into_iter().enumerate().for_each(|(idx, paged_exposure)| { - let append_at = - (last_page_idx.saturating_add(1).saturating_add(idx as u32)) as Page; - >::insert((era, &validator, append_at), paged_exposure); - }); + let (_unused_metadata, put_in_new_pages_chunks) = + put_in_new_pages.into_pages(page_size); + + put_in_new_pages_chunks + .into_iter() + .enumerate() + .for_each(|(idx, paged_exposure)| { + let append_at = + (last_page_idx.saturating_add(1).saturating_add(idx as u32)) as Page; + >::insert((era, &validator, append_at), paged_exposure); + }); } else { // expected page count is the number of nominators divided by the page size, rounded up. let expected_page_count = exposure @@ -1003,16 +1046,17 @@ impl EraElectionPlanner { exposures.into_iter().for_each(|(stash, exposure)| { log!( trace, - "stored exposure for stash {:?} and {:?} backers", + "storing exposure for stash {:?} with {:?} own-stake and {:?} backers", stash, + exposure.own, exposure.others.len() ); // build elected stash. elected_stashes_page.push(stash.clone()); - // accumulate total stake. + // accumulate total stake and backer count for bookkeeping. total_stake_page = total_stake_page.saturating_add(exposure.total); - // set or update staker exposure for this era. total_backers += exposure.others.len() as u32; + // set or update staker exposure for this era. Eras::::upsert_exposure(new_planned_era, &stash, exposure); }); @@ -1025,6 +1069,8 @@ impl EraElectionPlanner { Eras::::add_total_stake(new_planned_era, total_stake_page); // collect or update the pref of all winners. + // TODO: rather inefficient, we can do this once at the last page across all entries in + // `ElectableStashes`. for stash in &elected_stashes { let pref = Validators::::get(stash); Eras::::set_validator_prefs(new_planned_era, stash, pref); diff --git a/substrate/frame/staking-async/src/tests/election_provider.rs b/substrate/frame/staking-async/src/tests/election_provider.rs index 51dbc4c2702f4..55bed312e2698 100644 --- a/substrate/frame/staking-async/src/tests/election_provider.rs +++ b/substrate/frame/staking-async/src/tests/election_provider.rs @@ -290,6 +290,8 @@ fn less_than_needed_candidates_works() { } mod paged_exposures { + use crate::session_rotation::Rotator; + use super::*; #[test] @@ -337,137 +339,412 @@ mod paged_exposures { assert_eq!(exposure_metadata.nominator_count, 19); } + fn exposure_pages(era: u32, who: AccountId) -> Vec<(u32, (Balance, u32))> { + let mut res = ErasStakersPaged::::iter_prefix((era, who)) + .map(|(page, expo)| (page, (expo.page_total, expo.others.len() as u32))) + .collect::>(); + res.sort_by_key(|(page, _)| *page); + res + } + #[test] - fn store_stakers_info_elect_works() { - ExtBuilder::default().exposures_page_size(2).build_and_execute(|| { - assert_eq!(MaxExposurePageSize::get(), 2); - - let exposure_one = Exposure { - total: 1000 + 700, - own: 1000, - others: vec![ - IndividualExposure { who: 101, value: 500 }, - IndividualExposure { who: 102, value: 100 }, - IndividualExposure { who: 103, value: 100 }, - ], - }; - - let exposure_two = Exposure { - total: 1000 + 1000, - own: 1000, - others: vec![ - IndividualExposure { who: 104, value: 500 }, - IndividualExposure { who: 105, value: 500 }, - ], - }; - - let exposure_three = Exposure { - total: 1000 + 500, - own: 1000, - others: vec![ - IndividualExposure { who: 110, value: 250 }, - IndividualExposure { who: 111, value: 250 }, - ], - }; - - let exposures_page_one = bounded_vec![(1, exposure_one), (2, exposure_two),]; - let exposures_page_two = bounded_vec![(1, exposure_three),]; + fn store_stakers_info_elect_works_page_size_1() { + // scenario: + // 2 page election. 5 validators involved. + // Validator 1 has own-stake in first page, other-stake in both pages. + // Validator 2 has own-stake in second page, other-stake in both pages + // Validator 3 has no own-stake, other-stake in both pages + // Validator 4 has no own-stake, other-stake in second page only + // Validator 5 has no own-stake, other-stake in first page only + // all other stakes are 250, and the same 101 account as it makes no difference. + ExtBuilder::default().exposures_page_size(1).build_and_execute(|| { + // roll 1 session such that we are in "planning more" + Session::roll_to_next_session(); + assert_eq!(Rotator::::planned_era(), 2); + assert_eq!(Rotator::::active_era(), 1); + assert_eq!(ErasTotalStake::::get(Rotator::::planned_era()), 0); + assert_eq!( + ErasStakersOverview::::iter_prefix(Rotator::::planned_era()).count(), + 0 + ); + assert_eq!( + ErasStakersPaged::::iter_prefix((Rotator::::planned_era(),)).count(), + 0 + ); + + // stuff that goes into first page + let exposures_page1 = bounded_vec![ + // validator 1, own-stake and other stake + ( + 1, + Exposure:: { + total: 1000 + 250, + own: 1000, + others: vec![IndividualExposure { who: 101, value: 250 }] + } + ), + // validator 2, other stake only + ( + 2, + Exposure:: { + total: 250, + own: 0, + others: vec![IndividualExposure { who: 101, value: 250 }] + } + ), + // validator 3, part of other stake only + ( + 3, + Exposure:: { + total: 250, + own: 0, + others: vec![IndividualExposure { who: 101, value: 250 }] + } + ), + // validator 4, nothing + // validator 5, part of other stake only + ( + 5, + Exposure:: { + total: 250, + own: 0, + others: vec![IndividualExposure { who: 101, value: 250 }] + } + ), + ]; + + // stuff that goes into second page + let exposure_page2: BoundedExposuresOf = bounded_vec![ + // validator 1, other stake only + ( + 1, + Exposure:: { + total: 250, + own: 0, + others: vec![IndividualExposure { who: 101, value: 250 }] + } + ), + // validator 2, own-stake and other stake + ( + 2, + Exposure:: { + total: 1000 + 250, + own: 1000, + others: vec![IndividualExposure { who: 101, value: 250 }] + } + ), + // validator 3, part of other stake only + ( + 3, + Exposure:: { + total: 250, + own: 0, + others: vec![IndividualExposure { who: 101, value: 250 }] + } + ), + // validator 4, part of other stake only + ( + 4, + Exposure:: { + total: 250, + own: 0, + others: vec![IndividualExposure { who: 101, value: 250 }] + } + ), + // validator 5, nothing + ]; // our exposures are stored for this era. let current_era = current_era(); + assert_eq!(ErasTotalStake::::get(current_era), 0); - // stores exposure page with exposures of validator 1 and 2, returns exposed validator - // account id. + // insert page 1 of exposures assert_eq!( - EraElectionPlanner::::store_stakers_info(exposures_page_one, current_era) - .to_vec(), - vec![1, 2] + EraElectionPlanner::::store_stakers_info(exposures_page1, current_era).to_vec(), + vec![1, 2, 3, 5] ); - // Stakers overview OK for validator 1 and 2. + // overviews after inserting first page of exposures. assert_eq!( ErasStakersOverview::::get(current_era, &1).unwrap(), - PagedExposureMetadata { total: 1700, own: 1000, nominator_count: 3, page_count: 2 }, + PagedExposureMetadata { total: 1250, own: 1000, nominator_count: 1, page_count: 1 }, ); assert_eq!( ErasStakersOverview::::get(current_era, &2).unwrap(), - PagedExposureMetadata { total: 2000, own: 1000, nominator_count: 2, page_count: 1 }, + PagedExposureMetadata { total: 250, own: 0, nominator_count: 1, page_count: 1 }, ); + assert_eq!( + ErasStakersOverview::::get(current_era, &3).unwrap(), + PagedExposureMetadata { total: 250, own: 0, nominator_count: 1, page_count: 1 }, + ); + assert!(ErasStakersOverview::::get(current_era, &4).is_none()); + assert_eq!( + ErasStakersOverview::::get(current_era, &5).unwrap(), + PagedExposureMetadata { total: 250, own: 0, nominator_count: 1, page_count: 1 }, + ); + + // total stake after first page of exposures. + assert_eq!(ErasTotalStake::::get(current_era), 2000); - // stores exposure page with exposures of validator 1, returns exposed validator - // account id. + // details after first page of exposures. + assert_eq!(exposure_pages(current_era, 1), vec![(0, (250, 1))]); + assert_eq!(exposure_pages(current_era, 2), vec![(0, (250, 1))]); + assert_eq!(exposure_pages(current_era, 3), vec![(0, (250, 1))]); + assert_eq!(exposure_pages(current_era, 4), vec![]); + assert_eq!(exposure_pages(current_era, 5), vec![(0, (250, 1))]); + + // insert page 2 of exposures assert_eq!( - EraElectionPlanner::::store_stakers_info(exposures_page_two, current_era) - .to_vec(), - vec![1] + EraElectionPlanner::::store_stakers_info(exposure_page2, current_era).to_vec(), + vec![1, 2, 3, 4] ); - // Stakers overview OK for validator 1. + // overviews after inserting second page of exposures. assert_eq!( ErasStakersOverview::::get(current_era, &1).unwrap(), - PagedExposureMetadata { total: 2200, own: 1000, nominator_count: 5, page_count: 3 }, + PagedExposureMetadata { total: 1500, own: 1000, nominator_count: 2, page_count: 2 }, + ); + assert_eq!( + ErasStakersOverview::::get(current_era, &2).unwrap(), + PagedExposureMetadata { total: 1500, own: 1000, nominator_count: 2, page_count: 2 }, + ); + assert_eq!( + ErasStakersOverview::::get(current_era, &3).unwrap(), + PagedExposureMetadata { total: 500, own: 0, nominator_count: 2, page_count: 2 }, + ); + assert_eq!( + ErasStakersOverview::::get(current_era, &4).unwrap(), + PagedExposureMetadata { total: 250, own: 0, nominator_count: 1, page_count: 1 }, ); + assert_eq!( + ErasStakersOverview::::get(current_era, &5).unwrap(), + PagedExposureMetadata { total: 250, own: 0, nominator_count: 1, page_count: 1 }, + ); + + // total stake after second page of exposures. + assert_eq!(ErasTotalStake::::get(current_era), 4000); + + // details after second page of exposures. + assert_eq!(exposure_pages(current_era, 1), vec![(0, (250, 1)), (1, (250, 1))]); + assert_eq!(exposure_pages(current_era, 2), vec![(0, (250, 1)), (1, (250, 1))]); + assert_eq!(exposure_pages(current_era, 3), vec![(0, (250, 1)), (1, (250, 1))]); + assert_eq!(exposure_pages(current_era, 4), vec![(0, (250, 1))]); + assert_eq!(exposure_pages(current_era, 5), vec![(0, (250, 1))]); + }) + } - // validator 1 has 3 paged exposures. - assert!( - ErasStakersPaged::::iter_prefix_values((current_era, &1)).count() as u32 == - Eras::::exposure_page_count(current_era, &1) && - Eras::::exposure_page_count(current_era, &1) == 3 + #[test] + fn store_stakers_info_elect_works_page_size_2() { + // scenario: + // 2 page election. 5 validators involved. + // Validator 1 has own-stake in first page, other-stake in both pages (3 + 2). + // Validator 2 has own-stake in second page, other-stake in both pages (2 + 3) + // Validator 3 has no own-stake, other-stake in both pages (3 + 1) + // Validator 4 has no own-stake, other-stake in second page only (0 + 3) + // Validator 5 has no own-stake, other-stake in first page only (2 + 0) + // all other stakes are 250, and the same 101 (or more) account as it makes no difference. + ExtBuilder::default().exposures_page_size(2).build_and_execute(|| { + // roll 1 session such that we are in "planning more" + Session::roll_to_next_session(); + assert_eq!(Rotator::::planned_era(), 2); + assert_eq!(Rotator::::active_era(), 1); + assert_eq!(ErasTotalStake::::get(Rotator::::planned_era()), 0); + assert_eq!( + ErasStakersOverview::::iter_prefix(Rotator::::planned_era()).count(), + 0 + ); + assert_eq!( + ErasStakersPaged::::iter_prefix((Rotator::::planned_era(),)).count(), + 0 ); - assert!(ErasStakersPaged::::get((current_era, &1, 0)).is_some()); - assert!(ErasStakersPaged::::get((current_era, &1, 1)).is_some()); - assert!(ErasStakersPaged::::get((current_era, &1, 2)).is_some()); - assert!(ErasStakersPaged::::get((current_era, &1, 3)).is_none()); - // validator 2 has 1 paged exposures. - assert!(ErasStakersPaged::::get((current_era, &2, 0)).is_some()); - assert!(ErasStakersPaged::::get((current_era, &2, 1)).is_none()); - assert_eq!(ErasStakersPaged::::iter_prefix_values((current_era, &2)).count(), 1); + // stuff that goes into first page + let exposures_page1 = bounded_vec![ + // validator 1, own-stake and other stake + ( + 1, + Exposure:: { + total: 1000 + 750, + own: 1000, + others: vec![ + IndividualExposure { who: 101, value: 250 }, + IndividualExposure { who: 102, value: 250 }, + IndividualExposure { who: 103, value: 250 }, + ] + } + ), + // validator 2, other stake only + ( + 2, + Exposure:: { + total: 500, + own: 0, + others: vec![ + IndividualExposure { who: 101, value: 250 }, + IndividualExposure { who: 102, value: 250 }, + ] + } + ), + // validator 3, part of other stake only + ( + 3, + Exposure:: { + total: 750, + own: 0, + others: vec![ + IndividualExposure { who: 101, value: 250 }, + IndividualExposure { who: 102, value: 250 }, + IndividualExposure { who: 103, value: 250 } + ] + } + ), + // validator 4, nothing + // validator 5, all of other stake only + ( + 5, + Exposure:: { + total: 500, + own: 0, + others: vec![ + IndividualExposure { who: 101, value: 250 }, + IndividualExposure { who: 102, value: 250 }, + ] + } + ), + ]; + + let exposure_page2: BoundedExposuresOf = bounded_vec![ + // validator 1, other stake only + ( + 1, + Exposure:: { + total: 500, + own: 0, + others: vec![ + IndividualExposure { who: 104, value: 250 }, + IndividualExposure { who: 105, value: 250 } + ] + } + ), + // validator 2, own-stake and other stake + ( + 2, + Exposure:: { + total: 1000 + 750, + own: 1000, + others: vec![ + IndividualExposure { who: 103, value: 250 }, + IndividualExposure { who: 104, value: 250 }, + IndividualExposure { who: 105, value: 250 }, + ] + } + ), + // validator 3, part of other stake only + ( + 3, + Exposure:: { + total: 250, + own: 0, + others: vec![IndividualExposure { who: 103, value: 250 },] + } + ), + // validator 4, all of other stake only + ( + 4, + Exposure:: { + total: 750, + own: 0, + others: vec![ + IndividualExposure { who: 101, value: 250 }, + IndividualExposure { who: 102, value: 250 }, + IndividualExposure { who: 103, value: 250 } + ] + } + ), + // validator 5, nothing + ]; + + // our exposures are stored for this era. + let current_era = current_era(); - // exposures of validator 1 are the expected: + // insert page 1 of exposures assert_eq!( - ErasStakersPaged::::get((current_era, &1, 0)).unwrap(), - ExposurePage { - page_total: 600, - others: vec![ - IndividualExposure { who: 101, value: 500 }, - IndividualExposure { who: 102, value: 100 } - ] - } - .into(), + EraElectionPlanner::::store_stakers_info(exposures_page1, current_era).to_vec(), + vec![1, 2, 3, 5] ); + + // overviews after inserting first page of exposures. assert_eq!( - ErasStakersPaged::::get((current_era, &1, 1)).unwrap(), - ExposurePage { - page_total: 350, - others: vec![ - IndividualExposure { who: 103, value: 100 }, - IndividualExposure { who: 110, value: 250 } - ] - } - .into() + ErasStakersOverview::::get(current_era, &1).unwrap(), + PagedExposureMetadata { total: 1750, own: 1000, nominator_count: 3, page_count: 2 }, + ); + assert_eq!( + ErasStakersOverview::::get(current_era, &2).unwrap(), + PagedExposureMetadata { total: 500, own: 0, nominator_count: 2, page_count: 1 }, ); assert_eq!( - ErasStakersPaged::::get((current_era, &1, 2)).unwrap(), - ExposurePage { - page_total: 250, - others: vec![IndividualExposure { who: 111, value: 250 }] - } - .into() + ErasStakersOverview::::get(current_era, &3).unwrap(), + PagedExposureMetadata { total: 750, own: 0, nominator_count: 3, page_count: 2 }, ); + assert!(ErasStakersOverview::::get(current_era, &4).is_none()); + assert_eq!( + ErasStakersOverview::::get(current_era, &5).unwrap(), + PagedExposureMetadata { total: 500, own: 0, nominator_count: 2, page_count: 1 }, + ); + + // total stake after first page of exposures. + assert_eq!(ErasTotalStake::::get(current_era), 3500); + + // details after first page of exposures. + assert_eq!(exposure_pages(current_era, 1), vec![(0, (500, 2)), (1, (250, 1))]); + assert_eq!(exposure_pages(current_era, 2), vec![(0, (500, 2))]); + assert_eq!(exposure_pages(current_era, 3), vec![(0, (500, 2)), (1, (250, 1))]); + assert_eq!(exposure_pages(current_era, 4), vec![]); + assert_eq!(exposure_pages(current_era, 5), vec![(0, (500, 2))]); - // exposures of validator 2. + // insert page 2 of exposures assert_eq!( - ErasStakersPaged::::iter_prefix_values((current_era, &2)).collect::>(), - vec![ExposurePage { - page_total: 1000, - others: vec![ - IndividualExposure { who: 104, value: 500 }, - IndividualExposure { who: 105, value: 500 } - ] - } - .into()], + EraElectionPlanner::::store_stakers_info(exposure_page2, current_era).to_vec(), + vec![1, 2, 3, 4] + ); + + // overviews after inserting second page of exposures. + assert_eq!( + ErasStakersOverview::::get(current_era, &1).unwrap(), + PagedExposureMetadata { total: 2250, own: 1000, nominator_count: 5, page_count: 3 }, + ); + assert_eq!( + ErasStakersOverview::::get(current_era, &2).unwrap(), + PagedExposureMetadata { total: 2250, own: 1000, nominator_count: 5, page_count: 3 }, + ); + assert_eq!( + ErasStakersOverview::::get(current_era, &3).unwrap(), + PagedExposureMetadata { total: 1000, own: 0, nominator_count: 4, page_count: 2 }, + ); + assert_eq!( + ErasStakersOverview::::get(current_era, &4).unwrap(), + PagedExposureMetadata { total: 750, own: 0, nominator_count: 3, page_count: 2 }, + ); + assert_eq!( + ErasStakersOverview::::get(current_era, &5).unwrap(), + PagedExposureMetadata { total: 500, own: 0, nominator_count: 2, page_count: 1 }, + ); + + // total stake after second page of exposures. + assert_eq!(ErasTotalStake::::get(current_era), 6750); + + // details after second page of exposures. + assert_eq!( + exposure_pages(current_era, 1), + vec![(0, (500, 2)), (1, (500, 2)), (2, (250, 1))] + ); + assert_eq!( + exposure_pages(current_era, 2), + vec![(0, (500, 2)), (1, (500, 2)), (2, (250, 1))] ); + assert_eq!(exposure_pages(current_era, 3), vec![(0, (500, 2)), (1, (500, 2))]); + assert_eq!(exposure_pages(current_era, 4), vec![(0, (500, 2)), (1, (250, 1))]); + assert_eq!(exposure_pages(current_era, 5), vec![(0, (500, 2))]); }) } } diff --git a/substrate/frame/staking-async/src/tests/try_state.rs b/substrate/frame/staking-async/src/tests/try_state.rs index 19e2bef28ee25..d1d0111ea18f7 100644 --- a/substrate/frame/staking-async/src/tests/try_state.rs +++ b/substrate/frame/staking-async/src/tests/try_state.rs @@ -69,3 +69,31 @@ fn try_state_detects_inconsistent_active_current_era() { assert_ok!(Staking::do_try_state(System::block_number())); }); } + +#[test] +fn try_state_bad_exposure() { + ExtBuilder::default().try_state(false).build_and_execute(|| { + Session::roll_until_active_era(2); + assert!(Staking::do_try_state(System::block_number()).is_ok()); + + let (validator, mut metadata) = ErasStakersOverview::::iter() + .take(1) + .map(|(_era, validator, metadata)| (validator, metadata)) + .collect::>() + .pop() + .unwrap(); + metadata.total += 1; + ErasStakersOverview::::insert(2, validator, metadata); + assert!(Staking::do_try_state(System::block_number()).is_err()); + }); +} + +#[test] +fn try_state_bad_eras_total_stake() { + ExtBuilder::default().try_state(false).build_and_execute(|| { + Session::roll_until_active_era(2); + assert!(Staking::do_try_state(System::block_number()).is_ok()); + ErasTotalStake::::mutate(2, |s| *s -= 1); + assert!(Staking::do_try_state(System::block_number()).is_err()); + }); +}