referrerpolicy=no-referrer-when-downgrade

cumulus_primitives_core/
scheduling.rs

1// Copyright (C) Parity Technologies (UK) Ltd.
2// This file is part of Cumulus.
3// SPDX-License-Identifier: Apache-2.0
4
5//! V3 scheduling types for low-latency parachain block production.
6//!
7//! V3 candidates separate the relay parent (execution context) from the scheduling parent (a recent
8//! relay tip used for core assignment), so blocks can build on older relay parents while still
9//! being scheduled off recent relay state.
10//!
11//! # Resubmission
12//!
13//! If a candidate isn't backed in time, another collator can resubmit it with a fresh
14//! `scheduling_parent` — same `relay_parent`, no re-execution — by providing a
15//! `signed_scheduling_info` proving it is the eligible author for the slot at
16//! `internal_scheduling_parent`.
17
18use alloc::vec::Vec;
19use codec::{Decode, Encode};
20use polkadot_primitives::{
21	ApprovedPeerId, ClaimQueueOffset, CoreSelector, Header as RelayChainHeader, Slot, UMPSignal,
22	UMP_SEPARATOR,
23};
24use sp_runtime::traits::{BlakeTwo256, Hash as HashT};
25
26/// Payload a collator signs to resubmit a candidate.
27///
28/// Binds the core selection and credited peer to an internal scheduling parent, preventing replay
29/// across scheduling contexts.
30#[derive(Clone, Encode, Decode, Debug, PartialEq, Eq)]
31pub struct SchedulingInfoPayload {
32	/// Which core to use (indexes into the parachain's assigned cores).
33	pub core_selector: CoreSelector,
34	/// The claim queue offset.
35	pub claim_queue_offset: u8,
36	/// Peer ID credited for successful collation delivery.
37	pub peer_id: ApprovedPeerId,
38	/// The internal scheduling parent whose slot decides the eligible author that must sign this
39	/// payload.
40	pub internal_scheduling_parent: polkadot_primitives::Hash,
41}
42
43/// Signed scheduling info for candidate resubmission: a [`SchedulingInfoPayload`] plus the
44/// collator's signature over it, proving eligibility for the slot at `internal_scheduling_parent`.
45///
46/// `claim_queue_offset` comes from the runtime's `relay_parent_offset`, not this struct, so the
47/// collator cannot override it.
48#[derive(Clone, Encode, Decode, Debug, PartialEq, Eq)]
49pub struct SignedSchedulingInfo {
50	/// The scheduling information.
51	pub payload: SchedulingInfoPayload,
52	/// The eligible collator's signature over the SCALE-encoded [`SchedulingInfoPayload`].
53	///
54	/// A fixed 64-byte blob, decodable as either sr25519 or ed25519 (both are 64 bytes).
55	pub signature: [u8; 64],
56}
57
58impl SchedulingInfoPayload {
59	/// Create a new scheduling info payload.
60	pub fn new(
61		core_selector: CoreSelector,
62		claim_queue_offset: u8,
63		peer_id: ApprovedPeerId,
64		internal_scheduling_parent: polkadot_primitives::Hash,
65	) -> Self {
66		Self { core_selector, claim_queue_offset, peer_id, internal_scheduling_parent }
67	}
68}
69
70/// The scheduling-signal tail (`SelectCore`/`ApprovedPeer`) a candidate emits after the first
71/// `UMP_SEPARATOR`.
72///
73/// Single source of truth shared by the collator and the PVF (`validate_block`) so their tails
74/// can't drift. The relay decoder (`CandidateCommitments::ump_signals`) rejects a repeated variant
75/// or any third signal, and parses only the run after the first `UMP_SEPARATOR`.
76#[derive(Debug, Default, PartialEq, Eq)]
77pub struct SchedulingSignals {
78	select_core: Option<(CoreSelector, ClaimQueueOffset)>,
79	approved_peer: Option<ApprovedPeerId>,
80}
81
82impl SchedulingSignals {
83	/// Parse the encoded `UMPSignal`s a PoV's blocks emitted after the in-block `UMP_SEPARATOR`.
84	///
85	/// Panics on a repeated variant even when the values match: the relay decoder counts
86	/// occurrences, not distinct values, so a duplicate is a bug regardless.
87	pub fn from_block_signals(raw: &[Vec<u8>]) -> Self {
88		let mut signals = Self::default();
89		for bytes in raw {
90			// Exhaustive on purpose (no `_`): a new `UMPSignal` variant must fail to compile
91			// here, forcing a deliberate decision rather than being silently dropped.
92			match UMPSignal::decode(&mut &bytes[..]).expect("Failed to decode `UMPSignal`") {
93				UMPSignal::SelectCore(selector, offset) => {
94					if signals.select_core.replace((selector, offset)).is_some() {
95						panic!("Parachain emitted more than one `SelectCore` UMP signal");
96					}
97				},
98				UMPSignal::ApprovedPeer(peer_id) => {
99					if signals.approved_peer.replace(peer_id).is_some() {
100						panic!("Parachain emitted more than one `ApprovedPeer` UMP signal");
101					}
102				},
103			}
104		}
105		signals
106	}
107
108	/// Build the tail from a verified `SignedSchedulingInfo`, replacing the block's own signals
109	/// wholesale. Assumes every `UMPSignal` is a scheduling signal; guarded by
110	/// `all_ump_signals_are_scheduling_signals`.
111	pub fn from_scheduling_info(signed_info: &SignedSchedulingInfo) -> Self {
112		let payload = &signed_info.payload;
113		Self {
114			select_core: Some((
115				payload.core_selector,
116				ClaimQueueOffset(payload.claim_queue_offset),
117			)),
118			approved_peer: Some(payload.peer_id.clone()),
119		}
120	}
121
122	fn is_empty(&self) -> bool {
123		self.select_core.is_none() && self.approved_peer.is_none()
124	}
125
126	/// The encoded UMP messages for this tail: empty when there are no signals, otherwise
127	/// `[UMP_SEPARATOR, SelectCore?, ApprovedPeer?]`. Order is `SelectCore` then `ApprovedPeer`,
128	/// matching `pallet_parachain_system::send_ump_signals`. Nothing — not even the separator — is
129	/// emitted when empty, since the relay decoder keys off the first `UMP_SEPARATOR`.
130	pub fn into_ump_messages(self) -> Vec<Vec<u8>> {
131		if self.is_empty() {
132			return Vec::new();
133		}
134		let mut messages = Vec::with_capacity(3);
135		messages.push(UMP_SEPARATOR);
136		if let Some((selector, offset)) = self.select_core {
137			messages.push(UMPSignal::SelectCore(selector, offset).encode());
138		}
139		if let Some(peer_id) = self.approved_peer {
140			messages.push(UMPSignal::ApprovedPeer(peer_id).encode());
141		}
142		messages
143	}
144}
145
146/// V3 scheduling proof included in the PoV.
147///
148/// Proves ancestry from `scheduling_parent` back to the internal scheduling parent; the PVF
149/// validates it against the `relay_parent`/`scheduling_parent` in the candidate descriptor.
150#[derive(Clone, Encode, Decode, Debug, PartialEq, Eq)]
151pub struct SchedulingProof {
152	/// Relay chain headers chaining `scheduling_parent` backward: each header's `parent_hash` is
153	/// the next header's hash. The first header hashes to the candidate's `scheduling_parent`, the
154	/// last header's `parent_hash` is the internal scheduling parent. Length is the runtime's
155	/// `RelayParentOffset`.
156	pub header_chain: Vec<RelayChainHeader>,
157	/// The header at `internal_scheduling_parent`; its hash must equal the internal scheduling
158	/// parent derived from `header_chain` (the last header's `parent_hash`, or `scheduling_parent`
159	/// if the chain is empty).
160	pub internal_scheduling_parent_header: RelayChainHeader,
161	/// Optional signed core-selection override:
162	///
163	/// - `None`, `relay_parent == internal_scheduling_parent`: initial submission; core selection
164	///   comes from the block's UMP signals.
165	/// - `Some`, `relay_parent == internal_scheduling_parent`: initial submission with an explicit
166	///   (optional) core selection.
167	/// - `Some`, `relay_parent != internal_scheduling_parent`: resubmission (required); the
168	///   signature overrides the block's UMP signals and is verified against the eligible author
169	///   for the slot at `internal_scheduling_parent`.
170	pub signed_scheduling_info: Option<SignedSchedulingInfo>,
171}
172
173impl SchedulingProof {
174	/// Create a new scheduling proof.
175	pub fn new(
176		header_chain: Vec<RelayChainHeader>,
177		internal_scheduling_parent_header: RelayChainHeader,
178		signed_scheduling_info: Option<SignedSchedulingInfo>,
179	) -> Self {
180		Self { header_chain, internal_scheduling_parent_header, signed_scheduling_info }
181	}
182
183	/// The scheduling parent hash: the first/newest header in `header_chain`, or
184	/// `internal_scheduling_parent_header.hash()` when the chain is empty (they coincide at
185	/// `relay_parent_offset = 0`).
186	pub fn scheduling_parent(&self) -> polkadot_primitives::Hash {
187		self.header_chain
188			.first()
189			.map(BlakeTwo256::hash_of)
190			.unwrap_or_else(|| self.internal_scheduling_parent_header.hash())
191	}
192}
193
194/// Verifier for V3 scheduling: reports whether V3 is enabled and verifies a candidate's
195/// [`SignedSchedulingInfo`].
196pub trait VerifySchedulingSignature {
197	/// Whether V3 scheduling validation is enabled.
198	const V3_SCHEDULING_ENABLED: bool;
199
200	/// Verify `signed_info` against the author eligible at `relay_slot` (the internal scheduling
201	/// parent's slot).
202	fn verify(signed_info: &SignedSchedulingInfo, relay_slot: Slot) -> bool;
203}
204
205/// Default no-op wiring: V3 disabled, scheduling info accepted unconditionally. A real verifier
206/// should also turn V3 on.
207impl VerifySchedulingSignature for () {
208	const V3_SCHEDULING_ENABLED: bool = false;
209
210	fn verify(_signed_info: &SignedSchedulingInfo, _relay_slot: Slot) -> bool {
211		true
212	}
213}
214
215#[cfg(test)]
216mod tests {
217	use super::{SchedulingInfoPayload, SchedulingSignals, SignedSchedulingInfo};
218	use alloc::vec;
219	use codec::Encode;
220	use polkadot_primitives::{
221		ApprovedPeerId, ClaimQueueOffset, CoreSelector, UMPSignal, UMP_SEPARATOR,
222	};
223
224	fn peer(byte: u8) -> ApprovedPeerId {
225		ApprovedPeerId::try_from(vec![byte; 4]).expect("4 bytes fits the bound; qed")
226	}
227
228	fn signed_with(
229		core_selector: CoreSelector,
230		claim_queue_offset: u8,
231		peer_id: ApprovedPeerId,
232	) -> SignedSchedulingInfo {
233		SignedSchedulingInfo {
234			payload: SchedulingInfoPayload::new(
235				core_selector,
236				claim_queue_offset,
237				peer_id,
238				Default::default(),
239			),
240			signature: [0u8; 64],
241		}
242	}
243
244	#[test]
245	fn from_block_signals_roundtrips_select_core_and_approved_peer() {
246		// Both signals present: parsed, then emitted as [SEPARATOR, SelectCore, ApprovedPeer] in
247		// that exact order.
248		let raw = vec![
249			UMPSignal::SelectCore(CoreSelector(7), ClaimQueueOffset(1)).encode(),
250			UMPSignal::ApprovedPeer(peer(0xAA)).encode(),
251		];
252		assert_eq!(
253			SchedulingSignals::from_block_signals(&raw).into_ump_messages(),
254			vec![
255				UMP_SEPARATOR,
256				UMPSignal::SelectCore(CoreSelector(7), ClaimQueueOffset(1)).encode(),
257				UMPSignal::ApprovedPeer(peer(0xAA)).encode(),
258			]
259		);
260	}
261
262	#[test]
263	fn from_block_signals_select_core_only() {
264		// Block emitted only a `SelectCore`: no `ApprovedPeer`, one signal emitted.
265		let raw = vec![UMPSignal::SelectCore(CoreSelector(3), ClaimQueueOffset(0)).encode()];
266		assert_eq!(
267			SchedulingSignals::from_block_signals(&raw).into_ump_messages(),
268			vec![
269				UMP_SEPARATOR,
270				UMPSignal::SelectCore(CoreSelector(3), ClaimQueueOffset(0)).encode()
271			]
272		);
273	}
274
275	#[test]
276	#[should_panic(expected = "more than one `SelectCore`")]
277	fn from_block_signals_panics_on_duplicate_select_core_same_value() {
278		// Two identical `SelectCore` signals: still an error. The relay decoder counts
279		// occurrences, not distinct values, so matching duplicates would be rejected too.
280		let raw = vec![
281			UMPSignal::SelectCore(CoreSelector(1), ClaimQueueOffset(0)).encode(),
282			UMPSignal::SelectCore(CoreSelector(1), ClaimQueueOffset(0)).encode(),
283		];
284		let _ = SchedulingSignals::from_block_signals(&raw);
285	}
286
287	#[test]
288	#[should_panic(expected = "more than one `SelectCore`")]
289	fn from_block_signals_panics_on_duplicate_select_core_different_value() {
290		let raw = vec![
291			UMPSignal::SelectCore(CoreSelector(1), ClaimQueueOffset(0)).encode(),
292			UMPSignal::SelectCore(CoreSelector(2), ClaimQueueOffset(0)).encode(),
293		];
294		let _ = SchedulingSignals::from_block_signals(&raw);
295	}
296
297	#[test]
298	#[should_panic(expected = "more than one `ApprovedPeer`")]
299	fn from_block_signals_panics_on_duplicate_approved_peer() {
300		let raw = vec![
301			UMPSignal::ApprovedPeer(peer(0xAA)).encode(),
302			UMPSignal::ApprovedPeer(peer(0xBB)).encode(),
303		];
304		let _ = SchedulingSignals::from_block_signals(&raw);
305	}
306
307	#[test]
308	fn from_block_signals_empty_emits_nothing() {
309		// No signals in, nothing out — not even a separator.
310		assert!(SchedulingSignals::from_block_signals(&[]).into_ump_messages().is_empty());
311	}
312
313	#[test]
314	fn from_scheduling_info_sources_all_fields() {
315		// All three values — `core_selector`, `claim_queue_offset`, `peer_id` — are signed by the
316		// resubmitting collator, so the override sources every field from the signed payload.
317		// Distinct values ensure no field is sourced from the wrong place.
318		let signed = signed_with(CoreSelector(7), 3, peer(0xAA));
319		assert_eq!(
320			SchedulingSignals::from_scheduling_info(&signed).into_ump_messages(),
321			vec![
322				UMP_SEPARATOR,
323				UMPSignal::SelectCore(CoreSelector(7), ClaimQueueOffset(3)).encode(),
324				UMPSignal::ApprovedPeer(peer(0xAA)).encode(),
325			]
326		);
327	}
328
329	#[test]
330	fn from_scheduling_info_emits_peer_verbatim_even_if_empty() {
331		// The payload `peer_id` is a plain (non-`Option`) type → always emitted. An empty peer is
332		// emitted verbatim as `ApprovedPeer([])`, NOT omitted and NOT replaced by the block's peer.
333		let signed = signed_with(CoreSelector(5), 1, ApprovedPeerId::default());
334		assert_eq!(
335			SchedulingSignals::from_scheduling_info(&signed).into_ump_messages(),
336			vec![
337				UMP_SEPARATOR,
338				UMPSignal::SelectCore(CoreSelector(5), ClaimQueueOffset(1)).encode(),
339				UMPSignal::ApprovedPeer(ApprovedPeerId::default()).encode(),
340			]
341		);
342	}
343
344	#[test]
345	fn override_matches_block_signals_when_values_agree() {
346		// Given the same core info and peer id, the override emits the block's own tail byte for
347		// byte. Resubmissions rely on this: the collator rebuilds the commitments from the signed
348		// payload, and the PVF's override must land on identical bytes to pass the commitments
349		// check at backing.
350		let selector = CoreSelector(7);
351		let offset = ClaimQueueOffset(3);
352		let peer_id = peer(0xAA);
353
354		let from_block = SchedulingSignals::from_block_signals(&[
355			UMPSignal::SelectCore(selector, offset).encode(),
356			UMPSignal::ApprovedPeer(peer_id.clone()).encode(),
357		])
358		.into_ump_messages();
359
360		let from_signed =
361			SchedulingSignals::from_scheduling_info(&signed_with(selector, offset.0, peer_id))
362				.into_ump_messages();
363
364		assert_eq!(from_block, from_signed);
365	}
366
367	#[test]
368	fn from_scheduling_info_emits_even_when_block_emitted_nothing() {
369		// The override is authoritative and independent of what the block emitted: a resubmission
370		// always produces its tail.
371		let signed = signed_with(CoreSelector(0), 0, peer(0xCC));
372		assert!(!SchedulingSignals::from_scheduling_info(&signed).into_ump_messages().is_empty());
373	}
374
375	/// Compile-time tripwire for [`SchedulingSignals::from_scheduling_info`]: it wholesale-replaces
376	/// the UMP tail with only the scheduling signals, which is correct only while *every*
377	/// `UMPSignal` variant is a scheduling signal. This exhaustive match (no `_` arm) stops
378	/// compiling the moment a new variant is added — decide then whether `from_scheduling_info`
379	/// must first become selective (merge the block's non-scheduling signals) so it is not
380	/// silently dropped on resubmissions.
381	#[test]
382	fn all_ump_signals_are_scheduling_signals() {
383		fn classify(signal: UMPSignal) {
384			match signal {
385				UMPSignal::SelectCore(..) | UMPSignal::ApprovedPeer(..) => {},
386			}
387		}
388		let _ = classify;
389	}
390}