diff --git a/bin/node/runtime/src/lib.rs b/bin/node/runtime/src/lib.rs index b421a4f05ed1a..2751215a3b5f1 100644 --- a/bin/node/runtime/src/lib.rs +++ b/bin/node/runtime/src/lib.rs @@ -577,6 +577,7 @@ parameter_types! { pub const SignedRewardBase: Balance = 1 * DOLLARS; pub const SignedDepositBase: Balance = 1 * DOLLARS; pub const SignedDepositByte: Balance = 1 * CENTS; + pub const ChallengeDepositDiff: Perbill = Perbill::from_percent(75); pub BetterUnsignedThreshold: Perbill = Perbill::from_rational(1u32, 10_000); @@ -710,6 +711,7 @@ impl pallet_election_provider_multi_phase::Config for Runtime { type MaxElectingVoters = MaxElectingVoters; type BenchmarkingConfig = ElectionProviderBenchmarkConfig; type WeightInfo = pallet_election_provider_multi_phase::weights::SubstrateWeight; + type ChallengeDepositDiff = ChallengeDepositDiff; } parameter_types! { diff --git a/client/peerset/src/peersstate.rs b/client/peerset/src/peersstate.rs index c9af5b8e2ccd0..4189da3d50d5a 100644 --- a/client/peerset/src/peersstate.rs +++ b/client/peerset/src/peersstate.rs @@ -735,3 +735,4 @@ mod tests { peer.disconnect(); } } + diff --git a/frame/election-provider-multi-phase/src/benchmarking.rs b/frame/election-provider-multi-phase/src/benchmarking.rs index a8195df7305ff..d37cacd0b068b 100644 --- a/frame/election-provider-multi-phase/src/benchmarking.rs +++ b/frame/election-provider-multi-phase/src/benchmarking.rs @@ -530,6 +530,37 @@ frame_benchmarking::benchmarks! { assert!(encoding.len() <= desired_size); } + challenge_solution { + + let mut signed_submissions = SignedSubmissions::::get(); + + // Insert `max - 1` submissions because the call to `submit` will insert another + // submission and the score is worse then the previous scores. + for i in 0..(T::SignedMaxSubmissions::get()) { + let raw_solution = RawSolution { + score: ElectionScore { minimal_stake: 10_000_000u128 + (i as u128), ..Default::default() }, + ..Default::default() + }; + let signed_submission = SignedSubmission { + raw_solution, + who: account("submitters", i, SEED), + deposit: Default::default(), + reward: Default::default(), + }; + signed_submissions.insert(signed_submission); + } + signed_submissions.put(); + + let challenger = frame_benchmarking::whitelisted_caller(); + T::Currency::make_free_balance_be(&challenger, T::Currency::minimum_balance() * 1000u32.into()); + >::put(Phase::Signed); + + }: _(RawOrigin::Signed(challenger), 3) + + verify { + assert!(>::signed_submissions().len() as u32 == T::SignedMaxSubmissions::get() - 1); + } + impl_benchmark_test_suite!( MultiPhase, crate::mock::ExtBuilder::default().build_offchainify(10).0, diff --git a/frame/election-provider-multi-phase/src/lib.rs b/frame/election-provider-multi-phase/src/lib.rs index 7d8559050f300..135f12e9dc19b 100644 --- a/frame/election-provider-multi-phase/src/lib.rs +++ b/frame/election-provider-multi-phase/src/lib.rs @@ -236,7 +236,7 @@ use frame_election_provider_support::{ use frame_support::{ dispatch::DispatchResultWithPostInfo, ensure, - traits::{Currency, Get, OnUnbalanced, ReservableCurrency}, + traits::{BalanceStatus::Free, Currency, Get, OnUnbalanced, ReservableCurrency}, weights::{DispatchClass, Weight}, }; use frame_system::{ensure_none, offchain::SendTransactionTypes}; @@ -705,6 +705,9 @@ pub mod pallet { /// The weight of the pallet. type WeightInfo: WeightInfo; + + #[pallet::constant] + type ChallengeDepositDiff: Get; } #[pallet::hooks] @@ -852,7 +855,7 @@ pub mod pallet { impl Pallet { /// Submit a solution for the unsigned phase. /// - /// The dispatch origin fo this call must be __none__. + /// The dispatch origin for this call must be __none__. /// /// This submission is checked on the fly. Moreover, this unsigned solution is only /// validated when submitted to the pool from the **local** node. Effectively, this means @@ -1070,6 +1073,54 @@ pub mod pallet { >::put(solution); Ok(()) } + + /// Challenge a solution in the signed phase + /// + /// If the claimed score is correct, the challenger would be slashed. + /// + /// If the claimed score is not correct, the malicious submitter will lose the deposit, + /// the solution will be ejected and the challenger will receive some reward + /// less than the solution's deposit. + #[pallet::weight(100)] + pub fn challenge_solution(origin: OriginFor, index: u32) -> DispatchResult { + let who = ensure_signed(origin)?; + + ensure!(Self::current_phase().is_signed(), Error::::CallNotAllowed); + + if let Some(submission) = Self::signed_submissions().get_submission(index) { + ensure!( + T::Currency::can_slash(&who, submission.deposit), + >::SignedCannotPayDeposit + ); + let mut signed_submissions = Self::signed_submissions(); + match Self::feasibility_check( + submission.raw_solution.clone(), + ElectionCompute::Signed, + ) { + Ok(solution) => { + let _ = T::Currency::slash(&who, submission.deposit); + >::put(solution); + let _ = signed_submissions.pop(submission.raw_solution.score); + Self::deposit_event(Event::Challenged { account: who, outcome: false }); + Ok(()) + }, + Err(_error) => { + T::Currency::repatriate_reserved( + &submission.who, + &who, + T::ChallengeDepositDiff::get() * submission.deposit, + Free, + )?; + let _ = signed_submissions.pop(submission.raw_solution.score); + signed_submissions.put(); + Self::deposit_event(Event::Challenged { account: who, outcome: true }); + Ok(()) + }, + } + } else { + return Err(Error::::InvalidSubmissionIndex.into()) + } + } } #[pallet::event] @@ -1093,6 +1144,8 @@ pub mod pallet { SignedPhaseStarted { round: u32 }, /// The unsigned phase of the given round has started. UnsignedPhaseStarted { round: u32 }, + /// A solution from `account` was challenged, with the given `outcome`. + Challenged { account: ::AccountId, outcome: bool }, } /// Error of the pallet that can be returned in response to dispatches. @@ -2017,6 +2070,58 @@ mod tests { }) } + #[test] + fn challenge_solution_works() { + ExtBuilder::default().build_and_execute(|| { + crate::mock::SignedMaxSubmissions::set(50); + roll_to(14); + assert_eq!(MultiPhase::current_phase(), Phase::Off); + + roll_to(15); + assert_eq!(multi_phase_events(), vec![Event::SignedPhaseStarted { round: 1 }]); + assert_eq!(MultiPhase::current_phase(), Phase::Signed); + + let mut solution = raw_solution(); + + solution.score.minimal_stake += 1; + + assert!(mock::Balances::usable_balance(&99) == 100); + assert_ok!(MultiPhase::submit(crate::mock::Origin::signed(99), Box::new(solution),)); + assert_eq!(crate::mock::Balances::usable_balance(&9999), 100); + assert_ok!(MultiPhase::challenge_solution(crate::mock::Origin::signed(9999), 0)); + assert!(crate::mock::Balances::free_balance(&9999) > 100); + }) + } + + #[test] + fn unsuccessful_challenge_works() { + ExtBuilder::default().build_and_execute(|| { + crate::mock::SignedMaxSubmissions::set(50); + roll_to(14); + assert_eq!(MultiPhase::current_phase(), Phase::Off); + + roll_to(15); + assert_eq!(multi_phase_events(), vec![Event::SignedPhaseStarted { round: 1 }]); + assert_eq!(MultiPhase::current_phase(), Phase::Signed); + + let solution = raw_solution(); + + assert!(mock::Balances::usable_balance(&99) == 100); + assert_ok!(MultiPhase::submit(crate::mock::Origin::signed(99), Box::new(solution),)); + assert_eq!(crate::mock::Balances::usable_balance(&9999), 100); + assert_ok!(MultiPhase::challenge_solution(crate::mock::Origin::signed(9999), 0)); + assert_eq!( + MultiPhase::signed_submissions().iter().map(|s| s.deposit).collect::>(), + vec![5] + ); + assert_eq!(crate::mock::Balances::usable_balance(&9999), 95); + assert_eq!( + MultiPhase::signed_submissions().iter().map(|s| s.reward).collect::>(), + vec![15] + ); + }) + } + #[test] fn fallback_strategy_works() { ExtBuilder::default().onchain_fallback(true).build_and_execute(|| { diff --git a/frame/election-provider-multi-phase/src/mock.rs b/frame/election-provider-multi-phase/src/mock.rs index bbc2d6d43beee..38cc7e71e7b13 100644 --- a/frame/election-provider-multi-phase/src/mock.rs +++ b/frame/election-provider-multi-phase/src/mock.rs @@ -283,7 +283,10 @@ parameter_types! { pub static MaxElectableTargets: TargetIndex = TargetIndex::max_value(); pub static EpochLength: u64 = 30; + pub static OnChainFallback: bool = true; + + pub static ChallengeDepositDiff: Perbill = Perbill::from_percent(75); } pub struct OnChainSeqPhragmen; @@ -387,6 +390,7 @@ impl crate::Config for Runtime { type MaxElectableTargets = MaxElectableTargets; type MinerConfig = Self; type Solver = SequentialPhragmen, Balancing>; + type ChallengeDepositDiff = ChallengeDepositDiff; } impl frame_system::offchain::SendTransactionTypes for Runtime diff --git a/frame/election-provider-multi-phase/src/signed.rs b/frame/election-provider-multi-phase/src/signed.rs index eca75139f925a..08117c301b3ea 100644 --- a/frame/election-provider-multi-phase/src/signed.rs +++ b/frame/election-provider-multi-phase/src/signed.rs @@ -172,7 +172,7 @@ impl SignedSubmissions { } /// Get the submission at a particular index. - fn get_submission(&self, index: u32) -> Option> { + pub fn get_submission(&self, index: u32) -> Option> { 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 @@ -337,6 +337,11 @@ impl SignedSubmissions { let score = *score; self.swap_out_submission(score, None) } + + // Remove a signed submission by score from the set + pub fn pop(&mut self, remove_score: ElectionScore) -> Option> { + self.swap_out_submission(remove_score, None) + } } impl Deref for SignedSubmissions {