referrerpolicy=no-referrer-when-downgrade

cumulus_client_collator_discovery/
lib.rs

1// Copyright (C) Parity Technologies (UK) Ltd.
2// This file is part of Cumulus.
3// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0
4
5// Cumulus is free software: you can redistribute it and/or modify
6// it under the terms of the GNU General Public License as published by
7// the Free Software Foundation, either version 3 of the License, or
8// (at your option) any later version.
9
10// Cumulus is distributed in the hope that it will be useful,
11// but WITHOUT ANY WARRANTY; without even the implied warranty of
12// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13// GNU General Public License for more details.
14
15// You should have received a copy of the GNU General Public License
16// along with Cumulus. If not, see <https://www.gnu.org/licenses/>.
17
18//! Collator authority discovery for parachains is used to achieve `1 hop` block announcement
19//! between collators.
20//!
21//! This requires the parachain runtime to implement
22//! [`sp_authority_discovery::AuthorityDiscoveryApi`], and that the collator's keystore contains an
23//! `AUTHORITY_DISCOVERY` key.
24//!
25//! Once the API is detected, it's assumed to remain available. The authority set is expected in
26//! authoring (session/validator-index) order — adopting parachain runtimes should implement
27//! [`sp_authority_discovery::AuthorityDiscoveryApi::authorities`] as
28//! `pallet_authority_discovery::Pallet::current_authorities().to_vec()` rather than the
29//! standard `Pallet::authorities()`, which sorts by pubkey and merges in next-session keys.
30//! Each node reserves up to `max_reserved` of its nearest ring neighbors on both sides. The
31//! immediate neighbors get a direct 1-hop block-announcement path, and more distant authors
32//! receive blocks with enough lead time before they author.
33
34use std::{
35	collections::HashSet,
36	path::PathBuf,
37	sync::Arc,
38	time::{Duration, Instant},
39};
40
41use futures_timer::Delay;
42
43use sc_authority_discovery::AuthorityDiscovery;
44use sc_network::{service::traits::NetworkService, DhtEvent, Multiaddr, PeerId, ProtocolName};
45use sc_service::SpawnTaskHandle;
46use sp_api::{ApiExt, ProvideRuntimeApi};
47use sp_authority_discovery::{AuthorityDiscoveryApi, AuthorityId};
48use sp_blockchain::HeaderBackend;
49use sp_core::crypto::key_types;
50use sp_keystore::KeystorePtr;
51use sp_runtime::traits::Block as BlockT;
52
53use sc_network_sync::{block_announces_protocol_name, SyncingService};
54
55const LOG_TARGET: &str = "collator-discovery";
56
57/// Re-resolve authority addresses periodically.
58const TRY_RERESOLVE_AUTHORITIES: Duration = Duration::from_secs(30);
59
60/// Maximum number of multiaddrs accepted per authority. Bounds dial-attempt amplification
61/// from a single authority publishing many multiaddrs.
62const MAX_ADDRS_PER_AUTHORITY: usize = 4;
63
64/// Warn when resolved connectivity stays below this percentage for [`LOW_CONNECTIVITY_WARN_DELAY`].
65const LOW_CONNECTIVITY_WARN_THRESHOLD_PCT: usize = 85;
66const LOW_CONNECTIVITY_WARN_DELAY: Duration = Duration::from_secs(600);
67
68pub struct CollatorDiscoveryConfig {
69	pub max_reserved: usize,
70	pub protocol: ProtocolName,
71}
72
73/// Parameters for [`start_collator_discovery`].
74pub struct StartCollatorDiscoveryParams<Block: BlockT, Client, AD, NetEventStream> {
75	/// Upper bound on the number of authority peers the node reserves slots for.
76	/// `start_collator_discovery` should not be called on non-collators or
77	/// when `max_reserved` is 0.
78	pub max_reserved: usize,
79	pub client: Arc<Client>,
80	/// Usually the same `Arc` as `client`.
81	pub authority_discovery: Arc<AD>,
82	pub network: Arc<dyn NetworkService>,
83	pub sync_service: Arc<SyncingService<Block>>,
84	/// Raw network event stream; the worker filters for `Event::Dht`.
85	pub network_event_stream: NetEventStream,
86	/// Keystore with the local AD keys; used to sign DHT records and exclude this node
87	/// from the reserved peer set.
88	pub keystore: KeystorePtr,
89	pub genesis_hash: Block::Hash,
90	pub fork_id: Option<String>,
91	/// Local/testing only.
92	pub publish_non_global_ips: bool,
93	pub public_addresses: Vec<Multiaddr>,
94	pub persisted_cache_directory: Option<PathBuf>,
95	pub prometheus_registry: Option<prometheus_endpoint::Registry>,
96	pub spawn_handle: SpawnTaskHandle,
97}
98
99/// Spawn the authority-discovery worker and the parachain reserved-peer refresh task.
100///
101/// Only call this on collators with `max_reserved > 0`.
102pub fn start_collator_discovery<Block, Client, AD, NetEventStream>(
103	params: StartCollatorDiscoveryParams<Block, Client, AD, NetEventStream>,
104) -> Result<(), prometheus_endpoint::PrometheusError>
105where
106	Block: BlockT + Unpin + 'static,
107	Client: HeaderBackend<Block> + ProvideRuntimeApi<Block> + Send + Sync + 'static,
108	Client::Api: ApiExt<Block>,
109	AD: AuthorityDiscovery<Block> + Send + Sync + 'static,
110	NetEventStream: futures::Stream<Item = sc_network::Event> + Send + Unpin + 'static,
111{
112	let StartCollatorDiscoveryParams {
113		max_reserved,
114		client,
115		authority_discovery,
116		network,
117		sync_service,
118		network_event_stream,
119		keystore,
120		genesis_hash,
121		fork_id,
122		publish_non_global_ips,
123		public_addresses,
124		persisted_cache_directory,
125		prometheus_registry,
126		spawn_handle,
127	} = params;
128
129	let protocol: ProtocolName =
130		block_announces_protocol_name(genesis_hash, fork_id.as_deref()).into();
131
132	use futures::StreamExt;
133	let dht_event_stream = network_event_stream.filter_map(|e| async move {
134		match e {
135			sc_network::Event::Dht(e) => Some(e),
136			_ => None,
137		}
138	});
139
140	spawn_collator_discovery_tasks::<Block, _, _, _>(
141		CollatorDiscoveryConfig { max_reserved, protocol },
142		client,
143		authority_discovery,
144		network,
145		sync_service,
146		Box::pin(dht_event_stream),
147		keystore,
148		publish_non_global_ips,
149		public_addresses,
150		persisted_cache_directory,
151		prometheus_registry,
152		spawn_handle,
153	)
154}
155
156/// Spawn the authority-discovery worker and refresh task; returns immediately.
157fn spawn_collator_discovery_tasks<Block, Client, AD, DhtStream>(
158	config: CollatorDiscoveryConfig,
159	client: Arc<Client>,
160	authority_discovery: Arc<AD>,
161	network: Arc<dyn NetworkService>,
162	sync_service: Arc<SyncingService<Block>>,
163	dht_event_stream: DhtStream,
164	keystore: KeystorePtr,
165	publish_non_global_ips: bool,
166	public_addresses: Vec<Multiaddr>,
167	persisted_cache_directory: Option<PathBuf>,
168	prometheus_registry: Option<prometheus_endpoint::Registry>,
169	spawn_handle: SpawnTaskHandle,
170) -> Result<(), prometheus_endpoint::PrometheusError>
171where
172	Block: BlockT + Unpin + 'static,
173	Client: HeaderBackend<Block> + ProvideRuntimeApi<Block> + Send + Sync + 'static,
174	Client::Api: ApiExt<Block>,
175	AD: AuthorityDiscovery<Block> + Send + Sync + 'static,
176	DhtStream: futures::Stream<Item = DhtEvent> + Send + Unpin + 'static,
177{
178	let metrics = prometheus_registry.as_ref().map(Metrics::register).transpose()?;
179
180	let (worker, authority_discovery_service) =
181		sc_authority_discovery::new_worker_and_service_with_config(
182			sc_authority_discovery::WorkerConfig {
183				publish_non_global_ips,
184				public_addresses,
185				strict_record_validation: true,
186				persisted_cache_directory,
187				..Default::default()
188			},
189			authority_discovery.clone(),
190			Arc::new(network.clone()),
191			dht_event_stream,
192			sc_authority_discovery::Role::PublishAndDiscover(keystore.clone()),
193			prometheus_registry,
194			spawn_handle.clone(),
195		);
196
197	// We could have spawned this task as essential, but we don't because the collators
198	// should be able to continue to build blocks without running this task.
199	spawn_handle.spawn(
200		"para-authority-discovery-worker",
201		Some("authority-discovery"),
202		worker.run(),
203	);
204
205	log::info!(target: LOG_TARGET, "Starting collator discovery");
206
207	spawn_handle.spawn(
208		"collator-discovery",
209		Some("collator-discovery"),
210		discovery_refresh_loop::<Block, Client, AD>(
211			config,
212			client,
213			authority_discovery,
214			network,
215			sync_service,
216			authority_discovery_service,
217			keystore,
218			metrics,
219		),
220	);
221
222	Ok(())
223}
224
225/// Refresh the reserved/no-slot peer sets every [`TRY_RERESOLVE_AUTHORITIES`].
226async fn discovery_refresh_loop<Block, Client, AD>(
227	config: CollatorDiscoveryConfig,
228	client: Arc<Client>,
229	authority_discovery: Arc<AD>,
230	network: Arc<dyn NetworkService>,
231	sync_service: Arc<SyncingService<Block>>,
232	mut authority_discovery_service: sc_authority_discovery::Service,
233	keystore: KeystorePtr,
234	metrics: Option<Metrics>,
235) where
236	Block: BlockT,
237	Client: HeaderBackend<Block> + ProvideRuntimeApi<Block> + Send + Sync + 'static,
238	Client::Api: ApiExt<Block>,
239	AD: AuthorityDiscovery<Block> + Send + Sync + 'static,
240{
241	let CollatorDiscoveryConfig { max_reserved, protocol } = config;
242
243	let local_peer_id = network.local_peer_id();
244	let mut state = LoopState::new();
245
246	// Wait for the runtime to expose `AuthorityDiscoveryApi`.
247	loop {
248		let at = client.info().finalized_hash;
249		let ad_enabled = client
250			.runtime_api()
251			.has_api::<dyn AuthorityDiscoveryApi<Block>>(at)
252			.unwrap_or(false);
253		log::trace!(
254			target: LOG_TARGET,
255			"AuthorityDiscoveryApi at {at:?}: {}",
256			if ad_enabled { "enabled" } else { "disabled" },
257		);
258		if ad_enabled {
259			break;
260		}
261		Delay::new(TRY_RERESOLVE_AUTHORITIES).await;
262	}
263
264	// Refresh the reserved/no-slot peer sets every [`TRY_RERESOLVE_AUTHORITIES`].
265	loop {
266		// Get authority set at the latest *finalized* block so all collators converge
267		// on the same in the presences of forks.
268		let at = client.info().finalized_hash;
269		let local_pub_keys: HashSet<AuthorityId> = keystore
270			.sr25519_public_keys(key_types::AUTHORITY_DISCOVERY)
271			.into_iter()
272			.map(AuthorityId::from)
273			.collect();
274		update_parachain_authorities(
275			&*authority_discovery,
276			&*network,
277			&sync_service,
278			&mut authority_discovery_service,
279			&local_pub_keys,
280			local_peer_id,
281			max_reserved,
282			&protocol,
283			&mut state,
284			metrics.as_ref(),
285			at,
286		)
287		.await;
288		Delay::new(TRY_RERESOLVE_AUTHORITIES).await;
289	}
290}
291
292/// Loop-local state: last applied snapshot + low-connectivity bookkeeping.
293struct LoopState {
294	last_authorities: Option<HashSet<AuthorityId>>,
295	last_addrs: Option<HashSet<Multiaddr>>,
296	/// When we first dropped below the connectivity warning threshold; `None` if above it.
297	low_connectivity_since: Option<Instant>,
298}
299
300impl LoopState {
301	fn new() -> Self {
302		Self { last_authorities: None, last_addrs: None, low_connectivity_since: None }
303	}
304}
305
306/// Compute the additive diff between the multiaddrs we want reserved now and the multiaddrs
307/// we asked for on the previous tick.
308///
309/// `to_remove` is restricted to peers we previously added, externally-managed reserved peers
310/// (e.g. operator-supplied `--reserved-nodes`) never appear here and are never removed.
311fn compute_reserved_diff(
312	current: &HashSet<Multiaddr>,
313	previous: Option<&HashSet<Multiaddr>>,
314) -> (HashSet<Multiaddr>, Vec<PeerId>) {
315	let to_add = match previous {
316		Some(prev) => current.difference(prev).cloned().collect(),
317		None => current.clone(),
318	};
319	let to_remove = match previous {
320		Some(prev) => prev.difference(current).filter_map(PeerId::try_from_multiaddr).collect(),
321		None => Vec::new(),
322	};
323	(to_add, to_remove)
324}
325
326/// Select up to `max_reserved` authority neighbors of the node.
327///
328/// `authorities` is taken in authoring order, the order Aura uses to assign slots. We locate
329/// the local authority on this ring and take its nearest neighbors on both sides, wrapping
330/// around. Neighbors are added symmetrically (distance `d` on each side at once) so that
331/// the relationship is mutual — if we reserve the peer `d` positions away, that peer reserves us
332/// back. An odd `max_reserved` therefore leaves one slot unused. Once `max_reserved` reaches the
333/// ring size the result is the full mesh.
334///
335/// The immediate neighbors (`N-1`, `N+1`) get a direct, 1-hop block-announcement path; more
336/// distant authors receive blocks via the ring with enough lead time before they author.
337fn select_ring_neighbors(
338	authorities: Vec<AuthorityId>,
339	local_pub_keys: &HashSet<AuthorityId>,
340	max_reserved: usize,
341) -> HashSet<AuthorityId> {
342	let n = authorities.len();
343	if n == 0 || max_reserved == 0 {
344		return HashSet::new();
345	}
346	let Some(local_idx) = authorities.iter().position(|id| local_pub_keys.contains(id)) else {
347		return HashSet::new();
348	};
349
350	let half = max_reserved / 2;
351	let right = authorities.iter().cycle().skip(local_idx + 1).take(half);
352	let left = authorities.iter().cycle().skip(local_idx + n.saturating_sub(half)).take(half);
353	right.chain(left).filter(|id| !local_pub_keys.contains(id)).cloned().collect()
354}
355
356/// Resolve authority multiaddrs and push updated reserved/no-slot peer sets if anything
357/// changed since the last call.
358async fn update_parachain_authorities<Block, AD>(
359	authority_discovery: &AD,
360	network: &dyn NetworkService,
361	sync_service: &SyncingService<Block>,
362	authority_discovery_service: &mut sc_authority_discovery::Service,
363	local_pub_keys: &HashSet<AuthorityId>,
364	local_peer_id: PeerId,
365	max_reserved: usize,
366	protocol: &ProtocolName,
367	state: &mut LoopState,
368	metrics: Option<&Metrics>,
369	at: Block::Hash,
370) where
371	Block: BlockT,
372	AD: AuthorityDiscovery<Block> + Send + Sync + 'static,
373{
374	let authorities = match authority_discovery.authorities(at).await {
375		Ok(a) => a,
376		Err(e) => {
377			log::warn!(target: LOG_TARGET, "Failed to fetch parachain authorities at {at:?}: {e}");
378			return;
379		},
380	};
381
382	let selected = select_ring_neighbors(authorities, local_pub_keys, max_reserved);
383	let target_count = selected.len();
384	let (addrs, unresolved) =
385		resolve_authority_addresses(&selected, authority_discovery_service, local_peer_id).await;
386	let resolved = target_count.saturating_sub(unresolved);
387
388	if let Some(m) = metrics {
389		m.target_authorities.set(target_count as u64);
390		m.unresolved_authorities.set(unresolved as u64);
391		m.resolved_peers.set(addrs.len() as u64);
392	}
393
394	log_low_connectivity_if_stuck(target_count, resolved, &mut state.low_connectivity_since);
395
396	// Skip pushing when both the authority set and resolved multiaddrs are unchanged.
397	if state.last_authorities.as_ref() == Some(&selected) &&
398		state.last_addrs.as_ref() == Some(&addrs)
399	{
400		log::trace!(target: LOG_TARGET, "No-op refresh at {at:?}");
401		return;
402	}
403
404	// Diff against the last known state and apply additively.
405	let (to_add, to_remove) = compute_reserved_diff(&addrs, state.last_addrs.as_ref());
406
407	let peer_ids: HashSet<PeerId> = addrs.iter().filter_map(PeerId::try_from_multiaddr).collect();
408	log::debug!(
409		target: LOG_TARGET,
410		"Refreshing reserved peers at {at:?}: target={target_count} unresolved={unresolved} multiaddrs={} peer_ids={} (+{} -{})",
411		addrs.len(),
412		peer_ids.len(),
413		to_add.len(),
414		to_remove.len(),
415	);
416
417	if !to_add.is_empty() {
418		if let Err(e) = network.add_peers_to_reserved_set(protocol.clone(), to_add) {
419			log::warn!(
420				target: LOG_TARGET,
421				"add_peers_to_reserved_set failed at {at:?}: {e}; will retry on next tick",
422			);
423			return;
424		}
425	}
426
427	if !to_remove.is_empty() {
428		if let Err(e) = network.remove_peers_from_reserved_set(protocol.clone(), to_remove) {
429			log::warn!(
430				target: LOG_TARGET,
431				"remove_peers_from_reserved_set failed at {at:?}: {e}; will retry on next tick",
432			);
433			return;
434		}
435	}
436
437	// Update the no-slot set only after the reserved set is updated, so the two stay in sync.
438	sync_service.set_no_slot_peers(peer_ids);
439	state.last_authorities = Some(selected);
440	state.last_addrs = Some(addrs);
441}
442
443/// Resolve multiaddrs for each authority. Returns the deduplicated set of multiaddrs and the
444/// count of authorities whose addresses could not be resolved.
445///
446/// Drops multiaddrs that resolve to our own libp2p PeerId.
447async fn resolve_authority_addresses(
448	authorities: &HashSet<AuthorityId>,
449	service: &mut sc_authority_discovery::Service,
450	local_peer_id: PeerId,
451) -> (HashSet<Multiaddr>, usize) {
452	let mut addrs: HashSet<Multiaddr> = HashSet::new();
453	let mut unresolved = 0;
454
455	for id in authorities {
456		let Some(raw) = service.get_addresses_by_authority_id(id.clone()).await else {
457			unresolved += 1;
458			log::debug!(target: LOG_TARGET, "Couldn't resolve addresses of authority: {id:?}");
459			continue;
460		};
461
462		let original_len = raw.len();
463		let filtered: Vec<Multiaddr> = raw
464			.into_iter()
465			.filter(|m| PeerId::try_from_multiaddr(m).map_or(true, |pid| pid != local_peer_id))
466			.take(MAX_ADDRS_PER_AUTHORITY)
467			.collect();
468
469		if original_len > MAX_ADDRS_PER_AUTHORITY {
470			log::debug!(
471				target: LOG_TARGET,
472				"Capped multiaddrs for authority {id:?}: {original_len} -> {MAX_ADDRS_PER_AUTHORITY}",
473			);
474		}
475		log::debug!(target: LOG_TARGET, "Resolved authority {id:?}: {} multiaddr(s)", filtered.len());
476		addrs.extend(filtered);
477	}
478
479	(addrs, unresolved)
480}
481
482fn log_low_connectivity_if_stuck(target: usize, resolved: usize, since: &mut Option<Instant>) {
483	if target == 0 {
484		*since = None;
485		return;
486	}
487	let pct = (resolved * 100) / target;
488	if pct >= LOW_CONNECTIVITY_WARN_THRESHOLD_PCT {
489		*since = None;
490		return;
491	}
492	match *since {
493		Some(t) if t.elapsed() >= LOW_CONNECTIVITY_WARN_DELAY => {
494			log::warn!(
495				target: LOG_TARGET,
496				"Collator discovery: peer connectivity has been under {}% for more than {:?} \
497				 ({resolved}/{target} authorities resolved). Check authority-discovery \
498				 DHT reachability.",
499				LOW_CONNECTIVITY_WARN_THRESHOLD_PCT, LOW_CONNECTIVITY_WARN_DELAY,
500			);
501			// Reset so we warn at most once per elapsed window.
502			*since = Some(Instant::now());
503		},
504		Some(_) => {},
505		None => *since = Some(Instant::now()),
506	}
507}
508
509/// Prometheus metrics for the collator-discovery task.
510#[derive(Clone)]
511struct Metrics {
512	target_authorities: prometheus_endpoint::Gauge<prometheus_endpoint::U64>,
513	unresolved_authorities: prometheus_endpoint::Gauge<prometheus_endpoint::U64>,
514	resolved_peers: prometheus_endpoint::Gauge<prometheus_endpoint::U64>,
515}
516
517impl Metrics {
518	fn register(
519		registry: &prometheus_endpoint::Registry,
520	) -> Result<Self, prometheus_endpoint::PrometheusError> {
521		use prometheus_endpoint::{register, Gauge, Opts};
522		Ok(Self {
523			target_authorities: register(
524				Gauge::with_opts(Opts::new(
525					"collator_discovery_target_authorities",
526					"Number of parachain authorities currently targeted for reservation.",
527				))?,
528				registry,
529			)?,
530			unresolved_authorities: register(
531				Gauge::with_opts(Opts::new(
532					"collator_discovery_unresolved_authorities",
533					"Number of targeted authorities we couldn't resolve a multiaddr for.",
534				))?,
535				registry,
536			)?,
537			resolved_peers: register(
538				Gauge::with_opts(Opts::new(
539					"collator_discovery_resolved_peers",
540					"Number of multiaddrs pushed to the collator-sync reserved set.",
541				))?,
542				registry,
543			)?,
544		})
545	}
546}
547
548#[cfg(test)]
549mod tests {
550	use super::*;
551	use sp_core::sr25519;
552
553	fn authority_id(seed_byte: u8) -> AuthorityId {
554		AuthorityId::from(sr25519::Public::from_raw([seed_byte; 32]))
555	}
556
557	/// A ring `0..n` of authorities in authoring order, with `local` as our single local key.
558	fn ring(n: u8) -> Vec<AuthorityId> {
559		(0..n).map(authority_id).collect()
560	}
561
562	fn local_set(seed: u8) -> HashSet<AuthorityId> {
563		let mut s = HashSet::new();
564		s.insert(authority_id(seed));
565		s
566	}
567
568	#[test]
569	fn ring_picks_immediate_neighbors_with_min_budget() {
570		// Local at index 2 in a ring of 5; budget 2 -> exactly N-1 (1) and N+1 (3).
571		let selected = select_ring_neighbors(ring(5), &local_set(2), 2);
572		assert_eq!(selected, HashSet::from([authority_id(3), authority_id(1)]));
573	}
574
575	#[test]
576	fn ring_wraps_around_at_the_ends() {
577		// Local at index 0; neighbors are index 1 (right) and index 4 (wrapped left).
578		let selected = select_ring_neighbors(ring(5), &local_set(0), 2);
579		assert_eq!(selected, HashSet::from([authority_id(1), authority_id(4)]));
580	}
581
582	#[test]
583	fn ring_grows_balanced_on_both_sides() {
584		// Budget 4 around index 2: right {3,4}, left {0,1}.
585		let selected = select_ring_neighbors(ring(5), &local_set(2), 4);
586		assert_eq!(
587			selected,
588			HashSet::from([authority_id(3), authority_id(1), authority_id(4), authority_id(0)]),
589		);
590	}
591
592	#[test]
593	fn ring_odd_budget_leaves_one_slot_unused_to_stay_symmetric() {
594		// Budget 3 around index 2: half = 1 on each side -> {3} right, {1} left; one slot unused.
595		let selected = select_ring_neighbors(ring(5), &local_set(2), 3);
596		assert_eq!(selected, HashSet::from([authority_id(3), authority_id(1)]));
597	}
598
599	#[test]
600	fn ring_becomes_full_mesh_when_budget_covers_everyone() {
601		let selected = select_ring_neighbors(ring(5), &local_set(2), 100);
602		let expected: HashSet<_> = [0u8, 1, 3, 4].into_iter().map(authority_id).collect();
603		assert_eq!(selected, expected);
604	}
605
606	#[test]
607	fn ring_excludes_self() {
608		let selected = select_ring_neighbors(ring(4), &local_set(1), 100);
609		assert!(!selected.contains(&authority_id(1)));
610	}
611
612	#[test]
613	fn ring_handles_even_antipode_without_duplicates() {
614		// n = 4, local at 0: half = 2 on each side. Right covers {1,2}, left covers {2,3};
615		// the antipode (2) appears in both and is deduped by the HashSet.
616		let selected = select_ring_neighbors(ring(4), &local_set(0), 100);
617		assert_eq!(selected, HashSet::from([authority_id(1), authority_id(2), authority_id(3)]),);
618	}
619
620	#[test]
621	fn ring_alone_selects_nothing() {
622		let selected = select_ring_neighbors(ring(1), &local_set(0), 100);
623		assert!(selected.is_empty());
624	}
625
626	#[test]
627	fn ring_empty_when_not_an_authority() {
628		// Local key not in the set: nothing to reserve — non-authorities rely on gossip.
629		let selected = select_ring_neighbors(ring(5), &local_set(99), 3);
630		assert!(selected.is_empty());
631	}
632
633	#[test]
634	fn ring_neighbor_relationships_are_mutual() {
635		// For every node, the neighbors it reserves must reserve it back.
636		let n = 7u8;
637		let budget = 4;
638		let pick = |me: u8| -> HashSet<u8> {
639			select_ring_neighbors(ring(n), &local_set(me), budget)
640				.into_iter()
641				.map(|id| AsRef::<[u8]>::as_ref(&id)[0])
642				.collect()
643		};
644		for me in 0..n {
645			for peer in pick(me) {
646				assert!(
647					pick(peer).contains(&me),
648					"{me} reserved {peer} but {peer} did not reserve {me} back",
649				);
650			}
651		}
652	}
653
654	#[test]
655	fn low_connectivity_starts_quiet() {
656		let mut since = None;
657		// resolved == target, pct = 100 — no warning state.
658		log_low_connectivity_if_stuck(4, 4, &mut since);
659		assert!(since.is_none());
660	}
661
662	#[test]
663	fn low_connectivity_remembers_drop_below_threshold() {
664		let mut since = None;
665		// 1/4 = 25% < 85%, below threshold
666		log_low_connectivity_if_stuck(4, 1, &mut since);
667		assert!(since.is_some());
668	}
669
670	#[test]
671	fn low_connectivity_clears_on_recovery() {
672		let mut since = Some(Instant::now());
673		log_low_connectivity_if_stuck(4, 4, &mut since);
674		assert!(since.is_none());
675	}
676
677	#[test]
678	fn low_connectivity_noop_on_zero_target() {
679		let mut since = Some(Instant::now());
680		log_low_connectivity_if_stuck(0, 0, &mut since);
681		assert!(since.is_none());
682	}
683
684	/// Build a deterministic multiaddr `/memory/<seed>/p2p/<peer-id-derived-from-seed>`.
685	fn ma(seed: u8) -> Multiaddr {
686		use sc_network::{multiaddr::Protocol, Keypair};
687		let kp = Keypair::ed25519_from_bytes([seed; 32]).expect("32-byte seed; qed");
688		let pid: PeerId = kp.public().to_peer_id().into();
689		Multiaddr::empty()
690			.with(Protocol::Memory(seed as u64))
691			.with(Protocol::P2p(pid.into()))
692	}
693
694	fn set_of(ms: impl IntoIterator<Item = Multiaddr>) -> HashSet<Multiaddr> {
695		ms.into_iter().collect()
696	}
697
698	fn pid_of(m: &Multiaddr) -> PeerId {
699		PeerId::try_from_multiaddr(m).expect("multiaddr from ma() has /p2p/")
700	}
701
702	#[test]
703	fn diff_initial_tick_adds_everything_no_removals() {
704		let current = set_of([ma(1), ma(2)]);
705		let (to_add, to_remove) = compute_reserved_diff(&current, None);
706		assert_eq!(to_add, current);
707		assert!(to_remove.is_empty(), "no previous state => nothing to remove");
708	}
709
710	#[test]
711	fn diff_steady_state_is_noop() {
712		let current = set_of([ma(1), ma(2)]);
713		let previous = current.clone();
714		let (to_add, to_remove) = compute_reserved_diff(&current, Some(&previous));
715		assert!(to_add.is_empty());
716		assert!(to_remove.is_empty());
717	}
718
719	#[test]
720	fn diff_addition_only() {
721		let previous = set_of([ma(1)]);
722		let current = set_of([ma(1), ma(2)]);
723		let (to_add, to_remove) = compute_reserved_diff(&current, Some(&previous));
724		assert_eq!(to_add, set_of([ma(2)]));
725		assert!(to_remove.is_empty());
726	}
727
728	#[test]
729	fn diff_removal_only() {
730		let previous = set_of([ma(1), ma(2)]);
731		let current = set_of([ma(1)]);
732		let (to_add, to_remove) = compute_reserved_diff(&current, Some(&previous));
733		assert!(to_add.is_empty());
734		assert_eq!(to_remove, vec![pid_of(&ma(2))]);
735	}
736
737	#[test]
738	fn diff_mixed_add_and_remove() {
739		let previous = set_of([ma(1), ma(2)]);
740		let current = set_of([ma(1), ma(3)]);
741		let (to_add, to_remove) = compute_reserved_diff(&current, Some(&previous));
742		assert_eq!(to_add, set_of([ma(3)]));
743		assert_eq!(to_remove, vec![pid_of(&ma(2))]);
744	}
745
746	#[test]
747	fn diff_back_to_empty_removes_everything_previously_added() {
748		let previous = set_of([ma(1), ma(2)]);
749		let current = HashSet::new();
750		let (to_add, to_remove) = compute_reserved_diff(&current, Some(&previous));
751		assert!(to_add.is_empty());
752		let removed: HashSet<_> = to_remove.into_iter().collect();
753		assert_eq!(removed, set_of([ma(1), ma(2)]).iter().map(pid_of).collect::<HashSet<_>>());
754	}
755
756	#[test]
757	fn diff_empty_to_empty_is_total_noop() {
758		// First tick with an AD that returns no authorities: no network calls at all.
759		let current = HashSet::new();
760		let (to_add, to_remove) = compute_reserved_diff(&current, None);
761		assert!(to_add.is_empty());
762		assert!(to_remove.is_empty());
763	}
764}