referrerpolicy=no-referrer-when-downgrade

pallet_election_provider_multi_block/unsigned/
mod.rs

1// This file is part of Substrate.
2
3// Copyright (C) Parity Technologies (UK) Ltd.
4// SPDX-License-Identifier: Apache-2.0
5
6// Licensed under the Apache License, Version 2.0 (the "License");
7// you may not use this file except in compliance with the License.
8// You may obtain a copy of the License at
9//
10// 	http://www.apache.org/licenses/LICENSE-2.0
11//
12// Unless required by applicable law or agreed to in writing, software
13// distributed under the License is distributed on an "AS IS" BASIS,
14// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15// See the License for the specific language governing permissions and
16// limitations under the License.
17
18//! ## The unsigned phase, and its miner.
19//!
20//! This pallet deals with unsigned submissions. These are backup, "possibly" multi-page submissions
21//! from validators.
22//!
23//! This pallet has two miners, described in [`unsigned::miner`].
24//!
25//! As it stands, a validator can, during the unsigned phase, submit up to
26//! [`unsigned::Config::MinerPages`] pages. While this can be more than 1, it can likely not be a
27//! full, high quality solution. This is because unsigned validator solutions are verified on the
28//! fly, all within a single block. The exact value of this parameter should be determined by the
29//! benchmarks of a runtime.
30//!
31//! We could implement a protocol to allow multi-block, multi-page collaborative submissions from
32//! different validators, but it is not trivial. Moreover, recall that the unsigned phase is merely
33//! a backup and we should primarily rely on offchain staking miners to fulfill this role during
34//! `Phase::Signed`.
35//!
36//! ## Future Idea: Multi-Page unsigned submission
37//!
38//! the following is the idea of how to implement multi-page unsigned, which we don't have.
39//!
40//! All validators will run their miners and compute the full paginated solution. They submit all
41//! pages as individual authorized transactions to their local tx-pool.
42//!
43//! Upon validation, if any page is now present the corresponding transaction is dropped.
44//!
45//! At each block, the first page that may be valid is included as a high priority operational
46//! transaction. This page is validated on the fly to be correct. Since this transaction is sourced
47//! from a validator, we can panic if they submit an invalid transaction.
48//!
49//! Then, once the final page is submitted, some extra checks are done, as explained in
50//! [`crate::verifier`]:
51//!
52//! 1. bounds
53//! 2. total score
54//!
55//! These checks might still fail. If they do, the solution is dropped. At this point, we don't know
56//! which validator may have submitted a slightly-faulty solution.
57//!
58//! In order to prevent this, the transaction validation process always includes a check to ensure
59//! all of the previous pages that have been submitted match what the local validator has computed.
60//! If they match, the validator knows that they are putting skin in a game that is valid.
61//!
62//! If any bad paged are detected, the next validator can bail. This process means:
63//!
64//! * As long as all validators are honest, and run the same miner code, a correct solution is
65//!   found.
66//! * As little as one malicious validator can stall the process, but no one is accidentally
67//!   slashed, and no panic happens.
68//!
69//! Alternatively, we can keep track of submitters, and report a slash if it occurs. Or, if
70//! the signed process is bullet-proof, we can be okay with the status quo.
71
72/// Export weights
73pub use crate::weights::traits::pallet_election_provider_multi_block_unsigned::*;
74/// Exports of this pallet
75pub use pallet::*;
76#[cfg(feature = "runtime-benchmarks")]
77mod benchmarking;
78
79/// The miner.
80pub mod miner;
81
82#[frame_support::pallet]
83mod pallet {
84	use super::WeightInfo;
85	use crate::{
86		types::*,
87		unsigned::miner::{self},
88		verifier::Verifier,
89		CommonError,
90	};
91	use frame_support::pallet_prelude::*;
92	use frame_system::{offchain::CreateAuthorizedTransaction, pallet_prelude::*};
93	use sp_runtime::traits::SaturatedConversion;
94	use sp_std::prelude::*;
95
96	/// convert a [`crate::CommonError`] to a custom InvalidTransaction with the inner code being
97	/// the index of the variant.
98	fn base_error_to_invalid(error: CommonError) -> InvalidTransaction {
99		let index = error.encode().pop().unwrap_or(0);
100		InvalidTransaction::Custom(index)
101	}
102
103	pub(crate) type UnsignedWeightsOf<T> = <T as Config>::WeightInfo;
104
105	#[pallet::config]
106	#[pallet::disable_frame_system_supertrait_check]
107	pub trait Config: crate::Config + CreateAuthorizedTransaction<Call<Self>> {
108		/// The repeat threshold of the offchain worker.
109		///
110		/// For example, if it is `5`, that means that at least 5 blocks will elapse between
111		/// attempts to submit the worker's solution.
112		type OffchainRepeat: Get<BlockNumberFor<Self>>;
113
114		/// The solver used in hte offchain worker miner
115		type OffchainSolver: frame_election_provider_support::NposSolver<
116			AccountId = Self::AccountId,
117		>;
118
119		/// Whether the offchain worker miner would attempt to store the solutions in a local
120		/// database and reuse then. If set to `false`, it will try and re-mine solutions every
121		/// time.
122		type OffchainStorage: Get<bool>;
123
124		/// The priority of the unsigned transaction submitted in the unsigned-phase
125		type MinerTxPriority: Get<TransactionPriority>;
126
127		/// The number of pages that the offchain miner will try and submit.
128		type MinerPages: Get<PageIndex>;
129
130		/// Runtime weight information of this pallet.
131		type WeightInfo: WeightInfo;
132	}
133
134	#[pallet::pallet]
135	pub struct Pallet<T>(PhantomData<T>);
136
137	#[pallet::call]
138	impl<T: Config> Pallet<T> {
139		/// Submit an unsigned solution.
140		///
141		/// This works very much like an inherent, as only the validators are permitted to submit
142		/// anything. By default validators will compute this call in their `offchain_worker` hook
143		/// and try and submit it back.
144		///
145		/// This is different from signed page submission mainly in that the solution page is
146		/// verified on the fly.
147		///
148		/// The `paged_solution` may contain at most [`Config::MinerPages`] pages. They are
149		/// interpreted as msp -> lsp, as per [`crate::Pallet::msp_range_for`].
150		///
151		/// For example, if `Pages = 4`, and `MinerPages = 2`, our full snapshot range would be [0,
152		/// 1, 2, 3], with 3 being msp. But, in this case, then the `paged_raw_solution.pages` is
153		/// expected to correspond to `[snapshot(2), snapshot(3)]`.
154		#[pallet::weight((UnsignedWeightsOf::<T>::submit_unsigned(), DispatchClass::Operational))]
155		#[pallet::weight_of_authorize(UnsignedWeightsOf::<T>::authorize_submit_unsigned())]
156		#[pallet::authorize(Self::authorize_submit_unsigned)]
157		#[pallet::call_index(0)]
158		pub fn submit_unsigned(
159			origin: OriginFor<T>,
160			paged_solution: Box<PagedRawSolution<T::MinerConfig>>,
161		) -> DispatchResultWithPostInfo {
162			ensure_authorized(origin)?;
163			let error_message = "Invalid unsigned submission must produce invalid block and \
164				 deprive validator from their authoring reward.";
165
166			// phase, round, claimed score, page-count and hash are checked in pre-dispatch. we
167			// don't check them here anymore.
168			debug_assert!(Self::validate_unsigned_checks(&paged_solution).is_ok());
169
170			let claimed_score = paged_solution.score;
171
172			// we select the most significant pages, based on `T::MinerPages`.
173			let page_indices = crate::Pallet::<T>::msp_range_for(T::MinerPages::get() as usize);
174			<T::Verifier as Verifier>::verify_synchronous_multi(
175				paged_solution.solution_pages,
176				page_indices,
177				claimed_score,
178			)
179			.expect(error_message);
180
181			Ok(None.into())
182		}
183	}
184
185	impl<T: Config> Pallet<T> {
186		/// Authorization logic for the [`Call::submit_unsigned`] call.
187		fn authorize_submit_unsigned(
188			source: TransactionSource,
189			paged_solution: &Box<PagedRawSolution<T::MinerConfig>>,
190		) -> TransactionValidityWithRefund {
191			match source {
192				TransactionSource::Local | TransactionSource::InBlock => { /* allowed */ },
193				_ => return Err(InvalidTransaction::Call.into()),
194			}
195
196			Self::validate_unsigned_checks(paged_solution.as_ref())
197				.map_err(|err| {
198					sublog!(debug, "unsigned", "solution authorization failed due to {:?}", err);
199					err
200				})
201				.map_err(base_error_to_invalid)?;
202
203			ValidTransaction::with_tag_prefix("OffchainElection")
204				// The higher the score.minimal_stake, the better a paged_solution is.
205				.priority(
206					T::MinerTxPriority::get()
207						.saturating_add(paged_solution.score.minimal_stake.saturated_into()),
208				)
209				// Used to deduplicate unsigned solutions: each validator should produce one
210				// paged_solution per round at most, and solutions are not propagate.
211				.and_provides(paged_solution.round)
212				// Transaction should stay in the pool for the duration of the unsigned phase.
213				.longevity(T::UnsignedPhase::get().saturated_into::<u64>())
214				// We don't propagate this. This can never be validated at a remote node.
215				.propagate(false)
216				.build()
217				.map(|validity| (validity, Weight::zero()))
218		}
219	}
220
221	#[pallet::hooks]
222	impl<T: Config> Hooks<BlockNumberFor<T>> for Pallet<T> {
223		fn integrity_test() {
224			assert!(
225				UnsignedWeightsOf::<T>::submit_unsigned().all_lte(T::BlockWeights::get().max_block),
226				"weight of `submit_unsigned` is too high"
227			);
228			assert!(
229				<T as Config>::MinerPages::get() as usize <=
230					<T as crate::Config>::Pages::get() as usize,
231				"number of pages in the unsigned phase is too high"
232			);
233		}
234
235		#[cfg(feature = "try-runtime")]
236		fn try_state(now: BlockNumberFor<T>) -> Result<(), sp_runtime::TryRuntimeError> {
237			Self::do_try_state(now)
238		}
239
240		fn offchain_worker(now: BlockNumberFor<T>) {
241			use sp_runtime::offchain::storage_lock::{BlockAndTime, StorageLock};
242
243			// Create a lock with the maximum deadline of number of blocks in the unsigned phase.
244			// This should only come useful in an **abrupt** termination of execution, otherwise the
245			// guard will be dropped upon successful execution.
246			let mut lock =
247				StorageLock::<BlockAndTime<frame_system::Pallet<T>>>::with_block_deadline(
248					miner::OffchainWorkerMiner::<T>::OFFCHAIN_LOCK,
249					T::UnsignedPhase::get().saturated_into(),
250				);
251
252			match lock.try_lock() {
253				Ok(_guard) => {
254					Self::do_synchronized_offchain_worker(now);
255				},
256				Err(deadline) => {
257					sublog!(
258						trace,
259						"unsigned",
260						"offchain worker lock not released, deadline is {:?}",
261						deadline
262					);
263				},
264			};
265		}
266	}
267
268	impl<T: Config> Pallet<T> {
269		/// Internal logic of the offchain worker, to be executed only when the offchain lock is
270		/// acquired with success.
271		fn do_synchronized_offchain_worker(now: BlockNumberFor<T>) {
272			use miner::OffchainWorkerMiner;
273			let current_phase = crate::Pallet::<T>::current_phase();
274			sublog!(
275				trace,
276				"unsigned",
277				"lock for offchain worker acquired. Phase = {:?}",
278				current_phase
279			);
280
281			// do the repeat frequency check just one, if we are in unsigned phase.
282			if current_phase.is_unsigned() {
283				if let Err(reason) = OffchainWorkerMiner::<T>::ensure_offchain_repeat_frequency(now)
284				{
285					sublog!(
286						debug,
287						"unsigned",
288						"offchain worker repeat frequency check failed: {:?}",
289						reason
290					);
291					return;
292				}
293			}
294
295			if current_phase.is_unsigned_opened_now() {
296				// Mine a new solution, (maybe) cache it, and attempt to submit it
297				let initial_output = if T::OffchainStorage::get() {
298					OffchainWorkerMiner::<T>::mine_check_maybe_save_submit(true)
299				} else {
300					OffchainWorkerMiner::<T>::mine_check_maybe_save_submit(false)
301				};
302				sublog!(debug, "unsigned", "initial offchain worker output: {:?}", initial_output);
303			} else if current_phase.is_unsigned() {
304				// Maybe resubmit the cached solution, else re-compute.
305				let resubmit_output = if T::OffchainStorage::get() {
306					OffchainWorkerMiner::<T>::restore_or_compute_then_maybe_submit()
307				} else {
308					OffchainWorkerMiner::<T>::mine_check_maybe_save_submit(false)
309				};
310				sublog!(debug, "unsigned", "later offchain worker output: {:?}", resubmit_output);
311			};
312		}
313
314		/// The checks that should happen in the authorize callback.
315		///
316		/// These check both for snapshot independent checks, and some checks that are specific to
317		/// the unsigned phase.
318		pub(crate) fn validate_unsigned_checks(
319			paged_solution: &PagedRawSolution<T::MinerConfig>,
320		) -> Result<(), CommonError> {
321			Self::unsigned_specific_checks(paged_solution)
322				.and(crate::Pallet::<T>::snapshot_independent_checks(paged_solution, None))
323				.map_err(Into::into)
324		}
325
326		/// The checks that are specific to the (this) unsigned pallet.
327		///
328		/// ensure solution has the correct phase, and it has only 1 page.
329		pub fn unsigned_specific_checks(
330			paged_solution: &PagedRawSolution<T::MinerConfig>,
331		) -> Result<(), CommonError> {
332			ensure!(
333				crate::Pallet::<T>::current_phase().is_unsigned(),
334				CommonError::EarlySubmission
335			);
336			ensure!(
337				paged_solution.solution_pages.len() == T::MinerPages::get() as usize,
338				CommonError::WrongPageCount
339			);
340			ensure!(
341				paged_solution.solution_pages.len() <= <T as crate::Config>::Pages::get() as usize,
342				CommonError::WrongPageCount
343			);
344
345			Ok(())
346		}
347
348		#[cfg(any(test, feature = "runtime-benchmarks", feature = "try-runtime"))]
349		pub(crate) fn do_try_state(
350			_now: BlockNumberFor<T>,
351		) -> Result<(), sp_runtime::TryRuntimeError> {
352			Ok(())
353		}
354	}
355}
356
357#[cfg(test)]
358mod authorize {
359	use frame_election_provider_support::Support;
360	use frame_support::{
361		pallet_prelude::InvalidTransaction,
362		traits::Authorize,
363		unsigned::{TransactionSource, TransactionValidityError},
364	};
365
366	use super::Call;
367	use crate::{mock::*, types::*, verifier::Verifier};
368
369	#[test]
370	fn retracts_weak_score_accepts_better() {
371		ExtBuilder::mock_signed().build_and_execute(|| {
372			roll_to_snapshot_created();
373
374			let base_minimal_stake = 55;
375			let solution = mine_full_solution().unwrap();
376			load_mock_signed_and_start(solution.clone());
377			roll_to_full_verification();
378
379			// Some good solution is queued now.
380			assert_eq!(
381				<VerifierPallet as Verifier>::queued_score(),
382				Some(ElectionScore {
383					minimal_stake: base_minimal_stake,
384					sum_stake: 130,
385					sum_stake_squared: 8650
386				})
387			);
388
389			roll_to_unsigned_open();
390
391			// This is just worse.
392			let attempt = fake_solution(ElectionScore {
393				minimal_stake: base_minimal_stake - 1,
394				..Default::default()
395			});
396			let call = Call::<Runtime>::submit_unsigned { paged_solution: Box::new(attempt) };
397			assert_eq!(
398				call.authorize(TransactionSource::Local).unwrap().unwrap_err(),
399				TransactionValidityError::Invalid(InvalidTransaction::Custom(2)),
400			);
401
402			// This is better, but the number of winners is incorrect.
403			let attempt = fake_solution(ElectionScore {
404				minimal_stake: base_minimal_stake + 1,
405				..Default::default()
406			});
407			let call = Call::<Runtime>::submit_unsigned { paged_solution: Box::new(attempt) };
408			assert_eq!(
409				call.authorize(TransactionSource::Local).unwrap().unwrap_err(),
410				TransactionValidityError::Invalid(InvalidTransaction::Custom(4)),
411			);
412
413			// Note that we now have to use a solution with 2 winners, just to pass all of the
414			// snapshot independent checks.
415			let mut paged = raw_paged_from_supports(
416				vec![vec![
417					(40, Support { total: 10, voters: vec![(3, 5)] }),
418					(30, Support { total: 10, voters: vec![(3, 5)] }),
419				]],
420				0,
421			);
422
423			paged.score =
424				ElectionScore { minimal_stake: base_minimal_stake + 1, ..Default::default() };
425			let call = Call::<Runtime>::submit_unsigned { paged_solution: Box::new(paged) };
426			assert!(call.authorize(TransactionSource::Local).unwrap().is_ok());
427		})
428	}
429
430	#[test]
431	fn retracts_wrong_round() {
432		ExtBuilder::mock_signed().build_and_execute(|| {
433			roll_to_unsigned_open();
434
435			let mut attempt =
436				fake_solution(ElectionScore { minimal_stake: 5, ..Default::default() });
437			attempt.round += 1;
438			let call = Call::<Runtime>::submit_unsigned { paged_solution: Box::new(attempt) };
439
440			assert_eq!(
441				call.authorize(TransactionSource::Local).unwrap().unwrap_err(),
442				// WrongRound is index 1
443				TransactionValidityError::Invalid(InvalidTransaction::Custom(1)),
444			);
445		})
446	}
447
448	#[test]
449	fn retracts_too_many_pages_unsigned() {
450		ExtBuilder::mock_signed().build_and_execute(|| {
451			// NOTE: unsigned solutions should have just 1 page, regardless of the configured
452			// page count.
453			roll_to_unsigned_open();
454			let attempt = mine_full_solution().unwrap();
455			let call = Call::<Runtime>::submit_unsigned { paged_solution: Box::new(attempt) };
456
457			assert_eq!(
458				call.authorize(TransactionSource::Local).unwrap().unwrap_err(),
459				// WrongPageCount is index 3
460				TransactionValidityError::Invalid(InvalidTransaction::Custom(3)),
461			);
462
463			let attempt = mine_solution(2).unwrap();
464			let call = Call::<Runtime>::submit_unsigned { paged_solution: Box::new(attempt) };
465
466			assert_eq!(
467				call.authorize(TransactionSource::Local).unwrap().unwrap_err(),
468				TransactionValidityError::Invalid(InvalidTransaction::Custom(3)),
469			);
470
471			let attempt = mine_solution(1).unwrap();
472			let call = Call::<Runtime>::submit_unsigned { paged_solution: Box::new(attempt) };
473
474			assert!(call.authorize(TransactionSource::Local).unwrap().is_ok(),);
475		})
476	}
477
478	#[test]
479	fn retracts_wrong_winner_count() {
480		ExtBuilder::mock_signed().desired_targets(2).build_and_execute(|| {
481			roll_to_unsigned_open();
482
483			let paged = raw_paged_from_supports(
484				vec![vec![(40, Support { total: 10, voters: vec![(3, 10)] })]],
485				0,
486			);
487
488			let call = Call::<Runtime>::submit_unsigned { paged_solution: Box::new(paged) };
489
490			assert_eq!(
491				call.authorize(TransactionSource::Local).unwrap().unwrap_err(),
492				// WrongWinnerCount is index 4
493				TransactionValidityError::Invalid(InvalidTransaction::Custom(4)),
494			);
495		});
496	}
497
498	#[test]
499	fn retracts_wrong_phase() {
500		ExtBuilder::mock_signed().signed_phase(5, 6).build_and_execute(|| {
501			let solution = raw_paged_solution_low_score();
502			let call =
503				Call::<Runtime>::submit_unsigned { paged_solution: Box::new(solution.clone()) };
504
505			// initial
506			assert_eq!(MultiBlock::current_phase(), Phase::Off);
507			assert!(matches!(
508				call.authorize(TransactionSource::Local).unwrap().unwrap_err(),
509				// because EarlySubmission is index 0.
510				TransactionValidityError::Invalid(InvalidTransaction::Custom(0))
511			));
512			assert!(matches!(
513				call.authorize(TransactionSource::InBlock).unwrap().unwrap_err(),
514				TransactionValidityError::Invalid(InvalidTransaction::Custom(0))
515			));
516
517			// signed
518			roll_to_signed_open();
519			assert!(MultiBlock::current_phase().is_signed());
520			assert!(matches!(
521				call.authorize(TransactionSource::Local).unwrap().unwrap_err(),
522				TransactionValidityError::Invalid(InvalidTransaction::Custom(0))
523			));
524			assert!(matches!(
525				call.authorize(TransactionSource::InBlock).unwrap().unwrap_err(),
526				TransactionValidityError::Invalid(InvalidTransaction::Custom(0))
527			));
528
529			// unsigned
530			roll_to_unsigned_open();
531			assert!(MultiBlock::current_phase().is_unsigned());
532
533			assert_ok!(call.authorize(TransactionSource::Local).unwrap());
534			assert_ok!(call.authorize(TransactionSource::InBlock).unwrap());
535			assert_eq!(
536				call.authorize(TransactionSource::External).unwrap().unwrap_err(),
537				TransactionValidityError::Invalid(InvalidTransaction::Call),
538			);
539		})
540	}
541
542	#[test]
543	fn priority_is_set() {
544		ExtBuilder::mock_signed()
545			.miner_tx_priority(20)
546			.desired_targets(0)
547			.build_and_execute(|| {
548				roll_to_unsigned_open();
549				assert!(MultiBlock::current_phase().is_unsigned());
550
551				let solution =
552					fake_solution(ElectionScore { minimal_stake: 5, ..Default::default() });
553				let call =
554					Call::<Runtime>::submit_unsigned { paged_solution: Box::new(solution.clone()) };
555
556				assert_eq!(
557					call.authorize(TransactionSource::Local).unwrap().unwrap().0.priority,
558					25
559				);
560			})
561	}
562}
563
564#[cfg(test)]
565mod call {
566	use crate::{mock::*, verifier::Verifier, Snapshot};
567
568	#[test]
569	fn unsigned_submission_e2e() {
570		let (mut ext, pool) = ExtBuilder::mock_signed().build_offchainify();
571		ext.execute_with_sanity_checks(|| {
572			roll_to_unsigned_open();
573
574			// snapshot is created..
575			assert_full_snapshot();
576			// ..txpool is empty..
577			assert_eq!(pool.read().transactions.len(), 0);
578			// ..but nothing queued.
579			assert_eq!(<VerifierPallet as Verifier>::queued_score(), None);
580
581			// now the OCW should submit something.
582			roll_next_with_ocw(Some(pool.clone()));
583			assert_eq!(pool.read().transactions.len(), 1);
584			assert_eq!(<VerifierPallet as Verifier>::queued_score(), None);
585
586			// and now it should be applied.
587			roll_next_with_ocw(Some(pool.clone()));
588			assert_eq!(pool.read().transactions.len(), 0);
589			assert!(matches!(<VerifierPallet as Verifier>::queued_score(), Some(_)));
590		})
591	}
592
593	#[test]
594	#[should_panic(
595		expected = "Invalid unsigned submission must produce invalid block and deprive validator from their authoring reward."
596	)]
597	fn unfeasible_solution_panics() {
598		let (mut ext, pool) = ExtBuilder::mock_signed().build_offchainify();
599		ext.execute_with_sanity_checks(|| {
600			roll_to_unsigned_open();
601
602			// snapshot is created..
603			assert_full_snapshot();
604			// ..txpool is empty..
605			assert_eq!(pool.read().transactions.len(), 0);
606			// ..but nothing queued.
607			assert_eq!(<VerifierPallet as Verifier>::queued_score(), None);
608
609			// now the OCW should submit something.
610			roll_next_with_ocw(Some(pool.clone()));
611			assert_eq!(pool.read().transactions.len(), 1);
612			assert_eq!(<VerifierPallet as Verifier>::queued_score(), None);
613
614			// now we change the snapshot -- this should ensure that the solution becomes invalid.
615			// Note that we don't change the known fingerprint of the solution.
616			Snapshot::<Runtime>::remove_target(2);
617
618			// and now it should be applied.
619			roll_next_with_ocw(Some(pool.clone()));
620			assert_eq!(pool.read().transactions.len(), 0);
621			assert!(matches!(<VerifierPallet as Verifier>::queued_score(), Some(_)));
622		})
623	}
624}