Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions prdoc/pr_9511.prdoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
title: 'EPMB/Signed: Make invulnerables non-eject-able'
doc:
- audience: Runtime Dev
description: 'Follow-up to https://github.com/paritytech/polkadot-sdk/pull/8877
and audits: Make it such that invulnerable accounts cannot be ejected from the
election signed queue altogether. '
crates:
- name: pallet-election-provider-multi-block
bump: patch
- name: pallet-staking-async
bump: patch
45 changes: 31 additions & 14 deletions substrate/frame/election-provider-multi-block/src/signed/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -455,13 +455,13 @@ pub mod pallet {
) -> Result<bool, DispatchError> {
let mut sorted_scores = SortedScores::<T>::get(round);

let discarded = if let Some(_) = sorted_scores.iter().position(|(x, _)| x == who) {
let did_eject = if let Some(_) = sorted_scores.iter().position(|(x, _)| x == who) {
return Err(Error::<T>::Duplicate.into());
} else {
// must be new.
debug_assert!(!SubmissionMetadataStorage::<T>::contains_key(round, who));

let pos = match sorted_scores
let insert_idx = match sorted_scores
.binary_search_by_key(&metadata.claimed_score, |(_, y)| *y)
{
// an equal score exists, unlikely, but could very well happen. We just put them
Expand All @@ -471,10 +471,23 @@ pub mod pallet {
Err(pos) => pos,
};

let record = (who.clone(), metadata.claimed_score);
match sorted_scores.force_insert_keep_right(pos, record) {
Ok(None) => false,
Ok(Some((discarded, _score))) => {
let mut record = (who.clone(), metadata.claimed_score);
if sorted_scores.is_full() {
let remove_idx = sorted_scores
.iter()
.position(|(x, _)| !Pallet::<T>::is_invulnerable(x))
.ok_or(Error::<T>::QueueFull)?;
if insert_idx > remove_idx {
// we have a better solution
sp_std::mem::swap(&mut sorted_scores[remove_idx], &mut record);
// slicing safety note:
// - `insert_idx` is at most `sorted_scores.len()`, obtained from
// `binary_search_by_key`, valid for the upper bound of slicing.
// - `remove_idx` is a valid index, less then `insert_idx`, obtained from
// `.iter().position()`
sorted_scores[remove_idx..insert_idx].rotate_left(1);

let discarded = record.0;
let maybe_metadata =
SubmissionMetadataStorage::<T>::take(round, &discarded).defensive();
// Note: safe to remove unbounded, as at most `Pages` pages are stored.
Expand All @@ -489,24 +502,27 @@ pub mod pallet {
Pallet::<T>::settle_deposit(
&discarded,
metadata.deposit,
if Pallet::<T>::is_invulnerable(&discarded) {
One::one()
} else {
T::EjectGraceRatio::get()
},
T::EjectGraceRatio::get(),
);
}

Pallet::<T>::deposit_event(Event::<T>::Ejected(round, discarded));
true
},
Err(_) => return Err(Error::<T>::QueueFull.into()),
} else {
// we don't have a better solution
return Err(Error::<T>::QueueFull.into())
}
} else {
sorted_scores
.try_insert(insert_idx, record)
.expect("length checked above; qed");
false
}
};

SortedScores::<T>::insert(round, sorted_scores);
SubmissionMetadataStorage::<T>::insert(round, who, metadata);
Ok(discarded)
Ok(did_eject)
}

/// Submit a page of `solution` to the `page` index of `who`'s submission.
Expand Down Expand Up @@ -1023,6 +1039,7 @@ impl<T: Config> Pallet<T> {
);
debug_assert_eq!(_res, Ok(slash));
Self::deposit_event(Event::<T>::Slashed(current_round, loser.clone(), slash));
Invulnerables::<T>::mutate(|x| x.retain(|y| y != &loser));

// Try to start verification again if we still have submissions
if let crate::types::Phase::SignedValidation(remaining_blocks) =
Expand Down
189 changes: 132 additions & 57 deletions substrate/frame/election-provider-multi-block/src/signed/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ use sp_npos_elections::ElectionScore;

pub type T = Runtime;

fn score_from(x: u128) -> ElectionScore {
ElectionScore { minimal_stake: x, ..Default::default() }
}

mod calls {
use super::*;
use sp_runtime::{DispatchError, TokenError::FundsUnavailable};
Expand Down Expand Up @@ -213,13 +217,11 @@ mod calls {
}

#[test]
fn metadata_submission_sorted_based_on_stake() {
fn metadata_submission_sorted_based_on_score() {
ExtBuilder::signed().build_and_execute(|| {
roll_to_signed_open();
assert_full_snapshot();

let score_from = |x| ElectionScore { minimal_stake: x, ..Default::default() };

assert_ok!(SignedPallet::register(RuntimeOrigin::signed(91), score_from(100)));
assert_eq!(*Submissions::<Runtime>::leaderboard(0), vec![(91, score_from(100))]);
assert_eq!(balances(91), (95, 5));
Expand Down Expand Up @@ -1259,6 +1261,8 @@ mod e2e {
}

mod invulnerables {
use frame_support::hypothetically;

use super::*;

fn make_invulnerable(who: AccountId) {
Expand Down Expand Up @@ -1395,73 +1399,102 @@ mod invulnerables {
}

#[test]
fn ejected_invulnerable_gets_deposit_back() {
ExtBuilder::signed().max_signed_submissions(2).build_and_execute(|| {
fn invulnerable_cannot_eject_first() {
ExtBuilder::signed().max_signed_submissions(3).build_and_execute(|| {
roll_to_signed_open();
assert_full_snapshot();
make_invulnerable(99);

// by default, we pay back 20% of the discarded deposit back
assert_ok!(SignedPallet::register(RuntimeOrigin::signed(99), score_from(100)));
assert_ok!(SignedPallet::register(RuntimeOrigin::signed(98), score_from(150)));
assert_ok!(SignedPallet::register(RuntimeOrigin::signed(97), score_from(200)));

assert_eq!(
<Runtime as crate::signed::Config>::EjectGraceRatio::get(),
Perbill::from_percent(20)
Submissions::<Runtime>::leaderboard(0),
vec![(99, score_from(100)), (98, score_from(150)), (97, score_from(200))]
);

// submit 99 as invulnerable
assert_ok!(SignedPallet::register(
RuntimeOrigin::signed(99),
ElectionScore { minimal_stake: 100, ..Default::default() }
));
assert!(matches!(
Submissions::<Runtime>::metadata_of(0, 99).unwrap(),
SubmissionMetadata { deposit: 7, .. }
));
// inserting someone who would eject 99 won't work.
assert_noop!(
SignedPallet::register(RuntimeOrigin::signed(96), score_from(100)),
Error::<T>::QueueFull,
);
// inserting someone who would eject 98 will work.
assert_ok!(SignedPallet::register(RuntimeOrigin::signed(96), score_from(155)));
assert_eq!(
Submissions::<Runtime>::leaderboard(0),
vec![(99, score_from(100)), (96, score_from(155)), (97, score_from(200))]
);
})
}

// submit 98 as normal
assert_ok!(SignedPallet::register(
RuntimeOrigin::signed(98),
ElectionScore { minimal_stake: 101, ..Default::default() }
));
assert!(matches!(
Submissions::<Runtime>::metadata_of(0, 98).unwrap(),
SubmissionMetadata { deposit: 5, .. }
));
let _ = signed_events_since_last_call();
assert_eq!(balances(99), (93, 7));
assert_eq!(balances(98), (95, 5));
#[test]
fn invulnerable_cannot_eject_middle() {
ExtBuilder::signed().max_signed_submissions(3).build_and_execute(|| {
roll_to_signed_open();
assert_full_snapshot();
make_invulnerable(99);

// submit 97 and 96 with higher scores, eject both of the previous ones
assert_ok!(SignedPallet::register(
RuntimeOrigin::signed(97),
ElectionScore { minimal_stake: 200, ..Default::default() }
));
assert_ok!(SignedPallet::register(
RuntimeOrigin::signed(96),
ElectionScore { minimal_stake: 201, ..Default::default() }
));
assert_ok!(SignedPallet::register(RuntimeOrigin::signed(99), score_from(150)));
assert_ok!(SignedPallet::register(RuntimeOrigin::signed(98), score_from(100)));
assert_ok!(SignedPallet::register(RuntimeOrigin::signed(97), score_from(200)));

assert_eq!(
signed_events_since_last_call(),
vec![
Event::Ejected(0, 99),
Event::Registered(
0,
97,
ElectionScore { minimal_stake: 200, sum_stake: 0, sum_stake_squared: 0 }
),
Event::Ejected(0, 98),
Event::Registered(
0,
96,
ElectionScore { minimal_stake: 201, sum_stake: 0, sum_stake_squared: 0 }
)
]
Submissions::<Runtime>::leaderboard(0),
vec![(98, score_from(100)), (99, score_from(150)), (97, score_from(200))]
);

// 99 gets everything back
assert_eq!(balances(99), (100, 0));
// 98 gets 20% x 5 = 1 back
assert_eq!(balances(98), (96, 0));
// worse than all
assert_noop!(
SignedPallet::register(RuntimeOrigin::signed(96), score_from(50)),
Error::<T>::QueueFull,
);

// better than our first item
hypothetically!({
assert_ok!(SignedPallet::register(RuntimeOrigin::signed(96), score_from(125)));
assert_eq!(
Submissions::<Runtime>::leaderboard(0),
vec![(96, score_from(125)), (99, score_from(150)), (97, score_from(200))]
);
});

// better than inv
hypothetically!({
assert_ok!(SignedPallet::register(RuntimeOrigin::signed(96), score_from(175)));
assert_eq!(
Submissions::<Runtime>::leaderboard(0),
vec![(99, score_from(150)), (96, score_from(175)), (97, score_from(200))]
);
});

// better than all
hypothetically!({
assert_ok!(SignedPallet::register(RuntimeOrigin::signed(96), score_from(225)));
assert_eq!(
Submissions::<Runtime>::leaderboard(0),
vec![(99, score_from(150)), (97, score_from(200)), (96, score_from(225))]
);
});
})
}

#[test]
fn all_invulnerables() {
ExtBuilder::signed().max_signed_submissions(3).build_and_execute(|| {
roll_to_signed_open();
assert_full_snapshot();
assert_ok!(SignedPallet::set_invulnerables(RuntimeOrigin::root(), vec![99, 98, 97]));

assert_ok!(SignedPallet::register(RuntimeOrigin::signed(99), score_from(150)));
assert_ok!(SignedPallet::register(RuntimeOrigin::signed(98), score_from(100)));
assert_ok!(SignedPallet::register(RuntimeOrigin::signed(97), score_from(200)));

// Nothing can touch our boys now.
assert_noop!(
SignedPallet::register(RuntimeOrigin::signed(96), score_from(1000)),
Error::<T>::QueueFull,
);
})
}

Expand Down Expand Up @@ -1559,6 +1592,48 @@ mod invulnerables {
assert_eq!(new_deposit, 5); // Should not be fixed deposit anymore
});
}

#[test]
fn slashed_invulnerable_is_expelled() {
ExtBuilder::signed().build_and_execute(|| {
roll_to_signed_open();
assert_full_snapshot();
make_invulnerable(99);
assert!(Invulnerables::<T>::get().contains(&99));

let invalid_score = score_from(777);
assert_ok!(SignedPallet::register(RuntimeOrigin::signed(99), invalid_score));

roll_to_signed_validation_open();
roll_next(); // one block so signed pallet will send start signal.
roll_to_full_verification();

// Check that rejection events were properly generated
assert_eq!(
verifier_events_since_last_call(),
vec![
crate::verifier::Event::Verified(2, 0), // empty page
crate::verifier::Event::Verified(1, 0), // empty page
crate::verifier::Event::Verified(0, 0), // empty page
crate::verifier::Event::VerificationFailed(0, FeasibilityError::InvalidScore),
]
);

// Check that expected events were emitted for the rejection
assert_eq!(
signed_events(),
vec![Event::Registered(0, 99, invalid_score), Event::Slashed(0, 99, 7)] /* slash
* amount
* is indeed
* the invulnerable
* deposit
* (7) ^^^^ */
);

// Verify invulnerable is expelled
assert!(!Invulnerables::<T>::get().contains(&99));
});
}
}

mod defensive_tests {
Expand Down
39 changes: 39 additions & 0 deletions substrate/frame/staking-async/runtimes/parachain/src/staking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,45 @@ use sp_runtime::{
use xcm::latest::prelude::*;

parameter_types! {
<<<<<<< HEAD
=======
/// Number of election pages that we operate upon.
///
/// * Polkadot: 32 (3.2m snapshot)
/// * Kusama: 16 (1.6m snapshot)
///
/// Reasoning: Both leads to around 700 nominators per-page, yielding the weights in
/// https://github.com/paritytech/polkadot-sdk/pull/8704, the maximum of which being around 1mb
/// compressed PoV and 2mb uncompressed.
///
/// NOTE: in principle, there is nothing preventing us from stretching these values further, it
/// will only reduce the per-page POVs. Although, some operations like the first snapshot, and
/// the last page of export (where we operate on `MaxValidatorSet` validators) will not get any
/// better.
pub storage Pages: u32 = 4;

/// * Polkadot: 16 * 32 (512 blocks, 51.2m).
/// * Kusama: 8 * 16 (12 blocks, 12.8m).
///
/// (MaxSubmissions * Pages) for both, enough to verify all solutions.
///
/// Reasoning: Less security needed in Kusama, to compensate for the shorter session duration.
pub storage SignedValidationPhase: u32 = Pages::get() * 2;

/// * Polkadot: 200 blocks, 20m.
/// * Kusama: 100 blocks, 10m.
///
/// Reasoning:
///
/// * Polkadot wishes at least 8 submitters to be able to submit. That is 8 * 32 = 256 pages
/// for all submitters. Weight of each submission page is roughly 0.0007 of block weight. 200
/// blocks is more than enough.
/// * Kusama wishes at least 4 submitters to be able to submit. That is 4 * 16 = 64 pages for
/// all submitters. Weight of each submission page is roughly 0.0007 of block weight. 100
/// blocks is more than enough.
///
/// See `signed_weight_ratios` test below for more info.
>>>>>>> 56d3c42 (EPMB/Signed: Make invulnerables non-eject-able (#9511))
pub storage SignedPhase: u32 = 4 * MINUTES;
pub storage UnsignedPhase: u32 = MINUTES;
pub storage SignedValidationPhase: u32 = Pages::get() *2;
Expand Down
Loading
Loading