tenzro_network/service.rs
1//! Main NetworkService for Tenzro Network
2
3use crate::{
4 behaviour::{TenzroBehaviour, TenzroBehaviourEvent},
5 block_sync_proto::{BlockSyncRequest, BlockSyncResponse},
6 config::NetworkConfig,
7 consensus_direct_proto::{
8 ConsensusDirectError, ConsensusDirectRequest, ConsensusDirectResponse,
9 MAX_INBOUND_STREAMS_PER_PEER,
10 },
11 error::{NetworkError, Result},
12 gossip::{MessageDeduplicator, MessageValidation, validate_gossip_message},
13 message::{ConsensusMessage, NetworkMessage, MessagePayload},
14 metrics::NetworkMetrics,
15 peer_manager::{PeerManager, ManagedPeer},
16};
17use async_trait::async_trait;
18use futures::StreamExt;
19use libp2p::{
20 autonat, dcutr,
21 gossipsub::{self, IdentTopic, TopicHash},
22 identify,
23 kad::{self, QueryResult},
24 ping, relay,
25 request_response::{self, InboundRequestId, OutboundRequestId, ResponseChannel},
26 swarm::SwarmEvent,
27 Multiaddr, PeerId, Swarm,
28};
29use parking_lot::Mutex;
30use prometheus_client::registry::Registry;
31use std::collections::{HashMap, HashSet};
32use std::net::IpAddr;
33use std::path::PathBuf;
34use std::sync::Arc;
35use std::time::Duration;
36use tokio::sync::{mpsc, oneshot};
37use tokio::time::{interval, MissedTickBehavior};
38use tenzro_types::network::PeerStatus;
39
40/// Loads a persistent Ed25519 keypair from disk, or generates and saves a new one.
41///
42/// The keypair is stored as a protobuf-encoded file at `{data_dir}/p2p_key`.
43/// This ensures the node has a stable PeerId across restarts.
44fn load_or_generate_keypair(data_dir: &Option<PathBuf>) -> Result<libp2p::identity::Keypair> {
45 let Some(dir) = data_dir else {
46 tracing::warn!("No data_dir configured — generating ephemeral keypair (peer ID will change on restart)");
47 return Ok(libp2p::identity::Keypair::generate_ed25519());
48 };
49
50 let key_path = dir.join("p2p_key");
51
52 // Try to load existing key
53 if key_path.exists() {
54 match std::fs::read(&key_path) {
55 Ok(bytes) => {
56 match libp2p::identity::Keypair::from_protobuf_encoding(&bytes) {
57 Ok(keypair) => {
58 tracing::info!("Loaded persistent keypair from {}", key_path.display());
59 return Ok(keypair);
60 }
61 Err(e) => {
62 tracing::warn!("Failed to decode keypair from {}: {} — generating new one", key_path.display(), e);
63 }
64 }
65 }
66 Err(e) => {
67 tracing::warn!("Failed to read keypair file {}: {} — generating new one", key_path.display(), e);
68 }
69 }
70 }
71
72 // Generate new keypair and save it
73 let keypair = libp2p::identity::Keypair::generate_ed25519();
74
75 // Ensure parent directory exists
76 if let Some(parent) = key_path.parent()
77 && let Err(e) = std::fs::create_dir_all(parent)
78 {
79 tracing::warn!("Failed to create directory {}: {} — keypair will be ephemeral", parent.display(), e);
80 return Ok(keypair);
81 }
82
83 match keypair.to_protobuf_encoding() {
84 Ok(bytes) => {
85 match std::fs::write(&key_path, &bytes) {
86 Ok(()) => {
87 tracing::info!("Generated and saved new keypair to {}", key_path.display());
88 // Restrict file permissions on Unix
89 #[cfg(unix)]
90 {
91 use std::os::unix::fs::PermissionsExt;
92 if let Err(e) = std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)) {
93 tracing::warn!("Failed to set keypair file permissions: {}", e);
94 }
95 }
96 }
97 Err(e) => {
98 tracing::warn!("Failed to write keypair to {}: {} — keypair will be ephemeral", key_path.display(), e);
99 }
100 }
101 }
102 Err(e) => {
103 tracing::warn!("Failed to encode keypair: {} — keypair will be ephemeral", e);
104 }
105 }
106
107 Ok(keypair)
108}
109
110/// Returns true only if `addr` contains an IP that other nodes can actually
111/// reach. Rejects:
112/// - loopback (127.0.0.0/8 / ::1)
113/// - link-local (169.254.0.0/16 / fe80::/10)
114/// - unspecified (0.0.0.0 / ::)
115/// - broadcast, documentation, benchmarking ranges
116/// - Docker bridge default range 172.16.0.0/12 — when a node `--network host`s
117/// a container, libp2p enumerates the docker0 bridge (commonly 172.17.0.1)
118/// and advertises it via Identify. Other peers then dial that address, hit
119/// their OWN docker0 bridge, and get "Unexpected peer ID" from whatever
120/// local container happens to be there. Per-IP rate limiters then ban the
121/// legitimate peer. Observed on the GCE multi-region testnet 2026-05-14.
122/// - IPv4 192.168.0.0/16 (consumer-NAT range — never inside our datacenter
123/// deployment; if a node reports it, it's a misconfig / NAT leak)
124/// - IPv6 unique-local (fc00::/7) and multicast (ff00::/8)
125/// - addresses with no IP component at all
126///
127/// IPv4 10.0.0.0/8 is intentionally ACCEPTED — this is the GCE/AWS/K8s VPC
128/// subnet range and is the only reachable address for intra-region peers
129/// before AutoNAT confirms a public external IP. Cross-region peers connect
130/// via the public IPs that GCE allocates (which aren't in any RFC-1918 range).
131fn is_globally_routable(addr: &Multiaddr) -> bool {
132 use libp2p::multiaddr::Protocol;
133 for proto in addr.iter() {
134 match proto {
135 Protocol::Ip4(ip) => {
136 let octets = ip.octets();
137 // 172.16.0.0/12 — docker bridge default + corp-NAT range
138 let is_docker_or_corp = octets[0] == 172 && (octets[1] & 0xf0) == 16;
139 // 192.168.0.0/16 — consumer NAT, never in our DC deploy
140 let is_consumer_nat = octets[0] == 192 && octets[1] == 168;
141 // 100.64.0.0/10 — RFC 6598 carrier-grade NAT
142 let is_cgn = octets[0] == 100 && (octets[1] & 0xc0) == 64;
143 if ip.is_loopback()
144 || ip.is_link_local()
145 || ip.is_unspecified()
146 || ip.is_broadcast()
147 || ip.is_documentation()
148 || is_docker_or_corp
149 || is_consumer_nat
150 || is_cgn
151 {
152 return false;
153 }
154 return true;
155 }
156 Protocol::Ip6(ip) => {
157 let seg0 = ip.segments()[0];
158 if ip.is_loopback()
159 || ip.is_unspecified()
160 || ip.is_multicast()
161 || (seg0 & 0xffc0) == 0xfe80 // fe80::/10 link-local
162 || (seg0 & 0xfe00) == 0xfc00 // fc00::/7 unique-local
163 {
164 return false;
165 }
166 return true;
167 }
168 _ => {}
169 }
170 }
171 false // No IP component found — not routable
172}
173
174/// Extracts the IPv4/IPv6 address from a libp2p `Multiaddr`, if any.
175/// Used for per-IP dial rate limiting on incoming connections.
176fn extract_ip(addr: &Multiaddr) -> Option<IpAddr> {
177 use libp2p::multiaddr::Protocol;
178 for proto in addr.iter() {
179 match proto {
180 Protocol::Ip4(ip) => return Some(IpAddr::V4(ip)),
181 Protocol::Ip6(ip) => return Some(IpAddr::V6(ip)),
182 _ => continue,
183 }
184 }
185 None
186}
187
188/// Extracts the transport port from a libp2p `Multiaddr`, if any.
189///
190/// Walks the protocol stack and returns the first TCP/UDP/QUIC port encoded
191/// in the address. Used to gate `observed_addr` promotion on a
192/// listen-port match — see `is_observed_port_one_of_ours` below.
193fn extract_port(addr: &Multiaddr) -> Option<u16> {
194 use libp2p::multiaddr::Protocol;
195 for proto in addr.iter() {
196 match proto {
197 Protocol::Tcp(p) | Protocol::Udp(p) => return Some(p),
198 _ => continue,
199 }
200 }
201 None
202}
203
204/// Returns `true` if the port carried by `observed` matches at least one
205/// of the ports we are actually listening on.
206///
207/// This is the canonical defence against promoting a NAT-translated outbound
208/// source port as if it were a reachable listen address. The mode where this
209/// matters in practice: a node behind a stateful NAT (cloud VPC, home
210/// router, mobile carrier) dials a peer; the peer's libp2p Identify reports
211/// `observed_addr` = `(our_public_ip, ephemeral_source_port)`. The
212/// ephemeral source port is *not* a listener — nothing is bound there. If we
213/// promote it via `Swarm::add_external_address`, we advertise an unreachable
214/// address that subsequent peers will dial and fail on, halting consensus
215/// when the bootstrap node restarts (see `consensus_stall_2026_05_18`).
216///
217/// Pattern mirrors go-libp2p `observedAddrManager.shouldRecordObservation`
218/// and rust-libp2p ≥ 0.45 Identify, both of which require the observation
219/// to match one of the host's actual listen addresses before promotion.
220///
221/// Wildcard `0.0.0.0` / `::` listen addresses are bound to their port; the
222/// port is the salient comparison key because the public IP cannot be
223/// determined from the wildcard.
224fn is_observed_port_one_of_ours(observed: &Multiaddr, our_listen_addrs: &[Multiaddr]) -> bool {
225 let Some(observed_port) = extract_port(observed) else {
226 return false;
227 };
228 our_listen_addrs
229 .iter()
230 .filter_map(extract_port)
231 .any(|p| p == observed_port)
232}
233
234/// An inbound block-sync request received from a peer.
235///
236/// The receiver MUST eventually call
237/// [`TenzroNetworkService::send_block_sync_response`] with the matching
238/// `request_id`, or the inbound stream times out and the peer scores us
239/// down. Dropping the value without responding is acceptable only on
240/// explicit reject paths (the consumer should call `send_block_sync_response`
241/// with a `BlockSyncResponse::Error(_)` variant in that case).
242#[derive(Debug)]
243pub struct InboundBlockSync {
244 pub peer: PeerId,
245 pub request_id: InboundRequestId,
246 pub request: BlockSyncRequest,
247}
248
249/// Outbound block-sync result delivered asynchronously to the issuer of
250/// `request_blocks` / `request_tip_info` / `request_block_by_hash`.
251///
252/// The `request_id` matches the one returned by the request-issuing call.
253/// `result` carries either the decoded response or a typed transport error.
254#[derive(Debug)]
255pub struct OutboundBlockSyncResult {
256 pub peer: PeerId,
257 pub request_id: OutboundRequestId,
258 pub result: std::result::Result<BlockSyncResponse, BlockSyncOutboundError>,
259}
260
261/// Connection lifecycle events surfaced by the swarm to subscribed
262/// consumers (block-sync engine, future peer-aware subsystems).
263///
264/// `Connected` fires on the first physical connection to `peer` (i.e. when
265/// `num_established == 1`), so subscribers see one logical
266/// connect/disconnect pair per peer regardless of how many TCP/QUIC
267/// connections the swarm multiplexes underneath. `Disconnected` fires only
268/// when the last connection drops (`num_established == 0`).
269///
270/// This is the canonical 2026 libp2p pattern (Lighthouse `SyncManager`,
271/// generic `SwarmEvent::ConnectionEstablished`/`ConnectionClosed` routing
272/// via `tokio::sync::mpsc::UnboundedSender`): the network event loop owns
273/// the swarm, fans out lifecycle deltas through unbounded channels, and
274/// subscribers consume them in their own `tokio::select!` loop.
275#[derive(Debug, Clone)]
276pub enum PeerEvent {
277 /// First physical connection to `peer` was established.
278 Connected(PeerId),
279 /// Last physical connection to `peer` was closed.
280 Disconnected(PeerId),
281}
282
283/// Transport-level failure modes for an outbound block-sync request.
284///
285/// These are libp2p-layer errors (timeout, connection closed, codec
286/// failure). Server-side application errors are carried inside the
287/// `BlockSyncResponse::Error` variant on a successful round-trip and do
288/// NOT surface here — callers must inspect `result.ok()` for those.
289#[derive(Debug, thiserror::Error)]
290pub enum BlockSyncOutboundError {
291 #[error("dial failure")]
292 DialFailure,
293 #[error("request timed out")]
294 Timeout,
295 #[error("connection closed")]
296 ConnectionClosed,
297 #[error("remote does not speak the block-sync protocol")]
298 UnsupportedProtocols,
299 #[error("io error: {0}")]
300 Io(String),
301}
302
303impl From<request_response::OutboundFailure> for BlockSyncOutboundError {
304 fn from(e: request_response::OutboundFailure) -> Self {
305 match e {
306 request_response::OutboundFailure::DialFailure => Self::DialFailure,
307 request_response::OutboundFailure::Timeout => Self::Timeout,
308 request_response::OutboundFailure::ConnectionClosed => Self::ConnectionClosed,
309 request_response::OutboundFailure::UnsupportedProtocols => Self::UnsupportedProtocols,
310 request_response::OutboundFailure::Io(io) => Self::Io(io.to_string()),
311 }
312 }
313}
314
315/// Network service trait
316#[async_trait]
317pub trait NetworkService: Send + Sync {
318 /// Broadcasts a message to all peers on a topic
319 async fn broadcast(&self, topic: &str, message: NetworkMessage) -> Result<()>;
320
321 /// Sends a message to a specific peer
322 async fn send_to(&self, peer_id: PeerId, message: NetworkMessage) -> Result<()>;
323
324 /// Subscribes to a topic and returns a receiver for messages
325 async fn subscribe(&self, topic: &str) -> Result<mpsc::UnboundedReceiver<NetworkMessage>>;
326
327 /// Gets the list of connected peers
328 async fn connected_peers(&self) -> Result<Vec<PeerId>>;
329
330 /// Gets information about a specific peer
331 async fn peer_info(&self, peer_id: &PeerId) -> Result<Option<ManagedPeer>>;
332
333 /// Bans a peer
334 async fn ban_peer(&self, peer_id: &PeerId) -> Result<()>;
335
336 /// Unbans a peer
337 async fn unban_peer(&self, peer_id: &PeerId) -> Result<()>;
338
339 /// Gets the local peer ID
340 async fn local_peer_id(&self) -> Result<PeerId>;
341
342 /// Dials a peer at the given address
343 async fn dial(&self, addr: Multiaddr) -> Result<()>;
344
345 /// Sets the validator registry for peer authorization on validator-only topics
346 async fn set_validator_registry(&self, registry: std::sync::Arc<dyn crate::peer_manager::ValidatorRegistry>) -> Result<()>;
347
348 /// Returns the set of multiaddrs the swarm is currently listening on.
349 ///
350 /// Useful for tests and bootstrap scenarios where the listen port is
351 /// auto-assigned (e.g., `/ip4/127.0.0.1/tcp/0`) and the caller needs to
352 /// know the bound address to share with peers.
353 async fn listen_addresses(&self) -> Result<Vec<Multiaddr>>;
354
355 /// Sends a HotStuff-2 `ConsensusMessage` directly to every currently-
356 /// admitted validator over the `consensus-direct` request-response
357 /// overlay.
358 ///
359 /// Replaces the gossipsub `tenzro/consensus` publish path. The local
360 /// `ValidatorRegistry` snapshot is taken at send time; the message is
361 /// fanned out one request per peer. Per-peer transport failures are
362 /// logged but do NOT fail the call — quorum is the consumer's
363 /// responsibility, not the transport's.
364 ///
365 /// Returns `Ok(usize)` with the number of peers the request was
366 /// successfully dispatched to (in-flight; not yet acknowledged).
367 /// Returns `Err` only if no validator registry is installed or if
368 /// the validator set is empty (single-node operation; nothing to do).
369 async fn broadcast_to_validators(&self, message: ConsensusMessage) -> Result<usize>;
370
371 /// Returns the count of currently-connected peers that are also
372 /// admitted to the local validator registry.
373 ///
374 /// This is the warm-up gate for first-publish on the consensus-direct
375 /// overlay: a non-zero count means at least one validator peer is
376 /// reachable AND admitted, so `broadcast_to_validators` will dispatch
377 /// to at least one wire-bound stream.
378 ///
379 /// Returns 0 if no validator registry is installed (single-node /
380 /// pre-genesis operation).
381 async fn connected_validator_count(&self) -> Result<usize>;
382
383 /// Subscribes to inbound `ConsensusMessage`s arriving over the
384 /// `consensus-direct` overlay. Replaces the consensus-side
385 /// `subscribe("tenzro/consensus")` path.
386 ///
387 /// Only one subscriber is supported at a time — calling twice replaces
388 /// the previous channel. The subscriber MUST drain the receiver
389 /// continuously; if the channel is full or dropped, the event loop
390 /// responds to the sending peer with `ConsensusDirectError::NoSubscriber`,
391 /// surfacing the back-pressure to the consensus engine on the other side.
392 async fn subscribe_consensus_direct(
393 &self,
394 ) -> Result<mpsc::UnboundedReceiver<ConsensusMessage>>;
395
396 /// Installs the DID-to-PeerId resolver used by the MPC relay overlay.
397 ///
398 /// The resolver is consulted on every outbound `send_mpc_relay_message`
399 /// (to find the PeerId for `to_did`) and on every inbound MPC request
400 /// (to audit that `from_did` is in fact controlled by the sending peer).
401 /// A missing resolver causes outbound sends to fail with
402 /// `NetworkError::InvalidConfig` and inbound messages to be rejected
403 /// with `MpcRelayError::UnknownSender` — the relay is fail-closed.
404 ///
405 /// Idempotent — calling twice replaces the prior resolver.
406 async fn set_mpc_did_resolver(
407 &self,
408 resolver: std::sync::Arc<dyn crate::mpc_relay::MpcDidResolver>,
409 ) -> Result<()>;
410
411 /// Sends one MPC round message to the peer that controls `to_did`.
412 ///
413 /// Resolves `to_did` through the installed `MpcDidResolver`; returns
414 /// `Err(NetworkError::PeerNotFound)` if the DID does not resolve. The
415 /// returned `Ok(())` means the request was handed to the libp2p
416 /// request-response codec — not that the peer has acknowledged it. The
417 /// MPC session driver on the other side will reply `Ack`/`Error`
418 /// asynchronously; transport-level failures (peer unreachable, timeout)
419 /// surface through the codec's `OutboundFailure` event and are logged
420 /// by the event loop. The bridge-side session driver's per-message
421 /// receive timer is the authoritative end-to-end deadline.
422 async fn send_mpc_relay_message(
423 &self,
424 message: crate::mpc_relay::MpcRelayRequest,
425 ) -> Result<()>;
426
427 /// Subscribes to inbound MPC relay round messages.
428 ///
429 /// Only one subscriber is supported at a time — calling twice replaces
430 /// the previous channel. The subscriber MUST drain the receiver
431 /// continuously; if the channel is full or dropped, the event loop
432 /// responds to the sending peer with `MpcRelayError::NoSubscriber` so
433 /// the sender's session driver can fast-fail and abort the session
434 /// rather than wait for a per-round timeout. The receiver delivers
435 /// `MpcRelayRequest` envelopes that have already been audited
436 /// (`from_did` ↔ peer-ID match verified against the installed DID
437 /// resolver) — the consumer can trust the `from_did` field.
438 async fn subscribe_mpc_relay(
439 &self,
440 ) -> Result<mpsc::UnboundedReceiver<crate::mpc_relay::MpcRelayRequest>>;
441}
442
443/// Commands sent to the network service
444#[allow(clippy::large_enum_variant)]
445enum NetworkCommand {
446 Broadcast {
447 topic: String,
448 message: NetworkMessage,
449 response: oneshot::Sender<Result<()>>,
450 },
451 Subscribe {
452 topic: String,
453 response: oneshot::Sender<Result<mpsc::UnboundedReceiver<NetworkMessage>>>,
454 },
455 ConnectedPeers {
456 response: oneshot::Sender<Result<Vec<PeerId>>>,
457 },
458 PeerInfo {
459 peer_id: PeerId,
460 response: oneshot::Sender<Result<Option<ManagedPeer>>>,
461 },
462 BanPeer {
463 peer_id: PeerId,
464 response: oneshot::Sender<Result<()>>,
465 },
466 UnbanPeer {
467 peer_id: PeerId,
468 response: oneshot::Sender<Result<()>>,
469 },
470 LocalPeerId {
471 response: oneshot::Sender<Result<PeerId>>,
472 },
473 Dial {
474 addr: Multiaddr,
475 response: oneshot::Sender<Result<()>>,
476 },
477 SetValidatorRegistry {
478 registry: std::sync::Arc<dyn crate::peer_manager::ValidatorRegistry>,
479 response: oneshot::Sender<Result<()>>,
480 },
481 /// Returns the current count of gossipsub mesh peers for `topic`.
482 /// Used by the mesh warm-up gate before consensus first publish.
483 MeshPeerCount {
484 topic: String,
485 response: oneshot::Sender<Result<usize>>,
486 },
487 /// Returns the multiaddrs currently bound by the swarm's listeners.
488 /// Required when the configured listen port is 0 (OS-assigned) and the
489 /// caller needs to discover the actual bound address.
490 ListenAddresses {
491 response: oneshot::Sender<Result<Vec<Multiaddr>>>,
492 },
493 /// Returns the count of gossipsub mesh peers for `topic` that are ALSO
494 /// admitted to the local validator registry. Used by the admitted-mesh
495 /// gate to ensure first-publish on validator-only topics only fires
496 /// after identify has admitted enough peers — otherwise messages are
497 /// dropped on receipt by `authorize_peer_for_topic`.
498 ///
499 /// If no validator registry is installed, falls back to the plain mesh
500 /// peer count (permissive mode).
501 AdmittedMeshPeers {
502 topic: String,
503 response: oneshot::Sender<Result<usize>>,
504 },
505 /// Initiates an outbound block-sync request to `peer`. Returns the
506 /// `OutboundRequestId` synchronously; the response (or transport
507 /// failure) is delivered later through the channel registered with
508 /// `SubscribeBlockSyncResults`.
509 SendBlockSyncRequest {
510 peer: PeerId,
511 request: BlockSyncRequest,
512 response: oneshot::Sender<Result<OutboundRequestId>>,
513 },
514 /// Sends a block-sync response back to the peer that issued the
515 /// inbound request identified by `request_id`. The corresponding
516 /// `ResponseChannel` is held inside the event loop; if it has been
517 /// dropped (peer disconnected, stream timed out), the send returns
518 /// `Err(NetworkError::PeerNotFound)`.
519 SendBlockSyncResponse {
520 request_id: InboundRequestId,
521 response_payload: BlockSyncResponse,
522 response: oneshot::Sender<Result<()>>,
523 },
524 /// Subscribes to inbound block-sync requests. Only one subscriber is
525 /// supported at a time — calling twice replaces the previous channel.
526 /// Used by the node-level block-sync server to receive
527 /// `GetTipInfo` / `GetBlockRange` / `GetBlockByHash` from peers.
528 SubscribeBlockSyncRequests {
529 response: oneshot::Sender<Result<mpsc::UnboundedReceiver<InboundBlockSync>>>,
530 },
531 /// Subscribes to outbound block-sync results. Only one subscriber is
532 /// supported at a time. Used by the node-level block-sync engine to
533 /// correlate `OutboundRequestId`s returned by `SendBlockSyncRequest`
534 /// with the eventual peer response or transport error.
535 SubscribeBlockSyncResults {
536 response: oneshot::Sender<Result<mpsc::UnboundedReceiver<OutboundBlockSyncResult>>>,
537 },
538 /// Subscribes to peer connection lifecycle events. Only one subscriber
539 /// is supported at a time — calling twice replaces the previous
540 /// channel. Consumed by the block-sync engine to learn which peers it
541 /// can probe for tip info; future peer-aware subsystems (gossip
542 /// flooders, mesh-warmup gates) attach to the same stream.
543 SubscribePeerEvents {
544 response: oneshot::Sender<Result<mpsc::UnboundedReceiver<PeerEvent>>>,
545 },
546 /// Fans out a HotStuff-2 `ConsensusMessage` over the consensus-direct
547 /// request-response overlay to every validator currently admitted to
548 /// the local registry. Returns the number of peers the request was
549 /// dispatched to.
550 BroadcastToValidators {
551 message: ConsensusMessage,
552 response: oneshot::Sender<Result<usize>>,
553 },
554 /// Returns the count of currently-connected peers that are also
555 /// admitted to the local validator registry. Used by the consensus
556 /// engine warm-up gate before the first `BroadcastToValidators`.
557 ConnectedValidatorCount {
558 response: oneshot::Sender<Result<usize>>,
559 },
560 /// Subscribes to inbound consensus-direct messages. One subscriber per
561 /// node — calling twice replaces the previous channel.
562 SubscribeConsensusDirect {
563 response: oneshot::Sender<Result<mpsc::UnboundedReceiver<ConsensusMessage>>>,
564 },
565 /// Installs the DID-to-PeerId resolver used by the MPC relay overlay.
566 /// Idempotent — calling twice replaces the prior resolver.
567 SetMpcDidResolver {
568 resolver: std::sync::Arc<dyn crate::mpc_relay::MpcDidResolver>,
569 response: oneshot::Sender<Result<()>>,
570 },
571 /// Dispatches one MPC round message to the peer that controls
572 /// `message.to_did`. The `to_did` is resolved through the installed
573 /// `MpcDidResolver`; failure to resolve returns `PeerNotFound`.
574 SendMpcRelayMessage {
575 message: crate::mpc_relay::MpcRelayRequest,
576 response: oneshot::Sender<Result<()>>,
577 },
578 /// Subscribes to inbound MPC relay round messages. One subscriber per
579 /// node — calling twice replaces the previous channel.
580 SubscribeMpcRelay {
581 response: oneshot::Sender<
582 Result<mpsc::UnboundedReceiver<crate::mpc_relay::MpcRelayRequest>>,
583 >,
584 },
585 Shutdown {
586 response: oneshot::Sender<Result<()>>,
587 },
588}
589
590/// Implementation of NetworkService for Tenzro Network
591pub struct TenzroNetworkService {
592 command_tx: mpsc::UnboundedSender<NetworkCommand>,
593 /// Prometheus metrics bundle — exposed for `/metrics` endpoint.
594 metrics: Arc<NetworkMetrics>,
595 /// Metrics registry — exposed so the node can add its own subsystems
596 /// and serialize all metrics to the Prometheus text format.
597 metrics_registry: Arc<Mutex<Registry>>,
598}
599
600impl TenzroNetworkService {
601 /// Creates a new network service with a fresh metrics registry.
602 pub async fn new(config: NetworkConfig) -> Result<Self> {
603 let mut registry = Registry::default();
604 let metrics = NetworkMetrics::register(&mut registry);
605 Self::new_with_registry(config, Arc::new(Mutex::new(registry)), metrics).await
606 }
607
608 /// Creates a new network service using a caller-provided metrics registry.
609 /// Use this when the node already has a shared Prometheus registry that
610 /// aggregates metrics from consensus, storage, VM, etc.
611 pub async fn new_with_registry(
612 config: NetworkConfig,
613 metrics_registry: Arc<Mutex<Registry>>,
614 metrics: Arc<NetworkMetrics>,
615 ) -> Result<Self> {
616 // Validate configuration
617 config.validate()?;
618
619 let (command_tx, command_rx) = mpsc::unbounded_channel();
620 let loop_metrics = metrics.clone();
621
622 // Spawn the event loop
623 tokio::spawn(async move {
624 if let Err(e) = run_event_loop(config, command_rx, loop_metrics).await {
625 tracing::error!("Network event loop error: {}", e);
626 }
627 });
628
629 Ok(Self {
630 command_tx,
631 metrics,
632 metrics_registry,
633 })
634 }
635
636 /// Returns a handle to the Prometheus metrics bundle for external
637 /// instrumentation (e.g., RPC call counters that live outside the
638 /// networking layer can be incremented through this handle).
639 pub fn metrics(&self) -> Arc<NetworkMetrics> {
640 self.metrics.clone()
641 }
642
643 /// Returns the shared metrics registry — useful for callers that need
644 /// to register additional metric families or serialize the registry to
645 /// the Prometheus text format.
646 pub fn metrics_registry(&self) -> Arc<Mutex<Registry>> {
647 self.metrics_registry.clone()
648 }
649
650 /// Signals the event loop to shut down gracefully.
651 /// Waits for the loop to drain in-flight commands before returning.
652 pub async fn shutdown(&self) -> Result<()> {
653 self.send_command(|response| NetworkCommand::Shutdown { response }).await
654 }
655
656 /// Returns the current number of gossipsub mesh peers for `topic`.
657 ///
658 /// Used by the mesh warm-up gate (`wait_for_mesh`) to determine when it
659 /// is safe to publish — `Behaviour::publish` returns
660 /// `NoPeersSubscribedToTopic` if the mesh hasn't formed yet (rust-libp2p
661 /// `behaviour.rs:1064`).
662 pub async fn mesh_peer_count(&self, topic: &str) -> Result<usize> {
663 let topic_owned = topic.to_string();
664 self.send_command(move |response| NetworkCommand::MeshPeerCount {
665 topic: topic_owned,
666 response,
667 })
668 .await
669 }
670
671 /// Polls until the gossipsub mesh for `topic` has at least `min_peers`
672 /// members, or `timeout` elapses.
673 ///
674 /// On timeout, returns the last observed count so the caller can decide
675 /// whether to publish anyway (degraded operation) or retry. Polls every
676 /// 100 ms — short enough to not delay startup, long enough to let
677 /// gossipsub heartbeat (700 ms by default) drive at least one mesh
678 /// formation cycle.
679 ///
680 /// Workaround for rust-libp2p having no built-in `wait_for_mesh()` API
681 /// (issues #2585, #2036, #2557). This is the canonical pattern: poll
682 /// `Behaviour::mesh_peers(&topic).count() >= mesh_n_low` with bounded
683 /// retry before allowing first publish.
684 pub async fn wait_for_mesh(
685 &self,
686 topic: &str,
687 min_peers: usize,
688 timeout: std::time::Duration,
689 ) -> Result<usize> {
690 let deadline = tokio::time::Instant::now() + timeout;
691 // last_seen tracks the most recent mesh size for the timeout-path log.
692 // Initial 0 covers the case where the very first poll fails before
693 // the assignment in the loop body.
694 #[allow(unused_assignments)]
695 let mut last_seen = 0usize;
696 loop {
697 match self.mesh_peer_count(topic).await {
698 Ok(count) => {
699 last_seen = count;
700 if count >= min_peers {
701 tracing::info!(
702 topic = topic,
703 count = count,
704 min_peers = min_peers,
705 "Gossipsub mesh ready"
706 );
707 return Ok(count);
708 }
709 }
710 Err(e) => {
711 // Service is shut down or command channel closed —
712 // surface immediately, polling won't recover.
713 return Err(e);
714 }
715 }
716 if tokio::time::Instant::now() >= deadline {
717 tracing::warn!(
718 topic = topic,
719 count = last_seen,
720 min_peers = min_peers,
721 "wait_for_mesh timed out — proceeding with degraded mesh"
722 );
723 return Ok(last_seen);
724 }
725 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
726 }
727 }
728
729 /// Returns the count of gossipsub mesh peers for `topic` that are ALSO
730 /// admitted to the local validator registry.
731 ///
732 /// On validator-only topics (`consensus`, `attestations`), inbound
733 /// `authorize_peer_for_topic` rejects messages from peers not in the
734 /// registry. Identify-driven admission (`try_register_validator_on_identify`)
735 /// happens asynchronously after the libp2p connection is up, so a peer
736 /// can be in the gossipsub mesh BEFORE it has been admitted — and any
737 /// message it publishes during that window is silently dropped.
738 ///
739 /// `mesh_peer_count` alone is not a sufficient gate for first-publish
740 /// on validator-only topics; this method intersects mesh peers with
741 /// admitted validators, returning the size of the intersection.
742 ///
743 /// If no validator registry is installed, returns the plain mesh peer
744 /// count (permissive mode — pre-genesis or single-node).
745 pub async fn admitted_mesh_peer_count(&self, topic: &str) -> Result<usize> {
746 let topic_owned = topic.to_string();
747 self.send_command(move |response| NetworkCommand::AdmittedMeshPeers {
748 topic: topic_owned,
749 response,
750 })
751 .await
752 }
753
754 /// Polls until at least `min_admitted` mesh peers on `topic` are also
755 /// admitted to the validator registry, or `timeout` elapses.
756 ///
757 /// This is the correct first-publish gate for validator-only topics on
758 /// a multi-node cluster. Unlike `wait_for_mesh`, which only confirms
759 /// that gossipsub has chosen mesh peers, this confirms that those
760 /// peers will *accept* our messages on validator-only topics.
761 ///
762 /// On timeout, returns the last observed admitted count so the caller
763 /// can decide whether to publish anyway (degraded operation) or retry.
764 ///
765 /// Polls every 100ms — short enough not to delay startup, long enough
766 /// to let identify (one round-trip per peer) complete.
767 pub async fn wait_for_admitted_mesh(
768 &self,
769 topic: &str,
770 min_admitted: usize,
771 timeout: std::time::Duration,
772 ) -> Result<usize> {
773 let deadline = tokio::time::Instant::now() + timeout;
774 #[allow(unused_assignments)]
775 let mut last_seen = 0usize;
776 loop {
777 match self.admitted_mesh_peer_count(topic).await {
778 Ok(count) => {
779 last_seen = count;
780 if count >= min_admitted {
781 tracing::info!(
782 topic = topic,
783 admitted = count,
784 min_admitted = min_admitted,
785 "Admitted mesh ready — first publish safe"
786 );
787 return Ok(count);
788 }
789 }
790 Err(e) => return Err(e),
791 }
792 if tokio::time::Instant::now() >= deadline {
793 tracing::warn!(
794 topic = topic,
795 admitted = last_seen,
796 min_admitted = min_admitted,
797 "wait_for_admitted_mesh timed out — first publish may be silently dropped \
798 by receivers' validator-only topic gate"
799 );
800 return Ok(last_seen);
801 }
802 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
803 }
804 }
805
806 /// Polls until at least `min_validators` peers are both connected at
807 /// the libp2p layer AND admitted to the local validator registry, or
808 /// `timeout` elapses.
809 ///
810 /// This is the warm-up gate for the consensus-direct overlay (#144) —
811 /// it replaces the gossipsub-mesh-based gate that consensus used while
812 /// it was still publishing through gossipsub. Direct request-response
813 /// has no "mesh" — the only meaningful liveness signal is "I have a
814 /// connection open to a validator I'm willing to send to".
815 ///
816 /// On timeout, returns the last observed count so the caller can
817 /// decide whether to proceed in degraded mode (single-node operation,
818 /// pre-genesis) or keep waiting. Polls every 100ms — short enough not
819 /// to delay startup, long enough to let identify-driven admission
820 /// complete on a freshly-dialed peer.
821 pub async fn wait_for_connected_validators(
822 &self,
823 min_validators: usize,
824 timeout: std::time::Duration,
825 ) -> Result<usize> {
826 let deadline = tokio::time::Instant::now() + timeout;
827 #[allow(unused_assignments)]
828 let mut last_seen = 0usize;
829 loop {
830 match self.connected_validator_count().await {
831 Ok(count) => {
832 last_seen = count;
833 if count >= min_validators {
834 tracing::info!(
835 connected_validators = count,
836 min_validators = min_validators,
837 "Connected validators ready — first consensus-direct broadcast safe"
838 );
839 return Ok(count);
840 }
841 }
842 Err(e) => return Err(e),
843 }
844 if tokio::time::Instant::now() >= deadline {
845 tracing::warn!(
846 connected_validators = last_seen,
847 min_validators = min_validators,
848 "wait_for_connected_validators timed out — first \
849 consensus-direct broadcast may dispatch to zero peers"
850 );
851 return Ok(last_seen);
852 }
853 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
854 }
855 }
856
857 // ---------------------------------------------------------------
858 // Block-sync API.
859 //
860 // The wire protocol (`BlockSyncRequest` / `BlockSyncResponse`) is
861 // defined in `crate::block_sync_proto`; see its module docs for the
862 // Sui `state_sync`-derived design rationale.
863 //
864 // Outbound flow:
865 // 1. Caller invokes `request_blocks(peer, start, count)` (or one
866 // of the sibling helpers). It returns an `OutboundRequestId`.
867 // 2. Caller must have previously subscribed to results via
868 // `subscribe_block_sync_results()`. The eventual response or
869 // transport failure arrives on that channel keyed by the same id.
870 //
871 // Inbound flow:
872 // 1. Server-role nodes call `subscribe_block_sync_requests()` once
873 // at startup.
874 // 2. For each `InboundBlockSync` they receive, they MUST eventually
875 // call `send_block_sync_response(request_id, response)` — either
876 // with a real payload or a `BlockSyncResponse::Error(_)`.
877 // Dropping the request silently causes the peer to time out and
878 // score us down (Lighthouse `SyncManager` confirms this is the
879 // right contract).
880 // ---------------------------------------------------------------
881
882 /// Issues an outbound `GetBlockRange` request to `peer`. Returns the
883 /// `OutboundRequestId` synchronously; the response is delivered later
884 /// on the channel from `subscribe_block_sync_results`.
885 pub async fn request_blocks(
886 &self,
887 peer: PeerId,
888 start: tenzro_types::primitives::BlockHeight,
889 count: u32,
890 ) -> Result<OutboundRequestId> {
891 let request = BlockSyncRequest::GetBlockRange { start, count };
892 self.send_command(move |response| NetworkCommand::SendBlockSyncRequest {
893 peer,
894 request,
895 response,
896 })
897 .await
898 }
899
900 /// Issues an outbound `GetTipInfo` probe to `peer`.
901 pub async fn request_tip_info(&self, peer: PeerId) -> Result<OutboundRequestId> {
902 self.send_command(move |response| NetworkCommand::SendBlockSyncRequest {
903 peer,
904 request: BlockSyncRequest::GetTipInfo,
905 response,
906 })
907 .await
908 }
909
910 /// Issues an outbound `GetBlockByHash` request to `peer`. Used during
911 /// fork resolution (parent-lookup walk).
912 pub async fn request_block_by_hash(
913 &self,
914 peer: PeerId,
915 hash: tenzro_types::primitives::Hash,
916 ) -> Result<OutboundRequestId> {
917 self.send_command(move |response| NetworkCommand::SendBlockSyncRequest {
918 peer,
919 request: BlockSyncRequest::GetBlockByHash { hash },
920 response,
921 })
922 .await
923 }
924
925 /// Subscribes to inbound block-sync requests. The server-role node
926 /// reads from this channel, builds the appropriate response, and
927 /// answers via `send_block_sync_response(request_id, …)`.
928 ///
929 /// Calling this twice replaces the previous channel — there is one
930 /// authoritative server consumer per node.
931 pub async fn subscribe_block_sync_requests(
932 &self,
933 ) -> Result<mpsc::UnboundedReceiver<InboundBlockSync>> {
934 self.send_command(|response| NetworkCommand::SubscribeBlockSyncRequests { response })
935 .await
936 }
937
938 /// Subscribes to outbound block-sync results — one item per
939 /// `OutboundRequestId` previously returned by `request_blocks` /
940 /// `request_tip_info` / `request_block_by_hash`. The result is
941 /// either a decoded `BlockSyncResponse` or a typed transport error.
942 pub async fn subscribe_block_sync_results(
943 &self,
944 ) -> Result<mpsc::UnboundedReceiver<OutboundBlockSyncResult>> {
945 self.send_command(|response| NetworkCommand::SubscribeBlockSyncResults { response })
946 .await
947 }
948
949 /// Subscribes to peer connection lifecycle events. The block-sync
950 /// engine consumes this stream to populate its candidate-peer table:
951 /// `Connected` adds the peer, `Disconnected` evicts it.
952 ///
953 /// One subscriber at a time — calling twice replaces the previous
954 /// channel. The stream is unbounded; consumers MUST drain it
955 /// continuously or the network event loop will accumulate buffered
956 /// events. In practice, the consumer's own `tokio::select!` arm pulls
957 /// from this receiver alongside its other duties, which is the
958 /// canonical libp2p subscriber pattern.
959 pub async fn subscribe_peer_events(
960 &self,
961 ) -> Result<mpsc::UnboundedReceiver<PeerEvent>> {
962 self.send_command(|response| NetworkCommand::SubscribePeerEvents { response })
963 .await
964 }
965
966 /// Replies to a previously-received inbound block-sync request.
967 ///
968 /// If the inbound stream has already timed out or the peer has
969 /// disconnected, returns `Err(NetworkError::PeerNotFound)`. Callers
970 /// that want to reject a request should pass
971 /// `BlockSyncResponse::Error(BlockSyncError::*)` rather than dropping
972 /// the request — silent drops cause cascading peer-disconnect spirals.
973 pub async fn send_block_sync_response(
974 &self,
975 request_id: InboundRequestId,
976 response_payload: BlockSyncResponse,
977 ) -> Result<()> {
978 self.send_command(move |response| NetworkCommand::SendBlockSyncResponse {
979 request_id,
980 response_payload,
981 response,
982 })
983 .await
984 }
985
986 /// Sends a command and waits for response
987 async fn send_command<F, T>(&self, f: F) -> Result<T>
988 where
989 F: FnOnce(oneshot::Sender<Result<T>>) -> NetworkCommand,
990 {
991 let (tx, rx) = oneshot::channel();
992 let command = f(tx);
993
994 self.command_tx
995 .send(command)
996 .map_err(|_| NetworkError::ChannelSend)?;
997
998 rx.await.map_err(|_| NetworkError::ChannelReceive)?
999 }
1000}
1001
1002#[async_trait]
1003impl NetworkService for TenzroNetworkService {
1004 async fn broadcast(&self, topic: &str, message: NetworkMessage) -> Result<()> {
1005 self.send_command(|response| NetworkCommand::Broadcast {
1006 topic: topic.to_string(),
1007 message,
1008 response,
1009 })
1010 .await
1011 }
1012
1013 async fn send_to(&self, peer_id: PeerId, message: NetworkMessage) -> Result<()> {
1014 // Route direct peer messages through gossipsub by wrapping the message in a
1015 // Custom payload on the tenzro/direct topic. The payload encodes both
1016 // the target peer ID and the inner message so that subscribers can filter
1017 // messages intended for them. This reuses the already-established gossipsub
1018 // mesh without requiring a separate request-response protocol.
1019 let inner_bytes = message
1020 .to_bytes()
1021 .map_err(NetworkError::Serialization)?
1022 .to_vec();
1023
1024 // Encode: [32-byte peer id multihash] || [inner message bytes]
1025 // Using the peer's multihash digest (first 32 bytes) as the addressing prefix.
1026 let peer_multihash = peer_id.to_bytes();
1027 let mut payload = Vec::with_capacity(peer_multihash.len() + inner_bytes.len());
1028 payload.extend_from_slice(&peer_multihash);
1029 payload.extend_from_slice(&inner_bytes);
1030
1031 let direct_message = NetworkMessage::new(MessagePayload::Custom {
1032 topic: "tenzro/direct".to_string(),
1033 data: payload,
1034 });
1035
1036 self.send_command(|response| NetworkCommand::Broadcast {
1037 topic: "tenzro/direct".to_string(),
1038 message: direct_message,
1039 response,
1040 })
1041 .await
1042 }
1043
1044 async fn subscribe(&self, topic: &str) -> Result<mpsc::UnboundedReceiver<NetworkMessage>> {
1045 self.send_command(|response| NetworkCommand::Subscribe {
1046 topic: topic.to_string(),
1047 response,
1048 })
1049 .await
1050 }
1051
1052 async fn connected_peers(&self) -> Result<Vec<PeerId>> {
1053 self.send_command(|response| NetworkCommand::ConnectedPeers { response })
1054 .await
1055 }
1056
1057 async fn peer_info(&self, peer_id: &PeerId) -> Result<Option<ManagedPeer>> {
1058 self.send_command(|response| NetworkCommand::PeerInfo {
1059 peer_id: *peer_id,
1060 response,
1061 })
1062 .await
1063 }
1064
1065 async fn ban_peer(&self, peer_id: &PeerId) -> Result<()> {
1066 self.send_command(|response| NetworkCommand::BanPeer {
1067 peer_id: *peer_id,
1068 response,
1069 })
1070 .await
1071 }
1072
1073 async fn unban_peer(&self, peer_id: &PeerId) -> Result<()> {
1074 self.send_command(|response| NetworkCommand::UnbanPeer {
1075 peer_id: *peer_id,
1076 response,
1077 })
1078 .await
1079 }
1080
1081 async fn local_peer_id(&self) -> Result<PeerId> {
1082 self.send_command(|response| NetworkCommand::LocalPeerId { response })
1083 .await
1084 }
1085
1086 async fn dial(&self, addr: Multiaddr) -> Result<()> {
1087 self.send_command(|response| NetworkCommand::Dial { addr, response })
1088 .await
1089 }
1090
1091 async fn set_validator_registry(&self, registry: std::sync::Arc<dyn crate::peer_manager::ValidatorRegistry>) -> Result<()> {
1092 self.send_command(|response| NetworkCommand::SetValidatorRegistry { registry, response })
1093 .await
1094 }
1095
1096 async fn listen_addresses(&self) -> Result<Vec<Multiaddr>> {
1097 self.send_command(|response| NetworkCommand::ListenAddresses { response })
1098 .await
1099 }
1100
1101 async fn broadcast_to_validators(&self, message: ConsensusMessage) -> Result<usize> {
1102 self.send_command(move |response| NetworkCommand::BroadcastToValidators {
1103 message,
1104 response,
1105 })
1106 .await
1107 }
1108
1109 async fn subscribe_consensus_direct(
1110 &self,
1111 ) -> Result<mpsc::UnboundedReceiver<ConsensusMessage>> {
1112 self.send_command(|response| NetworkCommand::SubscribeConsensusDirect { response })
1113 .await
1114 }
1115
1116 async fn connected_validator_count(&self) -> Result<usize> {
1117 self.send_command(|response| NetworkCommand::ConnectedValidatorCount { response })
1118 .await
1119 }
1120
1121 async fn set_mpc_did_resolver(
1122 &self,
1123 resolver: std::sync::Arc<dyn crate::mpc_relay::MpcDidResolver>,
1124 ) -> Result<()> {
1125 self.send_command(|response| NetworkCommand::SetMpcDidResolver { resolver, response })
1126 .await
1127 }
1128
1129 async fn send_mpc_relay_message(
1130 &self,
1131 message: crate::mpc_relay::MpcRelayRequest,
1132 ) -> Result<()> {
1133 self.send_command(move |response| NetworkCommand::SendMpcRelayMessage {
1134 message,
1135 response,
1136 })
1137 .await
1138 }
1139
1140 async fn subscribe_mpc_relay(
1141 &self,
1142 ) -> Result<mpsc::UnboundedReceiver<crate::mpc_relay::MpcRelayRequest>> {
1143 self.send_command(|response| NetworkCommand::SubscribeMpcRelay { response })
1144 .await
1145 }
1146}
1147
1148/// Event loop state
1149struct EventLoopState {
1150 swarm: Swarm<TenzroBehaviour>,
1151 peer_manager: PeerManager,
1152 subscribers: HashMap<TopicHash, Vec<mpsc::UnboundedSender<NetworkMessage>>>,
1153 /// Application-level message deduplicator (defense-in-depth over gossipsub's built-in dedup)
1154 deduplicator: MessageDeduplicator,
1155 /// Prometheus metrics bundle (shared across the event loop and the service handle).
1156 metrics: Arc<NetworkMetrics>,
1157 /// Multiaddrs bound by the swarm's listeners. Populated from
1158 /// `SwarmEvent::NewListenAddr` and removed on `ExpiredListenAddr`.
1159 /// Surfaced via `NetworkCommand::ListenAddresses` so callers (especially
1160 /// tests using port 0) can discover the bound address.
1161 listen_addresses: Vec<Multiaddr>,
1162 /// Pending inbound block-sync response channels keyed by `InboundRequestId`.
1163 /// The `ResponseChannel<BlockSyncResponse>` returned by libp2p's
1164 /// request-response codec cannot be sent across an mpsc — it is `Send`
1165 /// but consuming it requires `&mut self` on the behaviour. We park it
1166 /// here until the API caller answers via `SendBlockSyncResponse`.
1167 pending_inbound_block_sync: HashMap<InboundRequestId, ResponseChannel<BlockSyncResponse>>,
1168 /// Subscriber channel for inbound block-sync requests. `None` until the
1169 /// node-level block-sync server attaches via `SubscribeBlockSyncRequests`.
1170 block_sync_request_subscriber: Option<mpsc::UnboundedSender<InboundBlockSync>>,
1171 /// Latched once `SubscribeBlockSyncRequests` runs. Used to distinguish
1172 /// "no subscriber yet" (legitimate bootstrap window before the event
1173 /// loop spawns the block-sync engine — log at debug) from "subscriber
1174 /// dropped after attach" (the genuinely anomalous channel-closed case —
1175 /// log at warn).
1176 block_sync_request_subscriber_ever_attached: bool,
1177 /// Subscriber channel for outbound block-sync results. `None` until the
1178 /// node-level block-sync engine attaches via `SubscribeBlockSyncResults`.
1179 block_sync_result_subscriber: Option<mpsc::UnboundedSender<OutboundBlockSyncResult>>,
1180 /// Subscriber channel for peer connection lifecycle events. `None` until
1181 /// the block-sync engine (or another peer-aware consumer) attaches via
1182 /// `SubscribePeerEvents`. The event loop fans `SwarmEvent::Connection*`
1183 /// transitions through this channel — first physical connection emits
1184 /// `Connected`, last drop emits `Disconnected`.
1185 peer_event_subscriber: Option<mpsc::UnboundedSender<PeerEvent>>,
1186 /// Subscriber channel for inbound consensus-direct messages. `None` until
1187 /// the node-level consensus engine attaches via `SubscribeConsensusDirect`.
1188 /// Inbound requests arriving while this is `None` are answered with
1189 /// `ConsensusDirectError::NoSubscriber` so the sender stops retrying
1190 /// against this peer.
1191 consensus_direct_subscriber: Option<mpsc::UnboundedSender<ConsensusMessage>>,
1192 /// Latched once `SubscribeConsensusDirect` runs. Same rationale as
1193 /// `block_sync_request_subscriber_ever_attached` — separates the
1194 /// bootstrap-pre-attach debug case from the dropped-channel warn case.
1195 consensus_direct_subscriber_ever_attached: bool,
1196 /// Per-peer count of currently-in-flight inbound consensus-direct
1197 /// streams. Used to enforce `MAX_INBOUND_STREAMS_PER_PEER` and reject
1198 /// overflow with `ConsensusDirectError::ServerBusy` rather than queueing.
1199 /// Decremented on `ResponseSent` / `InboundFailure`.
1200 consensus_direct_inbound_inflight: HashMap<PeerId, usize>,
1201 /// Subscriber channel for inbound MPC relay round messages. `None` until
1202 /// the node-level MPC adapter attaches via `SubscribeMpcRelay`. Inbound
1203 /// requests arriving while this is `None` are answered with
1204 /// `MpcRelayError::NoSubscriber`.
1205 mpc_relay_subscriber: Option<mpsc::UnboundedSender<crate::mpc_relay::MpcRelayRequest>>,
1206 /// Latched once `SubscribeMpcRelay` runs. Same rationale as
1207 /// `consensus_direct_subscriber_ever_attached`.
1208 mpc_relay_subscriber_ever_attached: bool,
1209 /// Per-peer count of currently-in-flight inbound MPC relay streams.
1210 /// Enforces `mpc_relay::MAX_INBOUND_STREAMS_PER_PEER`; overflow is
1211 /// rejected with `MpcRelayError::ServerBusy`. Decremented on
1212 /// `ResponseSent` / `InboundFailure`.
1213 mpc_relay_inbound_inflight: HashMap<PeerId, usize>,
1214 /// DID-to-PeerId resolver for the MPC relay overlay. `None` until the
1215 /// node-level adapter installs it via `SetMpcDidResolver`. Missing
1216 /// resolver causes outbound sends to fail and inbound messages to be
1217 /// rejected with `MpcRelayError::UnknownSender` — the relay is
1218 /// fail-closed.
1219 mpc_did_resolver: Option<std::sync::Arc<dyn crate::mpc_relay::MpcDidResolver>>,
1220 /// Tally of `observed_addr` reports received via Identify, keyed by the
1221 /// reported external multiaddr → set of distinct peer IDs that have
1222 /// reported it. Once a candidate accumulates reports from at least
1223 /// `OBSERVED_ADDR_CONFIRMATION_THRESHOLD` distinct peers, we promote it
1224 /// via `Swarm::add_external_address` so Identify advertises it on the
1225 /// next exchange. This is the permissionless-NAT-discovery primitive:
1226 /// every node learns its own public address from what its peers see,
1227 /// without any per-deployment `--external-p2p-addr` configuration.
1228 ///
1229 /// The defence against an attacker lying about our address is AutoNAT v2
1230 /// probe-back (registered in `TenzroBehaviour::autonat_client`): a third
1231 /// party cannot fake a server-initiated dial-back to a candidate, so the
1232 /// candidate is only advertised network-wide after AutoNAT confirms it.
1233 /// The Identify observed_addr tally surfaces candidates; AutoNAT gates
1234 /// promotion to confirmed-external.
1235 observed_addrs: HashMap<Multiaddr, HashSet<PeerId>>,
1236 /// External addresses we have already promoted via `add_external_address`
1237 /// to avoid double-promotion when more `observed_addr` reports arrive
1238 /// from additional peers after confirmation.
1239 advertised_external_addrs: HashSet<Multiaddr>,
1240 /// Operator-configured bootstrap multiaddrs, retained for periodic
1241 /// re-dial. Whenever a configured bootstrap peer is not currently
1242 /// connected, the cleanup tick re-issues `Swarm::dial(addr)` from this
1243 /// authoritative ground-truth set — independent of whatever Kademlia /
1244 /// Identify learned about the peer. This is the Lighthouse/Substrate
1245 /// self-healing pattern (`recurring_boot_dial`): bootstrap addresses
1246 /// are the operator's source of truth, and the libp2p peer-store's
1247 /// cached `(ip, ephemeral_port)` from a prior outbound connection
1248 /// must never displace them across a bootstrap restart.
1249 bootstrap_peers: Vec<(PeerId, Multiaddr)>,
1250}
1251
1252/// Number of distinct peers that must independently report the same
1253/// `observed_addr` via Identify before we promote it to an external-address
1254/// candidate. Set to 1 because AutoNAT v2 probe-back is the actual
1255/// reachability gate: Identify gives us a candidate, AutoNAT confirms it
1256/// cryptographically by asking a public peer to dial us back on that
1257/// address. A liar can name any candidate; they cannot fake an AutoNAT
1258/// dial-back from a third-party server. The higher N-distinct-peers value
1259/// (3) used by Substrate is a belt without braces — appropriate when
1260/// AutoNAT is not in the stack. Our stack has both, so the lower threshold
1261/// is correct and necessary: a freshly-joined node typically has 1 peer at
1262/// bootstrap (the seed) and cannot reach N=3 distinct reporters until it
1263/// is already meshed, which is the very problem this primitive is meant
1264/// to solve.
1265const OBSERVED_ADDR_CONFIRMATION_THRESHOLD: usize = 1;
1266
1267/// Main event loop for the network service
1268async fn run_event_loop(
1269 config: NetworkConfig,
1270 mut command_rx: mpsc::UnboundedReceiver<NetworkCommand>,
1271 metrics: Arc<NetworkMetrics>,
1272) -> Result<()> {
1273 // Load or generate keypair (persistent for stable peer IDs)
1274 let local_key = load_or_generate_keypair(&config.data_dir)?;
1275 let local_peer_id = PeerId::from(local_key.public());
1276
1277 tracing::info!("Local peer ID: {}", local_peer_id);
1278
1279 // Swarm construction via `SwarmBuilder` — required for `with_relay_client()`
1280 // which both adds the relay-client behaviour AND wraps the transport so
1281 // that `/p2p-circuit` multiaddrs become dialable. The pre-existing
1282 // hand-rolled `Swarm::new(transport, behaviour, ...)` path can't express
1283 // that wrap, which is why the migration is needed for #132.
1284 //
1285 // Composition order (libp2p 0.56 docs):
1286 // identity → tokio → tcp(TLS+Yamux) → quic → dns → relay_client → behaviour → swarm_config
1287 //
1288 // The TCP transport keeps `libp2p-tls` (rustls + aws-lc-rs, PQ-hybrid
1289 // X25519MLKEM768) — `with_tcp()` accepts the same `libp2p::tls::Config`
1290 // constructor that `transport::build_transport()` used previously. The
1291 // QUIC half is similarly inherited from the SwarmBuilder.
1292 //
1293 // `with_relay_client()` is unconditional here even when the role's
1294 // `enable_hole_punching` is false: the cost of installing the transport
1295 // wrapper is negligible and keeping it always-on lets a future config
1296 // change re-enable hole punching without touching the swarm topology.
1297 // The `relay_client` behaviour half is then conditionally toggled
1298 // on/off inside `TenzroBehaviour::new()`.
1299 let enable_relay = config.enable_relay;
1300 let enable_hole_punching = config.enable_hole_punching;
1301 let protocol_version = config.protocol_version.clone();
1302 let user_agent = config.user_agent.clone();
1303 let idle_timeout = config.connection_idle_timeout;
1304
1305 let mut swarm = libp2p::SwarmBuilder::with_existing_identity(local_key.clone())
1306 .with_tokio()
1307 .with_tcp(
1308 libp2p::tcp::Config::default().nodelay(true),
1309 libp2p::tls::Config::new,
1310 libp2p::yamux::Config::default,
1311 )
1312 .map_err(|e| NetworkError::Transport(format!("TCP/TLS upgrade failed: {}", e)))?
1313 .with_quic()
1314 .with_dns()
1315 .map_err(|e| NetworkError::Transport(format!("DNS transport failed: {}", e)))?
1316 .with_relay_client(libp2p::tls::Config::new, libp2p::yamux::Config::default)
1317 .map_err(|e| NetworkError::Transport(format!("Relay client transport failed: {}", e)))?
1318 .with_behaviour(|key, relay_client| {
1319 // libp2p's `TryIntoBehaviour` impl requires
1320 // `Result<B, Box<dyn std::error::Error + Send + Sync>>`.
1321 // `TenzroBehaviour::new` returns the non-Send/Sync variant, so
1322 // re-box the error here. The boxed error is later surfaced
1323 // through `NetworkError::Transport` at the `?` site below.
1324 TenzroBehaviour::new(
1325 local_peer_id,
1326 key,
1327 protocol_version,
1328 user_agent,
1329 enable_relay,
1330 enable_hole_punching,
1331 Some(relay_client),
1332 )
1333 .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
1334 e.to_string().into()
1335 })
1336 })
1337 .map_err(|e| NetworkError::Transport(format!("Behaviour construction failed: {}", e)))?
1338 .with_swarm_config(|cfg| cfg.with_idle_connection_timeout(idle_timeout))
1339 .build();
1340
1341 // Listen on configured addresses
1342 for addr in &config.listen_addresses {
1343 swarm
1344 .listen_on(addr.clone())
1345 .map_err(|e| NetworkError::Transport(format!("Failed to listen on {}: {}", addr, e)))?;
1346 tracing::info!("Listening on {}", addr);
1347 }
1348
1349 // Register statically-configured external addresses with the swarm.
1350 // These are the ONLY addresses Identify will advertise to peers (because
1351 // `hide_listen_addrs(true)` is set on the identify config) — see the
1352 // long-form rationale in `behaviour.rs::TenzroBehaviour::new`. On cloud
1353 // deployments where the node knows its own public IP at boot (validator
1354 // VMs in our GCE testnet, for example) this is the cleanest way to
1355 // prevent the docker0 / loopback / VPC-only addresses from being
1356 // advertised. For nodes behind NAT, leave this empty and let AutoNAT v2
1357 // confirm an external address dynamically.
1358 for addr in &config.external_addresses {
1359 if !is_globally_routable(addr) {
1360 tracing::warn!(
1361 "Refusing to advertise non-routable external address {} — \
1362 check NetworkConfig::external_addresses",
1363 addr
1364 );
1365 continue;
1366 }
1367 swarm.add_external_address(addr.clone());
1368 tracing::info!("Advertising external address {}", addr);
1369 }
1370 // Snapshot static external addresses so the observed_addr promotion path
1371 // below doesn't re-promote them — see `EventLoopState::advertised_external_addrs`.
1372 let preconfigured_external: HashSet<Multiaddr> = config
1373 .external_addresses
1374 .iter()
1375 .filter(|a| is_globally_routable(a))
1376 .cloned()
1377 .collect();
1378
1379 // Create peer manager. No longer needs `mut` — `add_protected_peer`
1380 // takes `&self` now that `protected_peers` is a lock-free `DashSet`.
1381 let peer_manager = PeerManager::new(
1382 (config.max_inbound_peers + config.max_outbound_peers) as usize,
1383 );
1384
1385 // Register boot node peers as protected (never auto-ban). We do NOT add
1386 // them as gossipsub `explicit_peers` — that classification causes mutual
1387 // GRAFT rejection in libp2p-gossipsub (see behaviour.rs:1400-1406:
1388 // "GRAFT: ignoring request from direct peer"), preventing the mesh from
1389 // ever forming between boot nodes. The publish-coverage guarantee we
1390 // need is already provided by `flood_publish(true)` on the gossipsub
1391 // config, which sends to every subscribed peer regardless of mesh
1392 // membership or peer score.
1393 for addr in &config.boot_nodes {
1394 for proto in addr.iter() {
1395 if let libp2p::multiaddr::Protocol::P2p(peer_id) = proto {
1396 peer_manager.add_protected_peer(peer_id);
1397 tracing::info!(
1398 peer = %peer_id,
1399 "Registered boot node as protected peer"
1400 );
1401 }
1402 }
1403 }
1404
1405 // Subscribe to initial topics
1406 for topic_str in &config.gossip_topics {
1407 let topic = IdentTopic::new(topic_str.as_str());
1408 if let Err(e) = swarm.behaviour_mut().subscribe(&topic) {
1409 tracing::warn!("Failed to subscribe to topic {}: {}", topic_str, e);
1410 } else {
1411 tracing::info!("Subscribed to topic: {}", topic_str);
1412 }
1413 }
1414
1415 // Parse `(PeerId, Multiaddr)` pairs out of the configured bootstrap
1416 // multiaddrs exactly once. Used for (a) the one-shot Kademlia bootstrap,
1417 // (b) the initial dial round below, and (c) the periodic re-dial sweep
1418 // stored on `EventLoopState::bootstrap_peers`.
1419 let bootstrap_peers: Vec<(PeerId, Multiaddr)> = config
1420 .boot_nodes
1421 .iter()
1422 .filter_map(|addr| {
1423 addr.iter()
1424 .find_map(|proto| {
1425 if let libp2p::multiaddr::Protocol::P2p(peer_id) = proto {
1426 Some(peer_id)
1427 } else {
1428 None
1429 }
1430 })
1431 .map(|peer_id| (peer_id, addr.clone()))
1432 })
1433 .collect();
1434
1435 // Bootstrap DHT if enabled
1436 if config.enable_dht && !bootstrap_peers.is_empty() {
1437 crate::discovery::bootstrap_dht(
1438 &mut swarm.behaviour_mut().kademlia,
1439 bootstrap_peers.clone(),
1440 );
1441 }
1442
1443 // Dial boot nodes
1444 for addr in &config.boot_nodes {
1445 if let Err(e) = swarm.dial(addr.clone()) {
1446 tracing::warn!("Failed to dial boot node {}: {}", addr, e);
1447 } else {
1448 tracing::info!("Dialing boot node: {}", addr);
1449 }
1450 }
1451
1452 let mut state = EventLoopState {
1453 swarm,
1454 peer_manager,
1455 subscribers: HashMap::new(),
1456 deduplicator: MessageDeduplicator::default(),
1457 metrics,
1458 listen_addresses: Vec::new(),
1459 pending_inbound_block_sync: HashMap::new(),
1460 block_sync_request_subscriber: None,
1461 block_sync_request_subscriber_ever_attached: false,
1462 block_sync_result_subscriber: None,
1463 peer_event_subscriber: None,
1464 consensus_direct_subscriber: None,
1465 consensus_direct_subscriber_ever_attached: false,
1466 consensus_direct_inbound_inflight: HashMap::new(),
1467 mpc_relay_subscriber: None,
1468 mpc_relay_subscriber_ever_attached: false,
1469 mpc_relay_inbound_inflight: HashMap::new(),
1470 mpc_did_resolver: None,
1471 observed_addrs: HashMap::new(),
1472 advertised_external_addrs: preconfigured_external,
1473 bootstrap_peers,
1474 };
1475
1476 // Create periodic cleanup timer (every 60 seconds)
1477 let mut cleanup_interval = interval(Duration::from_secs(60));
1478 cleanup_interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
1479
1480 // Periodic Kademlia bootstrap (every 60 seconds, mirrors Lighthouse).
1481 //
1482 // Kademlia routing tables degrade on churn-y networks: peers come and go,
1483 // buckets thin out, and DHT lookups start to stall. Re-issuing `bootstrap()`
1484 // on a slow tick keeps the routing table populated by re-exploring the
1485 // ID space from our own peer ID. The QueryId is fire-and-forget — actual
1486 // results land in `KademliaEvent::OutboundQueryProgressed` with
1487 // `QueryResult::Bootstrap(_)` and are handled by the swarm event handler.
1488 //
1489 // Gated by `config.enable_dht` for symmetry with the one-shot bootstrap
1490 // above; if the DHT is disabled, periodic bootstrap is meaningless.
1491 let dht_enabled = config.enable_dht;
1492 let mut kademlia_bootstrap_interval = interval(Duration::from_secs(60));
1493 kademlia_bootstrap_interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
1494
1495 // Main event loop
1496 'event_loop: loop {
1497 tokio::select! {
1498 // Fair poll across commands and swarm events. An earlier revision
1499 // biased toward `command_rx` and drained a bounded batch per turn,
1500 // on the theory that `SendBlockSyncResponse` had to reach
1501 // `send_response` quickly. That was the wrong lever: `handle_command`
1502 // only *enqueues* the response into the behaviour's send queue, and
1503 // the bytes do not egress until the swarm's connection handlers are
1504 // polled repeatedly as the socket becomes writable. Prioritising the
1505 // enqueue while throttling the swarm poll therefore did the opposite
1506 // of the intent — the response sat queued and the requester's inbound
1507 // substream hit `REQUEST_TIMEOUT_BODIES` (seen as `InboundFailure:
1508 // Timeout` on the serve side, `Eof`/`request timed out` on the
1509 // requester, and the same timeout on the consensus-direct overlay,
1510 // which shares the request_response machinery). Fair scheduling gives
1511 // the swarm continuous poll time so writes drain to completion; one
1512 // command per turn is sufficient because the command channel is not
1513 // the bottleneck — the wire is.
1514 Some(command) = command_rx.recv() => {
1515 if matches!(command, NetworkCommand::Shutdown { .. }) {
1516 handle_command(&mut state, command).await;
1517 tracing::info!("Network event loop shutting down gracefully");
1518 break 'event_loop;
1519 }
1520 handle_command(&mut state, command).await;
1521 }
1522
1523 // Handle swarm events
1524 event = state.swarm.select_next_some() => {
1525 handle_swarm_event(&mut state, event).await;
1526 }
1527
1528 // Periodic cleanup
1529 _ = cleanup_interval.tick() => {
1530 // Clean up expired bans
1531 state.peer_manager.cleanup_expired_bans();
1532
1533 // Clean up stale peers (not seen for 24 hours)
1534 state.peer_manager.cleanup_stale_peers(Duration::from_secs(86400));
1535
1536 // Update peer-count gauges from the authoritative peer manager.
1537 let stats = state.peer_manager.stats();
1538 state.metrics.peers_connected.set(stats.connected as i64);
1539 state.metrics.peers_banned.set(stats.banned as i64);
1540
1541 tracing::debug!(
1542 "Peer stats: {} total, {} connected, {} banned",
1543 stats.total,
1544 stats.connected,
1545 stats.banned
1546 );
1547
1548 // Re-dial any configured bootstrap peer we are not currently
1549 // connected to. This is the self-healing leg for the case
1550 // where a bootstrap node restarts and peers' libp2p
1551 // peer-stores still hold a cached `(ip, ephemeral_port)`
1552 // observation from a prior outbound connection — without
1553 // this sweep, peers will keep dialing the stale ephemeral
1554 // port and never re-converge until manually restarted.
1555 //
1556 // Mirrors Lighthouse's `recurring_boot_dial` and
1557 // Substrate's periodic bootnode dial. The configured
1558 // bootstrap multiaddr is the operator's authoritative
1559 // ground truth; the swarm's dial selector should always
1560 // try it in addition to whatever was learned via Identify.
1561 for (peer_id, addr) in &state.bootstrap_peers {
1562 if !state.swarm.is_connected(peer_id) {
1563 match state.swarm.dial(addr.clone()) {
1564 Ok(()) => tracing::info!(
1565 %peer_id,
1566 %addr,
1567 "Re-dialing configured bootstrap peer (not connected)"
1568 ),
1569 Err(e) => tracing::debug!(
1570 %peer_id,
1571 %addr,
1572 error = %e,
1573 "Bootstrap re-dial skipped"
1574 ),
1575 }
1576 }
1577 }
1578 }
1579
1580 // Periodic Kademlia bootstrap — keeps the DHT routing table fresh
1581 // on long-running validators. Fire-and-forget; progress arrives
1582 // via `KademliaEvent::OutboundQueryProgressed`.
1583 _ = kademlia_bootstrap_interval.tick(), if dht_enabled => {
1584 match state.swarm.behaviour_mut().kademlia.bootstrap() {
1585 Ok(query_id) => {
1586 tracing::debug!(
1587 ?query_id,
1588 "Periodic Kademlia bootstrap initiated"
1589 );
1590 }
1591 Err(e) => {
1592 tracing::warn!(
1593 error = %e,
1594 "Periodic Kademlia bootstrap failed to start \
1595 (no known peers in routing table?)"
1596 );
1597 }
1598 }
1599 }
1600 }
1601 }
1602
1603 Ok(())
1604}
1605
1606/// Translates a libp2p `request_response::Event` for the block-sync codec
1607/// into the typed `InboundBlockSync` / `OutboundBlockSyncResult` channels
1608/// the API exposes.
1609///
1610/// The `ResponseChannel<BlockSyncResponse>` cannot cross an mpsc — it is
1611/// consumed by `Behaviour::send_response(&mut self, …)`. We park it in
1612/// `pending_inbound_block_sync` keyed by `InboundRequestId`; the API caller
1613/// later issues `SendBlockSyncResponse { request_id, … }` and the event
1614/// loop performs the actual `send_response` call from the swarm context.
1615fn handle_block_sync_event(
1616 state: &mut EventLoopState,
1617 event: request_response::Event<BlockSyncRequest, BlockSyncResponse>,
1618) {
1619 use request_response::{Event as RrEvent, Message};
1620 match event {
1621 RrEvent::Message {
1622 peer,
1623 message: Message::Request {
1624 request_id,
1625 request,
1626 channel,
1627 },
1628 ..
1629 } => {
1630 // Park the response channel and notify the subscriber. If no
1631 // subscriber is attached yet, reply with a Storage error so
1632 // the requester can score us down and retry against another peer
1633 // — silent timeouts cause cascading peer-disconnect spirals
1634 // (Lighthouse `SyncManager` notes the same anti-pattern).
1635 let Some(tx) = state.block_sync_request_subscriber.as_ref() else {
1636 // Pre-attach is normal during the bootstrap window: the
1637 // libp2p stack starts accepting inbound block-sync requests
1638 // as soon as listeners bind, but the node-level engine in
1639 // `event_loop.rs` only spawns after the validator-mesh
1640 // warm-up gate. Log noisily only if a subscriber was once
1641 // attached and has since been dropped (the genuinely
1642 // anomalous case).
1643 if state.block_sync_request_subscriber_ever_attached {
1644 tracing::warn!(
1645 %peer,
1646 "Inbound block-sync request received but subscriber was dropped — \
1647 replying with Storage error"
1648 );
1649 } else {
1650 tracing::debug!(
1651 %peer,
1652 "Inbound block-sync request received during bootstrap window \
1653 (subscriber not yet attached) — replying with Storage error"
1654 );
1655 }
1656 let err_resp = BlockSyncResponse::Error(
1657 crate::block_sync_proto::BlockSyncError::Storage(
1658 "no block-sync subscriber attached".to_string(),
1659 ),
1660 );
1661 let _ = state
1662 .swarm
1663 .behaviour_mut()
1664 .block_sync
1665 .send_response(channel, err_resp);
1666 return;
1667 };
1668
1669 state
1670 .pending_inbound_block_sync
1671 .insert(request_id, channel);
1672
1673 let inbound = InboundBlockSync {
1674 peer,
1675 request_id,
1676 request,
1677 };
1678 if tx.send(inbound).is_err() {
1679 tracing::warn!(
1680 %peer,
1681 "Block-sync request subscriber dropped — discarding inbound request"
1682 );
1683 state.block_sync_request_subscriber = None;
1684 // Reply with a Storage error so the peer doesn't time out.
1685 if let Some(channel) = state.pending_inbound_block_sync.remove(&request_id) {
1686 let err_resp = BlockSyncResponse::Error(
1687 crate::block_sync_proto::BlockSyncError::Storage(
1688 "subscriber dropped".to_string(),
1689 ),
1690 );
1691 let _ = state
1692 .swarm
1693 .behaviour_mut()
1694 .block_sync
1695 .send_response(channel, err_resp);
1696 }
1697 }
1698 }
1699 RrEvent::Message {
1700 peer,
1701 message: Message::Response {
1702 request_id,
1703 response,
1704 },
1705 ..
1706 } => {
1707 if let Some(tx) = state.block_sync_result_subscriber.as_ref() {
1708 let item = OutboundBlockSyncResult {
1709 peer,
1710 request_id,
1711 result: Ok(response),
1712 };
1713 if tx.send(item).is_err() {
1714 tracing::warn!("Block-sync result subscriber dropped");
1715 state.block_sync_result_subscriber = None;
1716 }
1717 } else {
1718 tracing::warn!(
1719 %peer,
1720 %request_id,
1721 "Block-sync response received but no result subscriber attached"
1722 );
1723 }
1724 }
1725 RrEvent::OutboundFailure {
1726 peer,
1727 request_id,
1728 error,
1729 ..
1730 } => {
1731 tracing::warn!(%peer, %request_id, %error, "Outbound block-sync failure");
1732 if let Some(tx) = state.block_sync_result_subscriber.as_ref() {
1733 let item = OutboundBlockSyncResult {
1734 peer,
1735 request_id,
1736 result: Err(error.into()),
1737 };
1738 if tx.send(item).is_err() {
1739 state.block_sync_result_subscriber = None;
1740 }
1741 }
1742 }
1743 RrEvent::InboundFailure {
1744 peer,
1745 request_id,
1746 error,
1747 ..
1748 } => {
1749 // Peer dropped the stream or codec failed before we replied.
1750 // Drop the parked response channel — sending against it would
1751 // be a no-op anyway.
1752 tracing::debug!(%peer, %request_id, %error, "Inbound block-sync failure");
1753 state.pending_inbound_block_sync.remove(&request_id);
1754 }
1755 RrEvent::ResponseSent { peer, request_id, .. } => {
1756 tracing::trace!(%peer, %request_id, "Block-sync response flushed to wire");
1757 }
1758 }
1759}
1760
1761/// Translates a libp2p `request_response::Event` for the consensus-direct
1762/// codec into the typed `ConsensusMessage` channel exposed to the consensus
1763/// engine.
1764///
1765/// Inbound flow:
1766/// 1. Peer sends a `ConsensusDirectRequest::Message(ConsensusMessage)`.
1767/// 2. We check per-peer inbound concurrency; if at cap, immediately reply
1768/// `Error(ServerBusy{limit})` (no queuing — same anti-pattern guard as
1769/// block-sync).
1770/// 3. If no consensus subscriber is attached, immediately reply
1771/// `Error(NoSubscriber)` so the sender stops retrying against us.
1772/// 4. Otherwise: bump in-flight count, push the message to the subscriber,
1773/// and synchronously reply `Ack` (the consensus engine processes from
1774/// its own queue independently — we do not block the wire on its
1775/// pipeline).
1776///
1777/// Outbound flow:
1778/// * `ResponseSent` → trace log (request fully flushed; nothing else to do).
1779/// * `InboundFailure` → decrement in-flight counter and log.
1780/// * `OutboundFailure` → log; the consensus retry policy in tenzro-consensus
1781/// reacts to the typed error class returned through `Ack`/transport.
1782fn handle_consensus_direct_event(
1783 state: &mut EventLoopState,
1784 event: request_response::Event<ConsensusDirectRequest, ConsensusDirectResponse>,
1785) {
1786 use request_response::{Event as RrEvent, Message};
1787 match event {
1788 RrEvent::Message {
1789 peer,
1790 message: Message::Request {
1791 request_id: _,
1792 request,
1793 channel,
1794 },
1795 ..
1796 } => {
1797 // Per-peer inbound concurrency cap. Reject overflow synchronously
1798 // with ServerBusy rather than queueing — queuing causes the
1799 // requester's response timeout to fire while the request still
1800 // sits in our backlog.
1801 let inflight = state
1802 .consensus_direct_inbound_inflight
1803 .entry(peer)
1804 .or_insert(0);
1805 if *inflight >= MAX_INBOUND_STREAMS_PER_PEER {
1806 tracing::warn!(
1807 %peer,
1808 limit = MAX_INBOUND_STREAMS_PER_PEER,
1809 "consensus-direct: rejecting overflow inbound stream"
1810 );
1811 let _ = state
1812 .swarm
1813 .behaviour_mut()
1814 .consensus_direct
1815 .send_response(
1816 channel,
1817 ConsensusDirectResponse::Error(ConsensusDirectError::ServerBusy {
1818 limit: MAX_INBOUND_STREAMS_PER_PEER,
1819 }),
1820 );
1821 return;
1822 }
1823
1824 // No subscriber attached — sender should NOT retry against us.
1825 // Reply NoSubscriber so the consensus retry policy on the other
1826 // side can prune us from its targets.
1827 //
1828 // Pre-attach is normal during the validator-mesh warm-up window:
1829 // the libp2p stack accepts inbound consensus-direct requests as
1830 // soon as listeners bind, but the node-level engine attaches via
1831 // `subscribe_consensus_direct()` only after `init_consensus()`
1832 // and the bootstrap-quorum gate clear. Log noisily only when a
1833 // subscriber was once attached and has since been dropped.
1834 let Some(tx) = state.consensus_direct_subscriber.as_ref() else {
1835 if state.consensus_direct_subscriber_ever_attached {
1836 tracing::warn!(
1837 %peer,
1838 "consensus-direct: subscriber was dropped — replying NoSubscriber"
1839 );
1840 } else {
1841 tracing::debug!(
1842 %peer,
1843 "consensus-direct: inbound during bootstrap window \
1844 (subscriber not yet attached) — replying NoSubscriber"
1845 );
1846 }
1847 let _ = state
1848 .swarm
1849 .behaviour_mut()
1850 .consensus_direct
1851 .send_response(
1852 channel,
1853 ConsensusDirectResponse::Error(ConsensusDirectError::NoSubscriber),
1854 );
1855 return;
1856 };
1857
1858 // Forward to the consensus engine. We Ack synchronously — the
1859 // engine processes the message from its own queue. The Ack means
1860 // "we accepted the message into our pipeline", not "we processed
1861 // the consensus state-machine effect of this message".
1862 let ConsensusDirectRequest::Message(consensus_msg) = request;
1863 let send_result = tx.send(consensus_msg);
1864 if send_result.is_err() {
1865 tracing::warn!(
1866 %peer,
1867 "consensus-direct: subscriber dropped — replying NoSubscriber"
1868 );
1869 state.consensus_direct_subscriber = None;
1870 let _ = state
1871 .swarm
1872 .behaviour_mut()
1873 .consensus_direct
1874 .send_response(
1875 channel,
1876 ConsensusDirectResponse::Error(ConsensusDirectError::NoSubscriber),
1877 );
1878 return;
1879 }
1880
1881 *inflight += 1;
1882
1883 let _ = state
1884 .swarm
1885 .behaviour_mut()
1886 .consensus_direct
1887 .send_response(channel, ConsensusDirectResponse::Ack);
1888 }
1889 RrEvent::Message {
1890 peer,
1891 message: Message::Response {
1892 request_id,
1893 response,
1894 },
1895 ..
1896 } => {
1897 // Outbound responses are Ack/Error. We log Error variants because
1898 // the sender's consensus retry policy may want to skip future
1899 // dispatches to a NoSubscriber peer (the per-validator dispatch
1900 // loop in event_loop.rs picks the validator set fresh each call,
1901 // so a NoSubscriber decision is naturally recomputed next round).
1902 match response {
1903 ConsensusDirectResponse::Ack => {
1904 tracing::trace!(%peer, %request_id, "consensus-direct ack");
1905 }
1906 ConsensusDirectResponse::Error(err) => {
1907 tracing::warn!(
1908 %peer,
1909 %request_id,
1910 %err,
1911 "consensus-direct: peer returned error response"
1912 );
1913 }
1914 }
1915 }
1916 RrEvent::OutboundFailure {
1917 peer,
1918 request_id,
1919 error,
1920 ..
1921 } => {
1922 tracing::warn!(
1923 %peer,
1924 %request_id,
1925 %error,
1926 "consensus-direct outbound failure"
1927 );
1928 }
1929 RrEvent::InboundFailure {
1930 peer,
1931 request_id,
1932 error,
1933 ..
1934 } => {
1935 tracing::debug!(
1936 %peer,
1937 %request_id,
1938 %error,
1939 "consensus-direct inbound failure"
1940 );
1941 // Decrement inflight on the failure path — the request never
1942 // reached `send_response`, so the slot is freed here.
1943 if let Some(count) = state.consensus_direct_inbound_inflight.get_mut(&peer)
1944 && *count > 0
1945 {
1946 *count -= 1;
1947 }
1948 }
1949 RrEvent::ResponseSent { peer, request_id, .. } => {
1950 tracing::trace!(
1951 %peer,
1952 %request_id,
1953 "consensus-direct response flushed to wire"
1954 );
1955 // Decrement inflight on the happy path — the response is now on
1956 // the wire and the inbound stream slot is freed.
1957 if let Some(count) = state.consensus_direct_inbound_inflight.get_mut(&peer)
1958 && *count > 0
1959 {
1960 *count -= 1;
1961 }
1962 }
1963 }
1964}
1965
1966/// Handles inbound and outbound events from the MPC relay
1967/// request-response codec (`/tenzro/mpc/req-resp/1.0.0`).
1968///
1969/// Mirrors `handle_consensus_direct_event` with one extra step: every
1970/// inbound `MpcRelayRequest` is audited against the installed
1971/// `MpcDidResolver` — the sender's claimed `from_did` must round-trip back
1972/// to the connection's `PeerId`. A mismatch (or a missing resolver) means
1973/// either no identity is bound or the sender is spoofing a DID they don't
1974/// control; either way we reject with `MpcRelayError::UnknownSender` so the
1975/// session driver on the other side fails fast.
1976fn handle_mpc_relay_event(
1977 state: &mut EventLoopState,
1978 event: request_response::Event<
1979 crate::mpc_relay::MpcRelayRequest,
1980 crate::mpc_relay::MpcRelayResponse,
1981 >,
1982) {
1983 use crate::mpc_relay::{MpcRelayError, MpcRelayResponse, MAX_INBOUND_STREAMS_PER_PEER};
1984 use request_response::{Event as RrEvent, Message};
1985 match event {
1986 RrEvent::Message {
1987 peer,
1988 message: Message::Request {
1989 request_id: _,
1990 request,
1991 channel,
1992 },
1993 ..
1994 } => {
1995 // Per-peer inbound concurrency cap. Reject overflow synchronously
1996 // with ServerBusy — queueing would cause the requester's
1997 // per-round timeout to fire while the request sits in our
1998 // backlog (same rationale as consensus-direct).
1999 let inflight = state
2000 .mpc_relay_inbound_inflight
2001 .entry(peer)
2002 .or_insert(0);
2003 if *inflight >= MAX_INBOUND_STREAMS_PER_PEER {
2004 tracing::warn!(
2005 %peer,
2006 limit = MAX_INBOUND_STREAMS_PER_PEER,
2007 "mpc-relay: rejecting overflow inbound stream"
2008 );
2009 let _ = state
2010 .swarm
2011 .behaviour_mut()
2012 .mpc_relay
2013 .send_response(
2014 channel,
2015 MpcRelayResponse::Error(MpcRelayError::ServerBusy {
2016 limit: MAX_INBOUND_STREAMS_PER_PEER,
2017 }),
2018 );
2019 return;
2020 }
2021
2022 // Sender-DID audit. The relay is fail-closed: no resolver means
2023 // no identity binding is wired, so we cannot prove who the peer
2024 // claims to be. Either way the inbound message is rejected
2025 // before it reaches the session pipeline.
2026 let claimed_did = request.from_did.clone();
2027 let audit_ok = match state.mpc_did_resolver.as_ref() {
2028 None => {
2029 tracing::warn!(
2030 %peer,
2031 claimed_did = %claimed_did,
2032 "mpc-relay: rejecting inbound — no DID resolver installed"
2033 );
2034 false
2035 }
2036 Some(resolver) => match resolver.did_for_peer_id(&peer) {
2037 Some(actual_did) if actual_did == claimed_did => true,
2038 Some(actual_did) => {
2039 tracing::warn!(
2040 %peer,
2041 claimed_did = %claimed_did,
2042 actual_did = %actual_did,
2043 "mpc-relay: from_did does not match peer's bound DID — rejecting"
2044 );
2045 false
2046 }
2047 None => {
2048 tracing::warn!(
2049 %peer,
2050 claimed_did = %claimed_did,
2051 "mpc-relay: peer has no bound DID — rejecting"
2052 );
2053 false
2054 }
2055 },
2056 };
2057 if !audit_ok {
2058 let _ = state
2059 .swarm
2060 .behaviour_mut()
2061 .mpc_relay
2062 .send_response(
2063 channel,
2064 MpcRelayResponse::Error(MpcRelayError::UnknownSender),
2065 );
2066 return;
2067 }
2068
2069 // No subscriber attached — fast-fail so the sending session
2070 // driver can abort rather than wait for a per-round timeout.
2071 // Pre-attach is normal during bootstrap (the bridge attaches
2072 // the MPC subscriber only after the keyshare store loads).
2073 let Some(tx) = state.mpc_relay_subscriber.as_ref() else {
2074 if state.mpc_relay_subscriber_ever_attached {
2075 tracing::warn!(
2076 %peer,
2077 "mpc-relay: subscriber was dropped — replying NoSubscriber"
2078 );
2079 } else {
2080 tracing::debug!(
2081 %peer,
2082 "mpc-relay: inbound during bootstrap window \
2083 (subscriber not yet attached) — replying NoSubscriber"
2084 );
2085 }
2086 let _ = state
2087 .swarm
2088 .behaviour_mut()
2089 .mpc_relay
2090 .send_response(
2091 channel,
2092 MpcRelayResponse::Error(MpcRelayError::NoSubscriber),
2093 );
2094 return;
2095 };
2096
2097 // Forward into the session pipeline and Ack synchronously. The
2098 // Ack means "we accepted the round message into our pipeline",
2099 // not "we completed the protocol round".
2100 let send_result = tx.send(request);
2101 if send_result.is_err() {
2102 tracing::warn!(
2103 %peer,
2104 "mpc-relay: subscriber dropped — replying NoSubscriber"
2105 );
2106 state.mpc_relay_subscriber = None;
2107 let _ = state
2108 .swarm
2109 .behaviour_mut()
2110 .mpc_relay
2111 .send_response(
2112 channel,
2113 MpcRelayResponse::Error(MpcRelayError::NoSubscriber),
2114 );
2115 return;
2116 }
2117
2118 *inflight += 1;
2119
2120 let _ = state
2121 .swarm
2122 .behaviour_mut()
2123 .mpc_relay
2124 .send_response(channel, MpcRelayResponse::Ack);
2125 }
2126 RrEvent::Message {
2127 peer,
2128 message: Message::Response {
2129 request_id,
2130 response,
2131 },
2132 ..
2133 } => match response {
2134 MpcRelayResponse::Ack => {
2135 tracing::trace!(%peer, %request_id, "mpc-relay ack");
2136 }
2137 MpcRelayResponse::Error(err) => {
2138 tracing::warn!(
2139 %peer,
2140 %request_id,
2141 %err,
2142 "mpc-relay: peer returned error response"
2143 );
2144 }
2145 },
2146 RrEvent::OutboundFailure {
2147 peer,
2148 request_id,
2149 error,
2150 ..
2151 } => {
2152 tracing::warn!(
2153 %peer,
2154 %request_id,
2155 %error,
2156 "mpc-relay outbound failure"
2157 );
2158 }
2159 RrEvent::InboundFailure {
2160 peer,
2161 request_id,
2162 error,
2163 ..
2164 } => {
2165 tracing::debug!(
2166 %peer,
2167 %request_id,
2168 %error,
2169 "mpc-relay inbound failure"
2170 );
2171 if let Some(count) = state.mpc_relay_inbound_inflight.get_mut(&peer)
2172 && *count > 0
2173 {
2174 *count -= 1;
2175 }
2176 }
2177 RrEvent::ResponseSent { peer, request_id, .. } => {
2178 tracing::trace!(
2179 %peer,
2180 %request_id,
2181 "mpc-relay response flushed to wire"
2182 );
2183 if let Some(count) = state.mpc_relay_inbound_inflight.get_mut(&peer)
2184 && *count > 0
2185 {
2186 *count -= 1;
2187 }
2188 }
2189 }
2190}
2191
2192/// Handles swarm events
2193async fn handle_swarm_event(
2194 state: &mut EventLoopState,
2195 event: SwarmEvent<TenzroBehaviourEvent>,
2196) {
2197 match event {
2198 SwarmEvent::Behaviour(behaviour_event) => match behaviour_event {
2199 TenzroBehaviourEvent::Gossipsub(gossipsub::Event::Message {
2200 propagation_source,
2201 message_id,
2202 message,
2203 }) => {
2204 tracing::debug!(
2205 "Received message {} from peer {}",
2206 message_id,
2207 propagation_source
2208 );
2209
2210 // Check if peer is banned
2211 if state.peer_manager.is_banned(&propagation_source) {
2212 tracing::warn!("Ignoring message from banned peer {}", propagation_source);
2213 state.metrics.gossip_rejected_invalid.inc();
2214 return;
2215 }
2216
2217 // Check rate limit
2218 if !state.peer_manager.check_rate_limit(&propagation_source) {
2219 tracing::warn!("Rate limited message from peer {}", propagation_source);
2220 state.metrics.gossip_rejected_invalid.inc();
2221 return;
2222 }
2223
2224 // Application-level deduplication (defense-in-depth over gossipsub message IDs)
2225 if state.deduplicator.is_duplicate(&message.data) {
2226 tracing::trace!("Dropping duplicate message from peer {}", propagation_source);
2227 state.metrics.gossip_rejected_duplicate.inc();
2228 return;
2229 }
2230
2231 // Validate peer authorization for validator-only topics.
2232 // Drop silently — do NOT penalize reputation for validator-topic mismatch.
2233 // Validators may not be in the local registry during early startup or
2234 // after a genesis wipe, causing false-positive bans.
2235 let topic_str = message.topic.to_string();
2236 if !state.peer_manager.authorize_peer_for_topic(&propagation_source, &topic_str) {
2237 state.metrics.gossip_rejected_validator_only.inc();
2238 return;
2239 }
2240
2241 // Validate message structure (size, format, timestamp)
2242 match validate_gossip_message(&message.topic, &message.data) {
2243 MessageValidation::Accept => {}
2244 MessageValidation::Reject => {
2245 tracing::warn!("Message validation rejected from peer {}", propagation_source);
2246 state.peer_manager.decrease_reputation(&propagation_source, 5);
2247 state.metrics.gossip_rejected_invalid.inc();
2248 return;
2249 }
2250 MessageValidation::Ignore => {
2251 state.metrics.gossip_rejected_invalid.inc();
2252 return;
2253 }
2254 }
2255
2256 // Parse network message
2257 match NetworkMessage::from_bytes(&message.data) {
2258 Ok(net_msg) => {
2259 // Update peer reputation for valid messages
2260 state
2261 .peer_manager
2262 .increase_reputation(&propagation_source, 1);
2263 state.metrics.gossip_accepted.inc();
2264
2265 // Forward to subscribers
2266 if let Some(subs) = state.subscribers.get_mut(&message.topic) {
2267 subs.retain(|tx| tx.send(net_msg.clone()).is_ok());
2268 }
2269 }
2270 Err(e) => {
2271 tracing::warn!("Failed to parse network message: {}", e);
2272 state
2273 .peer_manager
2274 .decrease_reputation(&propagation_source, 5);
2275 state.metrics.gossip_rejected_invalid.inc();
2276 }
2277 }
2278 }
2279 TenzroBehaviourEvent::Gossipsub(gossipsub::Event::Subscribed { peer_id, topic }) => {
2280 tracing::debug!("Peer {} subscribed to topic {:?}", peer_id, topic);
2281 }
2282 TenzroBehaviourEvent::Gossipsub(gossipsub::Event::Unsubscribed { peer_id, topic }) => {
2283 tracing::debug!("Peer {} unsubscribed from topic {:?}", peer_id, topic);
2284 }
2285 TenzroBehaviourEvent::Identify(identify::Event::Received { peer_id, info, connection_id: _ }) => {
2286 tracing::info!(
2287 "Identified peer {}: protocol={}, agent={}",
2288 peer_id,
2289 info.protocol_version,
2290 info.agent_version
2291 );
2292
2293 // Dynamically admit Tenzro peers into the validator registry so
2294 // their consensus / attestation messages don't get rejected and
2295 // cause gossipsub peer-score decay → mutual ban. Only peers
2296 // whose protocol version starts with "tenzro/" are admitted;
2297 // anything else is a no-op. See PeerManager::try_register_validator_on_identify.
2298 state
2299 .peer_manager
2300 .try_register_validator_on_identify(&peer_id, &info.protocol_version);
2301
2302 // We do NOT call gossipsub.add_explicit_peer() here. In
2303 // libp2p-gossipsub, "explicit peers" are bypass-the-mesh
2304 // peers — incoming GRAFTs from them are rejected (behaviour.rs
2305 // ~1400 "GRAFT: ignoring request from direct peer"). When
2306 // both ends classify each other as explicit, the mesh never
2307 // forms. Publish-side coverage for known peers is already
2308 // provided by `flood_publish(true)` on the gossipsub config.
2309
2310 state
2311 .peer_manager
2312 .update_protocol_version(&peer_id, info.protocol_version);
2313
2314 // Add only globally routable addresses to Kademlia DHT, then
2315 // dial the discovered peer to actually form a mesh connection.
2316 //
2317 // Filters out loopback (127.x), private RFC-1918, Docker bridge (172.17.x),
2318 // link-local, and unspecified addresses that K8s pods cannot reach.
2319 //
2320 // The dial-on-discovery step is the canonical permissionless-mesh
2321 // pattern used by Substrate (`discovery.rs::next_kad_random_query` →
2322 // dial via Behaviour::poll), Lighthouse (`network/src/discovery.rs`
2323 // `Discovery::dial_peer`), and IPFS Kubo. Without it, joiners learn
2324 // about the other validators via Kademlia/Identify but never open
2325 // TCP connections to them — the routing table fills up while
2326 // `swarm.connected_peers()` stays at 0, producing a permanent
2327 // star topology around the bootstrap node.
2328 let already_connected = state.swarm.is_connected(&peer_id);
2329 for addr in &info.listen_addrs {
2330 if is_globally_routable(addr) {
2331 state
2332 .swarm
2333 .behaviour_mut()
2334 .kademlia
2335 .add_address(&peer_id, addr.clone());
2336
2337 // Feed the discovered address into the Swarm's per-peer
2338 // address book so `request_response` behaviours
2339 // (`consensus_direct`, `block_sync`, etc.) can dial out
2340 // when they enqueue a request.
2341 //
2342 // **This is the canonical libp2p fix for "send_request
2343 // returns RequestId synchronously, then dial fails
2344 // silently."** `request_response::Behaviour` does not
2345 // auto-discover addresses from existing swarm
2346 // connections — it requires either an explicit address
2347 // on the request or a prior `Swarm::add_peer_address`
2348 // call. Without this hook, every cross-replica vote
2349 // dispatch was a no-op: `send_request` happily allocated
2350 // a RequestId, the dispatcher logged `dispatched=9`,
2351 // and the request sat in the state machine waiting for
2352 // a dial that never happened — never delivered as
2353 // `OutboundFailure::DialFailure` either, because the
2354 // dial wasn't scheduled. Result: consensus QC never
2355 // formed (`votes=1 threshold=7` forever).
2356 //
2357 // Identify's `Event::Received { peer_id, info }` is
2358 // the natural hook — `info.listen_addrs` is the peer's
2359 // self-advertised reachable set, which is exactly what
2360 // request_response needs to dial. The same address set
2361 // is already fed into Kademlia immediately above; this
2362 // call extends the same data into the swarm-level
2363 // address book that all behaviours consult.
2364 //
2365 // Refs: libp2p/rust-libp2p#5708, request_response
2366 // Behaviour docs (the `add_address` method on the
2367 // behaviour itself is deprecated in favour of
2368 // `Swarm::add_peer_address`).
2369 state.swarm.add_peer_address(peer_id, addr.clone());
2370
2371 if !already_connected {
2372 match state.swarm.dial(addr.clone()) {
2373 Ok(()) => tracing::info!(
2374 %peer_id,
2375 %addr,
2376 "Dialing Kademlia-discovered peer (Identify)"
2377 ),
2378 Err(e) => tracing::debug!(
2379 %peer_id,
2380 %addr,
2381 error = %e,
2382 "Dial-on-discovery skipped"
2383 ),
2384 }
2385 }
2386 } else {
2387 tracing::debug!(
2388 "Skipping non-routable DHT address from {}: {}",
2389 peer_id,
2390 addr
2391 );
2392 }
2393 }
2394
2395 // Permissionless NAT discovery: every Identify exchange reports
2396 // the local-node address the remote peer observed us at. Tally
2397 // distinct peers per observed address; once N≥3 independent
2398 // peers agree on the same external multiaddr, promote it via
2399 // `Swarm::add_external_address` so Identify advertises it on
2400 // the next exchange (libp2p sources advertised addrs from
2401 // `Swarm::external_addresses()`).
2402 //
2403 // This is the same primitive Substrate / Lighthouse / IPFS /
2404 // Sui use to ship NAT-agnostic permissionless networks —
2405 // no `--external-p2p-addr` flag required, no per-deployment
2406 // config, works from home wifi, mobile, EC2, GCE alike.
2407 //
2408 // The N-distinct-peers gate is the standard rust-libp2p
2409 // defence against a single malicious peer lying about our
2410 // address (see libp2p-identify CHANGELOG 0.43.0: observed
2411 // addresses are no longer trusted by default). AutoNAT v2
2412 // client (registered in `TenzroBehaviour::autonat_client`)
2413 // adds orthogonal probe-back confirmation: it picks one of
2414 // our advertised external addresses and asks a public peer
2415 // to dial back to verify reachability. Both gates must pass.
2416 let obs = info.observed_addr.clone();
2417 if !is_globally_routable(&obs) {
2418 tracing::trace!(
2419 %peer_id,
2420 observed = %obs,
2421 "Ignoring non-routable observed address from peer"
2422 );
2423 } else if !is_observed_port_one_of_ours(&obs, &state.listen_addresses) {
2424 // Reject NAT-translated outbound source ports. The peer
2425 // observed us at `(public_ip, ephemeral_port)` because
2426 // our outbound connection was source-NAT'd; nothing is
2427 // listening on the ephemeral port, so advertising it
2428 // would publish an unreachable address. Subsequent peers
2429 // would dial the ephemeral port, fail, and cache the
2430 // stale entry — which is exactly the failure mode that
2431 // halted consensus when v0 restarted (see
2432 // `consensus_stall_2026_05_18` runbook).
2433 //
2434 // Only an observation whose port matches one of our
2435 // actual listen-port bindings can plausibly be a
2436 // dial-back-reachable address.
2437 tracing::debug!(
2438 %peer_id,
2439 observed = %obs,
2440 "Ignoring observed address — port not in our listen-port set \
2441 (NAT-translated source port, not a reachable listen address)"
2442 );
2443 } else if !state.advertised_external_addrs.contains(&obs) {
2444 let reporters = state.observed_addrs.entry(obs.clone()).or_default();
2445 if reporters.insert(peer_id) {
2446 tracing::debug!(
2447 %peer_id,
2448 observed = %obs,
2449 count = reporters.len(),
2450 threshold = OBSERVED_ADDR_CONFIRMATION_THRESHOLD,
2451 "Observed-address report received via Identify"
2452 );
2453 }
2454 if reporters.len() >= OBSERVED_ADDR_CONFIRMATION_THRESHOLD {
2455 tracing::info!(
2456 address = %obs,
2457 reporters = reporters.len(),
2458 "Promoting observed address to advertised external address \
2459 (N distinct peers agree — permissionless NAT discovery)"
2460 );
2461 state.swarm.add_external_address(obs.clone());
2462 state.advertised_external_addrs.insert(obs.clone());
2463 // Free the tally memory for this address — once promoted,
2464 // additional reports are not actionable.
2465 state.observed_addrs.remove(&obs);
2466 }
2467 }
2468 }
2469 TenzroBehaviourEvent::Kademlia(kad::Event::OutboundQueryProgressed { result, .. }) => {
2470 match result {
2471 QueryResult::GetProviders(Ok(kad::GetProvidersOk::FoundProviders { providers, .. })) => {
2472 tracing::debug!("Found {} provider(s)", providers.len());
2473 for peer in providers {
2474 tracing::debug!("Found provider: {}", peer);
2475 }
2476 }
2477 QueryResult::GetProviders(Ok(kad::GetProvidersOk::FinishedWithNoAdditionalRecord { closest_peers })) => {
2478 tracing::debug!("GetProviders query finished with {} closest peers", closest_peers.len());
2479 }
2480 QueryResult::Bootstrap(Ok(_)) => {
2481 tracing::info!("DHT bootstrap completed");
2482 }
2483 _ => {}
2484 }
2485 }
2486 TenzroBehaviourEvent::Ping(ping::Event { peer, result, .. }) => {
2487 match result {
2488 Ok(duration) => {
2489 tracing::trace!("Ping to {} successful: {:?}", peer, duration);
2490 state.peer_manager.increase_reputation(&peer, 1);
2491 }
2492 Err(e) => {
2493 // Log at trace level — ping failures are common during K8s pod startup
2494 // and should NOT reduce peer reputation. Banning validators for transient
2495 // ping timeouts causes permanent gossip isolation.
2496 tracing::trace!("Ping to {} failed: {}", peer, e);
2497 }
2498 }
2499 }
2500 TenzroBehaviourEvent::BlockSync(rr_event) => {
2501 handle_block_sync_event(state, rr_event);
2502 }
2503 TenzroBehaviourEvent::ConsensusDirect(rr_event) => {
2504 handle_consensus_direct_event(state, rr_event);
2505 }
2506 TenzroBehaviourEvent::MpcRelay(rr_event) => {
2507 handle_mpc_relay_event(state, rr_event);
2508 }
2509 TenzroBehaviourEvent::AutonatClient(autonat::v2::client::Event {
2510 tested_addr,
2511 bytes_sent,
2512 server,
2513 result,
2514 }) => {
2515 // AutoNAT v2 client probe result. Successful probes confirm
2516 // an external address; libp2p's autonat client behaviour
2517 // emits `SwarmEvent::ExternalAddrConfirmed` on success
2518 // independently, so we just log here for observability.
2519 match result {
2520 Ok(()) => tracing::info!(
2521 %server,
2522 address = %tested_addr,
2523 bytes_sent,
2524 "AutoNAT probe succeeded — address reachable"
2525 ),
2526 Err(e) => tracing::debug!(
2527 %server,
2528 address = %tested_addr,
2529 bytes_sent,
2530 error = %e,
2531 "AutoNAT probe failed — address not reachable from server"
2532 ),
2533 }
2534 }
2535 TenzroBehaviourEvent::AutonatServer(_) => {
2536 // Server-side dial-back requests. No action needed — the
2537 // behaviour serves probes autonomously.
2538 }
2539 TenzroBehaviourEvent::RelayClient(relay_event) => match relay_event {
2540 relay::client::Event::ReservationReqAccepted {
2541 relay_peer_id,
2542 renewal,
2543 limit,
2544 } => {
2545 tracing::info!(
2546 %relay_peer_id,
2547 renewal,
2548 ?limit,
2549 "Circuit-Relay v2 reservation accepted — this node is now \
2550 reachable via /p2p/<relay>/p2p-circuit/p2p/<self>"
2551 );
2552 }
2553 relay::client::Event::OutboundCircuitEstablished {
2554 relay_peer_id,
2555 limit,
2556 } => {
2557 tracing::info!(
2558 %relay_peer_id,
2559 ?limit,
2560 "Outbound circuit established via relay"
2561 );
2562 }
2563 relay::client::Event::InboundCircuitEstablished {
2564 src_peer_id,
2565 limit,
2566 } => {
2567 tracing::info!(
2568 %src_peer_id,
2569 ?limit,
2570 "Inbound circuit established via relay"
2571 );
2572 }
2573 },
2574 TenzroBehaviourEvent::Relay(_) => {
2575 // Server-side relay events (reservation requests served to
2576 // other peers). No action needed — the behaviour handles
2577 // them autonomously according to `relay::Config`.
2578 }
2579 TenzroBehaviourEvent::Dcutr(dcutr::Event {
2580 remote_peer_id,
2581 result,
2582 }) => match result {
2583 Ok(conn_id) => tracing::info!(
2584 %remote_peer_id,
2585 ?conn_id,
2586 "DCUtR hole-punch succeeded — direct connection upgraded from relayed"
2587 ),
2588 Err(e) => tracing::debug!(
2589 %remote_peer_id,
2590 error = %e,
2591 "DCUtR hole-punch failed — will continue using relayed connection"
2592 ),
2593 },
2594 _ => {}
2595 },
2596 SwarmEvent::ConnectionEstablished {
2597 peer_id,
2598 endpoint,
2599 num_established,
2600 ..
2601 } => {
2602 // Auto-unban peers that reconnect — stale bans from previous
2603 // sessions shouldn't permanently isolate validators.
2604 if state.peer_manager.is_banned(&peer_id) {
2605 tracing::info!("Auto-unbanning reconnecting peer {}", peer_id);
2606 state.peer_manager.unban_peer(&peer_id);
2607 }
2608
2609 tracing::info!(
2610 "Connection established with {} (endpoint: {:?}, num_established: {})",
2611 peer_id,
2612 endpoint,
2613 num_established
2614 );
2615
2616 // Metrics: increment directional + total established gauge
2617 state.metrics.connections_established.inc();
2618 match &endpoint {
2619 libp2p::core::ConnectedPoint::Listener { .. } => {
2620 state.metrics.connections_inbound_total.inc();
2621 }
2622 libp2p::core::ConnectedPoint::Dialer { .. } => {
2623 state.metrics.connections_outbound_total.inc();
2624 }
2625 }
2626
2627 state.peer_manager.add_peer(peer_id);
2628 state
2629 .peer_manager
2630 .update_status(&peer_id, PeerStatus::Connected);
2631
2632 // Application-layer peer address migration detection. libp2p's
2633 // `ConnectionEstablished` surfaces the remote multiaddr via
2634 // `endpoint.get_remote_address()`; comparing against the
2635 // previously observed remote for this peer lets us count QUIC
2636 // path migrations, mobile-network switches, and NAT rebinding
2637 // events without reaching into quinn-proto internals.
2638 let remote_addr = endpoint.get_remote_address().clone();
2639 if state.peer_manager.update_endpoint(&peer_id, remote_addr.clone()) {
2640 state.metrics.peer_address_migrations_total.inc();
2641 tracing::info!(
2642 %peer_id,
2643 new_addr = %remote_addr,
2644 "Peer address migration observed — counter incremented"
2645 );
2646 }
2647
2648 // Fan out a `PeerEvent::Connected` exactly once per peer — only
2649 // when this is the first physical connection. Subsequent
2650 // multiplexed connections to the same peer don't re-emit. If
2651 // the receiver is gone (engine task panicked or stopped),
2652 // detach the subscriber so we stop trying.
2653 if num_established.get() == 1 {
2654 if let Some(tx) = state.peer_event_subscriber.as_ref() {
2655 if tx.send(PeerEvent::Connected(peer_id)).is_err() {
2656 tracing::warn!(
2657 "Peer-event subscriber dropped while sending Connected({}); detaching",
2658 peer_id
2659 );
2660 state.peer_event_subscriber = None;
2661 }
2662 }
2663 }
2664 }
2665 SwarmEvent::ConnectionClosed {
2666 peer_id,
2667 cause,
2668 num_established,
2669 ..
2670 } => {
2671 tracing::info!(
2672 "Connection closed with {} (cause: {:?}, remaining: {})",
2673 peer_id,
2674 cause,
2675 num_established
2676 );
2677
2678 // Metrics: decrement established gauge (one physical connection closed)
2679 state.metrics.connections_established.dec();
2680
2681 if num_established == 0 {
2682 state
2683 .peer_manager
2684 .update_status(&peer_id, PeerStatus::Disconnected);
2685
2686 // Last physical connection dropped — fan out
2687 // `PeerEvent::Disconnected` so subscribers can evict this
2688 // peer from their candidate sets.
2689 if let Some(tx) = state.peer_event_subscriber.as_ref() {
2690 if tx.send(PeerEvent::Disconnected(peer_id)).is_err() {
2691 tracing::warn!(
2692 "Peer-event subscriber dropped while sending Disconnected({}); detaching",
2693 peer_id
2694 );
2695 state.peer_event_subscriber = None;
2696 }
2697 }
2698 }
2699 }
2700 SwarmEvent::NewListenAddr { address, .. } => {
2701 tracing::info!("Listening on {}", address);
2702 if !state.listen_addresses.contains(&address) {
2703 state.listen_addresses.push(address);
2704 }
2705 }
2706 SwarmEvent::ExpiredListenAddr { address, .. } => {
2707 tracing::info!("Listen address expired: {}", address);
2708 state.listen_addresses.retain(|a| a != &address);
2709 }
2710 SwarmEvent::IncomingConnection { send_back_addr, local_addr, .. } => {
2711 tracing::debug!("Incoming connection from {} to {}", send_back_addr, local_addr);
2712
2713 // Apply per-IP + global dial rate limiting to mitigate connection-flood DoS.
2714 // The `check_dial_rate_limit` consults both a keyed IP limiter (10/min burst 5)
2715 // and a global limiter (200/min burst 20). If either denies, we surface the
2716 // rejection via metrics. NOTE: libp2p 0.56's SwarmEvent::IncomingConnection
2717 // is a notification; actual denial must flow through the connection_limits
2718 // behaviour (configured in TenzroBehaviour::new) or a future deny-list hook.
2719 // Here we record the rate-limit decision for observability.
2720 if let Some(ip) = extract_ip(&send_back_addr)
2721 && !state.peer_manager.check_dial_rate_limit(ip)
2722 {
2723 tracing::warn!("Dial rate-limit exceeded for IP {}", ip);
2724 state.metrics.dials_rejected_per_ip.inc();
2725 }
2726 }
2727 SwarmEvent::IncomingConnectionError { send_back_addr, error, .. } => {
2728 tracing::warn!("Incoming connection error from {}: {}", send_back_addr, error);
2729 }
2730 SwarmEvent::OutgoingConnectionError { peer_id, error, .. } => {
2731 if let Some(peer_id) = peer_id {
2732 tracing::warn!("Outgoing connection error with {}: {}", peer_id, error);
2733 state.peer_manager.record_failed_connection(&peer_id);
2734 } else {
2735 tracing::warn!("Outgoing connection error (no peer ID): {}", error);
2736 }
2737 }
2738 SwarmEvent::NewExternalAddrCandidate { address } => {
2739 // libp2p emits this when a behaviour (typically Identify or
2740 // AutoNAT) surfaces an externally-observed address. We don't
2741 // auto-promote here — the observed_addr tally in the Identify
2742 // handler is what gates promotion. Logged for operator
2743 // observability.
2744 tracing::debug!(%address, "New external address candidate");
2745 }
2746 SwarmEvent::ExternalAddrConfirmed { address } => {
2747 tracing::info!(
2748 %address,
2749 "External address confirmed (AutoNAT probe-back succeeded)"
2750 );
2751 // Belt-and-braces: AutoNAT-confirmed addresses are reliable
2752 // reachability evidence. Make sure we also record it so the
2753 // observed_addr tally doesn't waste cycles re-counting.
2754 state.advertised_external_addrs.insert(address.clone());
2755 state.observed_addrs.remove(&address);
2756 }
2757 SwarmEvent::ExternalAddrExpired { address } => {
2758 tracing::info!(
2759 %address,
2760 "External address expired — stopping advertisement"
2761 );
2762 state.advertised_external_addrs.remove(&address);
2763 }
2764 _ => {}
2765 }
2766}
2767
2768/// Handles commands from the API
2769async fn handle_command(state: &mut EventLoopState, command: NetworkCommand) {
2770 match command {
2771 NetworkCommand::Broadcast {
2772 topic,
2773 message,
2774 response,
2775 } => {
2776 let result = (|| {
2777 let topic_obj = IdentTopic::new(topic);
2778 let bytes = message.to_bytes().map_err(|e| {
2779 NetworkError::Serialization(e)
2780 })?;
2781
2782 state
2783 .swarm
2784 .behaviour_mut()
2785 .publish(&topic_obj, bytes.to_vec())
2786 .map_err(|e| NetworkError::PublishError(e.to_string()))?;
2787
2788 Ok(())
2789 })();
2790
2791 if result.is_ok() {
2792 state.metrics.gossip_published.inc();
2793 }
2794
2795 let _ = response.send(result);
2796 }
2797 NetworkCommand::Subscribe { topic, response } => {
2798 let result = (|| {
2799 let topic_obj = IdentTopic::new(&topic);
2800 let topic_hash = topic_obj.hash();
2801
2802 state
2803 .swarm
2804 .behaviour_mut()
2805 .subscribe(&topic_obj)
2806 .map_err(|e| NetworkError::SubscriptionError(e.to_string()))?;
2807
2808 let (tx, rx) = mpsc::unbounded_channel();
2809 state
2810 .subscribers
2811 .entry(topic_hash)
2812 .or_default()
2813 .push(tx);
2814
2815 Ok(rx)
2816 })();
2817
2818 let _ = response.send(result);
2819 }
2820 NetworkCommand::ConnectedPeers { response } => {
2821 let peers: Vec<PeerId> = state.swarm.connected_peers().cloned().collect();
2822 let _ = response.send(Ok(peers));
2823 }
2824 NetworkCommand::PeerInfo { peer_id, response } => {
2825 let info = state.peer_manager.get_peer(&peer_id);
2826 let _ = response.send(Ok(info));
2827 }
2828 NetworkCommand::BanPeer { peer_id, response } => {
2829 // Record in our peer manager (reputation, persistence).
2830 state.peer_manager.ban_peer(&peer_id);
2831 // Enforce at the libp2p transport layer via allow_block_list behaviour —
2832 // this forcibly closes existing connections and rejects future dials.
2833 state.swarm.behaviour_mut().block_peer(peer_id);
2834 tracing::info!("Peer {} banned and blocked at libp2p layer", peer_id);
2835 let _ = response.send(Ok(()));
2836 }
2837 NetworkCommand::UnbanPeer { peer_id, response } => {
2838 state.peer_manager.unban_peer(&peer_id);
2839 // Remove the libp2p-layer block so the peer can reconnect.
2840 state.swarm.behaviour_mut().unblock_peer(peer_id);
2841 tracing::info!("Peer {} unbanned and unblocked at libp2p layer", peer_id);
2842 let _ = response.send(Ok(()));
2843 }
2844 NetworkCommand::LocalPeerId { response } => {
2845 let peer_id = *state.swarm.local_peer_id();
2846 let _ = response.send(Ok(peer_id));
2847 }
2848 NetworkCommand::Dial { addr, response } => {
2849 let result = state
2850 .swarm
2851 .dial(addr)
2852 .map_err(|e| NetworkError::Connection(e.to_string()));
2853 let _ = response.send(result);
2854 }
2855 NetworkCommand::SetValidatorRegistry { registry, response } => {
2856 state.peer_manager.set_validator_registry(registry);
2857 tracing::info!("Validator registry installed in peer manager");
2858 let _ = response.send(Ok(()));
2859 }
2860 NetworkCommand::MeshPeerCount { topic, response } => {
2861 let topic_hash = IdentTopic::new(topic).hash();
2862 let count = state
2863 .swarm
2864 .behaviour()
2865 .mesh_peers(&topic_hash)
2866 .len();
2867 let _ = response.send(Ok(count));
2868 }
2869 NetworkCommand::ListenAddresses { response } => {
2870 let _ = response.send(Ok(state.listen_addresses.clone()));
2871 }
2872 NetworkCommand::AdmittedMeshPeers { topic, response } => {
2873 let topic_hash = IdentTopic::new(topic).hash();
2874 // Snapshot the mesh peer set first, then dereference into owned PeerIds.
2875 let mesh: Vec<PeerId> = state
2876 .swarm
2877 .behaviour()
2878 .mesh_peers(&topic_hash)
2879 .into_iter()
2880 .copied()
2881 .collect();
2882 let admitted = match state.peer_manager.validator_registry() {
2883 None => {
2884 // Permissive mode (no registry) — every mesh peer is "admitted".
2885 mesh.len()
2886 }
2887 Some(registry) => {
2888 let validator_set = registry.validator_peer_ids();
2889 mesh.iter().filter(|p| validator_set.contains(*p)).count()
2890 }
2891 };
2892 let _ = response.send(Ok(admitted));
2893 }
2894 NetworkCommand::SendBlockSyncRequest { peer, request, response } => {
2895 let request_id = state
2896 .swarm
2897 .behaviour_mut()
2898 .block_sync
2899 .send_request(&peer, request);
2900 let _ = response.send(Ok(request_id));
2901 }
2902 NetworkCommand::SendBlockSyncResponse {
2903 request_id,
2904 response_payload,
2905 response,
2906 } => {
2907 let result = match state.pending_inbound_block_sync.remove(&request_id) {
2908 Some(channel) => state
2909 .swarm
2910 .behaviour_mut()
2911 .block_sync
2912 .send_response(channel, response_payload)
2913 .map_err(|_| {
2914 // send_response returns Err(BlockSyncResponse) on a closed
2915 // channel — peer disconnected or the inbound stream timed
2916 // out. The payload is dropped; surface a typed error.
2917 NetworkError::ChannelSend
2918 }),
2919 None => Err(NetworkError::PeerNotFound(format!(
2920 "no parked inbound block-sync request for id {}",
2921 request_id
2922 ))),
2923 };
2924 let _ = response.send(result);
2925 }
2926 NetworkCommand::SubscribeBlockSyncRequests { response } => {
2927 let (tx, rx) = mpsc::unbounded_channel();
2928 state.block_sync_request_subscriber = Some(tx);
2929 state.block_sync_request_subscriber_ever_attached = true;
2930 let _ = response.send(Ok(rx));
2931 }
2932 NetworkCommand::SubscribeBlockSyncResults { response } => {
2933 let (tx, rx) = mpsc::unbounded_channel();
2934 state.block_sync_result_subscriber = Some(tx);
2935 let _ = response.send(Ok(rx));
2936 }
2937 NetworkCommand::SubscribePeerEvents { response } => {
2938 let (tx, rx) = mpsc::unbounded_channel();
2939 state.peer_event_subscriber = Some(tx);
2940 let _ = response.send(Ok(rx));
2941 }
2942 NetworkCommand::BroadcastToValidators { message, response } => {
2943 // Snapshot the validator set at send time. Per the
2944 // `ValidatorRegistry` trait contract, `validator_peer_ids()` is
2945 // a cheap clone of the current admitted-validator set.
2946 let result = match state.peer_manager.validator_registry() {
2947 None => Err(NetworkError::InvalidConfig(
2948 "consensus-direct broadcast requires an installed ValidatorRegistry"
2949 .to_string(),
2950 )),
2951 Some(registry) => {
2952 let validator_set = registry.validator_peer_ids();
2953 let local = *state.swarm.local_peer_id();
2954 let request = ConsensusDirectRequest::Message(message);
2955 let mut dispatched = 0usize;
2956 for peer in validator_set.iter() {
2957 if *peer == local {
2958 // Skip self — consensus engine consumes its own
2959 // outbound messages directly via the in-process
2960 // channel, not over the wire.
2961 continue;
2962 }
2963 let _request_id = state
2964 .swarm
2965 .behaviour_mut()
2966 .consensus_direct
2967 .send_request(peer, request.clone());
2968 dispatched += 1;
2969 }
2970 Ok(dispatched)
2971 }
2972 };
2973 let _ = response.send(result);
2974 }
2975 NetworkCommand::SubscribeConsensusDirect { response } => {
2976 let (tx, rx) = mpsc::unbounded_channel();
2977 state.consensus_direct_subscriber = Some(tx);
2978 state.consensus_direct_subscriber_ever_attached = true;
2979 let _ = response.send(Ok(rx));
2980 }
2981 NetworkCommand::ConnectedValidatorCount { response } => {
2982 let count = match state.peer_manager.validator_registry() {
2983 None => 0,
2984 Some(registry) => {
2985 let validator_set = registry.validator_peer_ids();
2986 let local = *state.swarm.local_peer_id();
2987 state
2988 .swarm
2989 .connected_peers()
2990 .filter(|p| **p != local && validator_set.contains(*p))
2991 .count()
2992 }
2993 };
2994 let _ = response.send(Ok(count));
2995 }
2996 NetworkCommand::SetMpcDidResolver { resolver, response } => {
2997 state.mpc_did_resolver = Some(resolver);
2998 let _ = response.send(Ok(()));
2999 }
3000 NetworkCommand::SendMpcRelayMessage { message, response } => {
3001 let result = match state.mpc_did_resolver.as_ref() {
3002 None => Err(NetworkError::InvalidConfig(
3003 "mpc-relay send requires an installed MpcDidResolver".to_string(),
3004 )),
3005 Some(resolver) => match resolver.peer_id_for_did(&message.to_did) {
3006 None => Err(NetworkError::PeerNotFound(message.to_did.clone())),
3007 Some(peer) => {
3008 let _req_id = state
3009 .swarm
3010 .behaviour_mut()
3011 .mpc_relay
3012 .send_request(&peer, message);
3013 Ok(())
3014 }
3015 },
3016 };
3017 let _ = response.send(result);
3018 }
3019 NetworkCommand::SubscribeMpcRelay { response } => {
3020 let (tx, rx) = mpsc::unbounded_channel();
3021 state.mpc_relay_subscriber = Some(tx);
3022 state.mpc_relay_subscriber_ever_attached = true;
3023 let _ = response.send(Ok(rx));
3024 }
3025 NetworkCommand::Shutdown { response } => {
3026 // Respond before the outer loop observes the Shutdown variant and breaks.
3027 let _ = response.send(Ok(()));
3028 }
3029 }
3030}
3031
3032#[cfg(test)]
3033mod tests {
3034 use super::*;
3035
3036 fn ma(s: &str) -> Multiaddr {
3037 s.parse().expect("valid multiaddr")
3038 }
3039
3040 #[test]
3041 fn extract_port_pulls_tcp_port() {
3042 assert_eq!(extract_port(&ma("/ip4/35.184.63.8/tcp/9000")), Some(9000));
3043 }
3044
3045 #[test]
3046 fn extract_port_pulls_udp_port_for_quic() {
3047 assert_eq!(
3048 extract_port(&ma("/ip4/35.184.63.8/udp/9000/quic-v1")),
3049 Some(9000)
3050 );
3051 }
3052
3053 #[test]
3054 fn extract_port_handles_p2p_suffix() {
3055 let m = ma("/ip4/10.0.0.5/tcp/9000/p2p/12D3KooWGgjoKhKXBvN6jFWqn5sJE6KD38bXtNaoE6itbRUjGBxK");
3056 assert_eq!(extract_port(&m), Some(9000));
3057 }
3058
3059 #[test]
3060 fn observed_port_accepted_when_matches_listen() {
3061 // Wildcard listen on tcp/9000 — peer observes us at our public IP
3062 // on tcp/9000 (correct, dial-back-reachable address).
3063 let listen = vec![ma("/ip4/0.0.0.0/tcp/9000")];
3064 let observed = ma("/ip4/35.184.63.8/tcp/9000");
3065 assert!(is_observed_port_one_of_ours(&observed, &listen));
3066 }
3067
3068 #[test]
3069 fn observed_port_rejected_when_ephemeral_source_port() {
3070 // The exact bug pattern: v0 listens on tcp/9000, dials a peer,
3071 // peer sees the connection coming from the NAT'd source port 39692
3072 // and reports `(public_ip, 39692)` in Identify. We must NOT
3073 // promote 39692 — nothing is listening there.
3074 let listen = vec![ma("/ip4/0.0.0.0/tcp/9000")];
3075 let observed = ma("/ip4/35.184.63.8/tcp/39692");
3076 assert!(!is_observed_port_one_of_ours(&observed, &listen));
3077 }
3078
3079 #[test]
3080 fn observed_port_accepted_against_quic_listener() {
3081 // A QUIC listener on udp/9000 should accept a tcp/9000 observation
3082 // *iff* the ports match — `extract_port` is transport-agnostic on
3083 // the comparison key, which is what we want for a node that
3084 // simultaneously listens on both transports at the same port (the
3085 // tenzro-node universal default).
3086 let listen = vec![ma("/ip4/0.0.0.0/udp/9000/quic-v1")];
3087 let observed = ma("/ip4/35.184.63.8/tcp/9000");
3088 assert!(is_observed_port_one_of_ours(&observed, &listen));
3089 }
3090
3091 #[test]
3092 fn observed_port_rejected_when_no_listeners() {
3093 let listen: Vec<Multiaddr> = vec![];
3094 let observed = ma("/ip4/35.184.63.8/tcp/9000");
3095 assert!(!is_observed_port_one_of_ours(&observed, &listen));
3096 }
3097
3098 #[test]
3099 fn observed_port_rejected_when_no_port_in_observation() {
3100 // Pathological / malformed observation with no transport port —
3101 // can't match anything, must reject.
3102 let listen = vec![ma("/ip4/0.0.0.0/tcp/9000")];
3103 let observed = ma("/ip4/35.184.63.8");
3104 assert!(!is_observed_port_one_of_ours(&observed, &listen));
3105 }
3106}
3107