Skip to main content

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