EPM: allow duplicate submissions (#12237)

* allow for duplicate signed submissions

* Fix a bunch of things, seems all good now

* fmt

* Fix

* Update frame/election-provider-multi-phase/src/signed.rs

Co-authored-by: Niklas Adolfsson <niklasadolfsson1@gmail.com>

* Update frame/election-provider-multi-phase/src/signed.rs

Co-authored-by: Niklas Adolfsson <niklasadolfsson1@gmail.com>

* add migratin

* fmt

* comment typo

* some review comments

* fix bench

Co-authored-by: Niklas Adolfsson <niklasadolfsson1@gmail.com>
Co-authored-by: Ross Bulat <ross@parity.io>
This commit is contained in:
Kian Paimani
2022-10-19 21:05:27 +01:00
committed by GitHub
parent 4870337d34
commit 0fe016eed7
5 changed files with 292 additions and 103 deletions
@@ -24,11 +24,11 @@ use crate::{
};
use codec::{Decode, Encode, HasCompact};
use frame_election_provider_support::NposSolution;
use frame_support::{
storage::bounded_btree_map::BoundedBTreeMap,
traits::{defensive_prelude::*, Currency, Get, OnUnbalanced, ReservableCurrency},
use frame_support::traits::{
defensive_prelude::*, Currency, Get, OnUnbalanced, ReservableCurrency,
};
use sp_arithmetic::traits::SaturatedConversion;
use sp_core::bounded::BoundedVec;
use sp_npos_elections::ElectionScore;
use sp_runtime::{
traits::{Saturating, Zero},
@@ -37,7 +37,6 @@ use sp_runtime::{
use sp_std::{
cmp::Ordering,
collections::{btree_map::BTreeMap, btree_set::BTreeSet},
ops::Deref,
vec::Vec,
};
@@ -99,8 +98,12 @@ pub type SignedSubmissionOf<T> = SignedSubmission<
<<T as crate::Config>::MinerConfig as MinerConfig>::Solution,
>;
pub type SubmissionIndicesOf<T> =
BoundedBTreeMap<ElectionScore, u32, <T as Config>::SignedMaxSubmissions>;
/// Always sorted vector of a score, submitted at the given block number, which can be found at the
/// given index (`u32`) of the `SignedSubmissionsMap`.
pub type SubmissionIndicesOf<T> = BoundedVec<
(ElectionScore, <T as frame_system::Config>::BlockNumber, u32),
<T as Config>::SignedMaxSubmissions,
>;
/// Outcome of [`SignedSubmissions::insert`].
pub enum InsertResult<T: Config> {
@@ -126,6 +129,16 @@ pub struct SignedSubmissions<T: Config> {
}
impl<T: Config> SignedSubmissions<T> {
/// `true` if the structure is empty.
pub fn is_empty(&self) -> bool {
self.indices.is_empty()
}
/// Get the length of submitted solutions.
pub fn len(&self) -> usize {
self.indices.len()
}
/// Get the signed submissions from storage.
pub fn get() -> Self {
let submissions = SignedSubmissions {
@@ -134,10 +147,12 @@ impl<T: Config> SignedSubmissions<T> {
insertion_overlay: BTreeMap::new(),
deletion_overlay: BTreeSet::new(),
};
// validate that the stored state is sane
debug_assert!(submissions
.indices
.values()
.iter()
.map(|(_, _, index)| index)
.copied()
.max()
.map_or(true, |max_idx| submissions.next_idx > max_idx,));
@@ -155,7 +170,8 @@ impl<T: Config> SignedSubmissions<T> {
.map_or(true, |max_idx| self.next_idx > max_idx,));
debug_assert!(self
.indices
.values()
.iter()
.map(|(_, _, index)| index)
.copied()
.max()
.map_or(true, |max_idx| self.next_idx > max_idx,));
@@ -174,9 +190,9 @@ impl<T: Config> SignedSubmissions<T> {
/// Get the submission at a particular index.
fn get_submission(&self, index: u32) -> Option<SignedSubmissionOf<T>> {
if self.deletion_overlay.contains(&index) {
// Note: can't actually remove the item from the insertion overlay (if present)
// because we don't want to use `&mut self` here. There may be some kind of
// `RefCell` optimization possible here in the future.
// Note: can't actually remove the item from the insertion overlay (if present) because
// we don't want to use `&mut self` here. There may be some kind of `RefCell`
// optimization possible here in the future.
None
} else {
self.insertion_overlay
@@ -188,27 +204,30 @@ impl<T: Config> SignedSubmissions<T> {
/// Perform three operations:
///
/// - Remove a submission (identified by score)
/// - Insert a new submission (identified by score and insertion index)
/// - Return the submission which was removed.
/// - Remove the solution at the given position of `self.indices`.
/// - Insert a new submission (identified by score and insertion index), if provided.
/// - Return the submission which was removed, if any.
///
/// Note: in the case that `weakest_score` is not present in `self.indices`, this will return
/// `None` without inserting the new submission and without further notice.
///
/// Note: this does not enforce any ordering relation between the submission removed and that
/// inserted.
/// The call site must ensure that `remove_pos` is a valid index. If otherwise, `None` is
/// silently returned.
///
/// Note: this doesn't insert into `insertion_overlay`, the optional new insertion must be
/// inserted into `insertion_overlay` to keep the variable `self` in a valid state.
/// inserted into `insertion_overlay` to keep the variable `self` in a valid state.
fn swap_out_submission(
&mut self,
remove_score: ElectionScore,
insert: Option<(ElectionScore, u32)>,
remove_pos: usize,
insert: Option<(ElectionScore, T::BlockNumber, u32)>,
) -> Option<SignedSubmissionOf<T>> {
let remove_index = self.indices.remove(&remove_score)?;
if let Some((insert_score, insert_idx)) = insert {
if remove_pos >= self.indices.len() {
return None
}
// safe: index was just checked in the line above.
let (_, _, remove_index) = self.indices.remove(remove_pos);
if let Some((insert_score, block_number, insert_idx)) = insert {
self.indices
.try_insert(insert_score, insert_idx)
.try_push((insert_score, block_number, insert_idx))
.expect("just removed an item, we must be under capacity; qed");
}
@@ -222,20 +241,17 @@ impl<T: Config> SignedSubmissions<T> {
})
}
/// Remove the signed submission with the highest score from the set.
pub fn pop_last(&mut self) -> Option<SignedSubmissionOf<T>> {
let best_index = self.indices.len().checked_sub(1)?;
self.swap_out_submission(best_index, None)
}
/// Iterate through the set of signed submissions in order of increasing score.
pub fn iter(&self) -> impl '_ + Iterator<Item = SignedSubmissionOf<T>> {
self.indices.iter().filter_map(move |(_score, &idx)| {
let maybe_submission = self.get_submission(idx);
if maybe_submission.is_none() {
log!(
error,
"SignedSubmissions internal state is invalid (idx {}); \
there is a logic error in code handling signed solution submissions",
idx,
)
}
maybe_submission
})
self.indices
.iter()
.filter_map(move |(_score, _bn, idx)| self.get_submission(*idx).defensive())
}
/// Empty the set of signed submissions, returning an iterator of signed submissions in
@@ -283,68 +299,54 @@ impl<T: Config> SignedSubmissions<T> {
/// to `is_score_better`, we do not change anything.
pub fn insert(&mut self, submission: SignedSubmissionOf<T>) -> InsertResult<T> {
// verify the expectation that we never reuse an index
debug_assert!(!self.indices.values().any(|&idx| idx == self.next_idx));
debug_assert!(!self.indices.iter().map(|(_, _, x)| x).any(|&idx| idx == self.next_idx));
let block_number = frame_system::Pallet::<T>::block_number();
let weakest = match self.indices.try_insert(submission.raw_solution.score, self.next_idx) {
Ok(Some(prev_idx)) => {
// a submission of equal score was already present in the set;
// no point editing the actual backing map as we know that the newer solution can't
// be better than the old. However, we do need to put the old value back.
self.indices
.try_insert(submission.raw_solution.score, prev_idx)
.expect("didn't change the map size; qed");
return InsertResult::NotInserted
},
Ok(None) => {
// successfully inserted into the set; no need to take out weakest member
None
},
Err((insert_score, insert_idx)) => {
// could not insert into the set because it is full.
// note that we short-circuit return here in case the iteration produces `None`.
// If there wasn't a weakest entry to remove, then there must be a capacity of 0,
// which means that we can't meaningfully proceed.
let weakest_score = match self.indices.iter().next() {
let maybe_weakest = match self.indices.try_push((
submission.raw_solution.score,
block_number,
self.next_idx,
)) {
Ok(_) => None,
Err(_) => {
// the queue is full -- if this is better, insert it.
let weakest_score = match self.indices.iter().next().defensive() {
None => return InsertResult::NotInserted,
Some((score, _)) => *score,
Some((score, _, _)) => *score,
};
let threshold = T::BetterSignedThreshold::get();
// if we haven't improved on the weakest score, don't change anything.
if !insert_score.strict_threshold_better(weakest_score, threshold) {
if !submission.raw_solution.score.strict_threshold_better(weakest_score, threshold)
{
return InsertResult::NotInserted
}
self.swap_out_submission(weakest_score, Some((insert_score, insert_idx)))
self.swap_out_submission(
0, // swap out the worse one, which is always index 0.
Some((submission.raw_solution.score, block_number, self.next_idx)),
)
},
};
// this is the ONLY place that we insert, and we sort post insertion. If scores are the
// same, we sort based on reverse of submission block number.
self.indices
.sort_by(|(score1, bn1, _), (score2, bn2, _)| match score1.cmp(score2) {
Ordering::Equal => bn1.cmp(&bn2).reverse(),
x => x,
});
// we've taken out the weakest, so update the storage map and the next index
debug_assert!(!self.insertion_overlay.contains_key(&self.next_idx));
self.insertion_overlay.insert(self.next_idx, submission);
debug_assert!(!self.deletion_overlay.contains(&self.next_idx));
self.next_idx += 1;
match weakest {
match maybe_weakest {
Some(weakest) => InsertResult::InsertedEjecting(weakest),
None => InsertResult::Inserted,
}
}
/// Remove the signed submission with the highest score from the set.
pub fn pop_last(&mut self) -> Option<SignedSubmissionOf<T>> {
let (score, _) = self.indices.iter().rev().next()?;
// deref in advance to prevent mutable-immutable borrow conflict
let score = *score;
self.swap_out_submission(score, None)
}
}
impl<T: Config> Deref for SignedSubmissions<T> {
type Target = SubmissionIndicesOf<T>;
fn deref(&self) -> &Self::Target {
&self.indices
}
}
impl<T: Config> Pallet<T> {
@@ -379,6 +381,12 @@ impl<T: Config> Pallet<T> {
Self::snapshot_metadata().unwrap_or_default();
while let Some(best) = all_submissions.pop_last() {
log!(
debug,
"finalized_signed: trying to verify from {:?} score {:?}",
best.who,
best.raw_solution.score
);
let SignedSubmission { raw_solution, who, deposit, call_fee } = best;
let active_voters = raw_solution.solution.voter_count() as u32;
let feasibility_weight = {
@@ -386,6 +394,7 @@ impl<T: Config> Pallet<T> {
let desired_targets = Self::desired_targets().defensive_unwrap_or_default();
T::WeightInfo::feasibility_check(voters, targets, active_voters, desired_targets)
};
// the feasibility check itself has some weight
weight = weight.saturating_add(feasibility_weight);
match Self::feasibility_check(raw_solution, ElectionCompute::Signed) {
@@ -397,12 +406,14 @@ impl<T: Config> Pallet<T> {
call_fee,
);
found_solution = true;
log!(debug, "finalized_signed: found a valid solution");
weight = weight
.saturating_add(T::WeightInfo::finalize_signed_phase_accept_solution());
break
},
Err(_) => {
log!(warn, "finalized_signed: invalid signed submission found, slashing.");
Self::finalize_signed_phase_reject_solution(&who, deposit);
weight = weight
.saturating_add(T::WeightInfo::finalize_signed_phase_reject_solution());
@@ -526,14 +537,7 @@ impl<T: Config> Pallet<T> {
#[cfg(test)]
mod tests {
use super::*;
use crate::{
mock::{
balances, multi_phase_events, raw_solution, roll_to, roll_to_signed, Balances,
ExtBuilder, MockedWeightInfo, MultiPhase, Runtime, RuntimeOrigin, SignedMaxRefunds,
SignedMaxSubmissions, SignedMaxWeight,
},
Error, Event, Perbill, Phase,
};
use crate::{mock::*, ElectionCompute, Error, Event, Perbill, Phase};
use frame_support::{assert_noop, assert_ok, assert_storage_noop};
#[test]
@@ -868,8 +872,8 @@ mod tests {
}
#[test]
fn replace_weakest_works() {
ExtBuilder::default().build_and_execute(|| {
fn replace_weakest_by_score_works() {
ExtBuilder::default().signed_max_submission(3).build_and_execute(|| {
roll_to_signed();
assert!(MultiPhase::current_phase().is_signed());
@@ -893,7 +897,7 @@ mod tests {
.iter()
.map(|s| s.raw_solution.score.minimal_stake)
.collect::<Vec<_>>(),
vec![4, 6, 7, 8, 9],
vec![4, 6, 7],
);
// better.
@@ -909,7 +913,7 @@ mod tests {
.iter()
.map(|s| s.raw_solution.score.minimal_stake)
.collect::<Vec<_>>(),
vec![5, 6, 7, 8, 9],
vec![5, 6, 7],
);
})
}
@@ -946,7 +950,8 @@ mod tests {
}
#[test]
fn equally_good_solution_is_not_accepted() {
fn equally_good_solution_is_not_accepted_when_queue_full() {
// because in ordering of solutions, an older solution has higher priority and should stay.
ExtBuilder::default().signed_max_submission(3).build_and_execute(|| {
roll_to_signed();
assert!(MultiPhase::current_phase().is_signed());
@@ -958,6 +963,7 @@ mod tests {
};
assert_ok!(MultiPhase::submit(RuntimeOrigin::signed(99), Box::new(solution)));
}
assert_eq!(
MultiPhase::signed_submissions()
.iter()
@@ -978,6 +984,99 @@ mod tests {
})
}
#[test]
fn equally_good_solution_is_accepted_when_queue_not_full() {
// because in ordering of solutions, an older solution has higher priority and should stay.
ExtBuilder::default().signed_max_submission(3).build_and_execute(|| {
roll_to(15);
assert!(MultiPhase::current_phase().is_signed());
let solution = RawSolution {
score: ElectionScore { minimal_stake: 5, ..Default::default() },
..Default::default()
};
assert_ok!(MultiPhase::submit(RuntimeOrigin::signed(99), Box::new(solution)));
assert_eq!(
MultiPhase::signed_submissions()
.iter()
.map(|s| (s.who, s.raw_solution.score.minimal_stake,))
.collect::<Vec<_>>(),
vec![(99, 5)]
);
roll_to(16);
let solution = RawSolution {
score: ElectionScore { minimal_stake: 5, ..Default::default() },
..Default::default()
};
assert_ok!(MultiPhase::submit(RuntimeOrigin::signed(999), Box::new(solution)));
assert_eq!(
MultiPhase::signed_submissions()
.iter()
.map(|s| (s.who, s.raw_solution.score.minimal_stake,))
.collect::<Vec<_>>(),
vec![(999, 5), (99, 5)]
);
let solution = RawSolution {
score: ElectionScore { minimal_stake: 6, ..Default::default() },
..Default::default()
};
assert_ok!(MultiPhase::submit(RuntimeOrigin::signed(9999), Box::new(solution)));
assert_eq!(
MultiPhase::signed_submissions()
.iter()
.map(|s| (s.who, s.raw_solution.score.minimal_stake,))
.collect::<Vec<_>>(),
vec![(999, 5), (99, 5), (9999, 6)]
);
})
}
#[test]
fn all_equal_score() {
// because in ordering of solutions, an older solution has higher priority and should stay.
ExtBuilder::default().signed_max_submission(3).build_and_execute(|| {
roll_to(15);
assert!(MultiPhase::current_phase().is_signed());
for i in 0..SignedMaxSubmissions::get() {
roll_to((15 + i).into());
let solution = raw_solution();
assert_ok!(MultiPhase::submit(
RuntimeOrigin::signed(100 + i as AccountId),
Box::new(solution)
));
}
assert_eq!(
MultiPhase::signed_submissions()
.iter()
.map(|s| (s.who, s.raw_solution.score.minimal_stake))
.collect::<Vec<_>>(),
vec![(102, 40), (101, 40), (100, 40)]
);
roll_to(25);
// The first one that will actually get verified is the last one.
assert_eq!(
multi_phase_events(),
vec![
Event::SignedPhaseStarted { round: 1 },
Event::SolutionStored { compute: ElectionCompute::Signed, prev_ejected: false },
Event::SolutionStored { compute: ElectionCompute::Signed, prev_ejected: false },
Event::SolutionStored { compute: ElectionCompute::Signed, prev_ejected: false },
Event::Rewarded { account: 100, value: 7 },
Event::UnsignedPhaseStarted { round: 1 }
]
);
})
}
#[test]
fn all_in_one_signed_submission_scenario() {
// a combination of:
@@ -991,6 +1090,7 @@ mod tests {
assert_eq!(balances(&99), (100, 0));
assert_eq!(balances(&999), (100, 0));
assert_eq!(balances(&9999), (100, 0));
let solution = raw_solution();
// submit a correct one.