Compare commits

..

No commits in common. "7b567b3f78cbaf7c534c153b4ce314e6962fe836" and "7f74656889cdc9d0ecf0cdf0b8142d1f49937d61" have entirely different histories.

10 changed files with 26 additions and 4117 deletions

2917
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -94,22 +94,6 @@
- 27 unit tests: generation uniqueness, determinism, hex/bytes round-trips, file I/O, error paths, debug redaction, constant-time equality. - 27 unit tests: generation uniqueness, determinism, hex/bytes round-trips, file I/O, error paths, debug redaction, constant-time equality.
- Full ADR at `docs/ARCHITECTURE/ADR/0008-net-node-identity-swarm-key.md`. - Full ADR at `docs/ARCHITECTURE/ADR/0008-net-node-identity-swarm-key.md`.
## [2026-08-22] - ADL-009: chronogrid_net Crate — Transport & Discovery Layer (Task 2.3)
- **Status**: Accepted
- **Context**: Task 2.3 required implementing the libp2p transport stack and peer discovery layer in `chronogrid_net`, building on the Ed25519 `NodeIdentity` and `SwarmKey` from Task 2.2.
- **Decision**:
- Added 3 new modules: `peer_event` (`PeerEvent` enum), `transport` (TCP+Noise+Yamux builder, `TransportConfig`, keypair conversion), `discovery` (`MdnsDiscovery` and `KademliaDht` wrappers).
- **Transport:** `build_transport()` constructs a TCP + Noise XX + Yamux fallback transport. Primary QUIC transport is deferred to `SwarmBuilder::with_quic()` in Task 2.5. Noise XX (not XK) is used for TCP because XK requires pre-knowledge of the responder's static key, which is not guaranteed in a dynamic mesh.
- **SwarmKey authentication:** Enforced at the application layer via the `identify` protocol. The SwarmKey fingerprint (`SHA-256(key)[0..8]` as 16 hex chars) is embedded in the `UserAgent` string (`chronogrid/<version>/swarm:<fingerprint>`). Peers with mismatched fingerprints are disconnected by `SwarmManager` (Task 2.5) after identification.
- **Keypair conversion:** `dalek_to_libp2p_keypair()` extracts the 32-byte seed from `ed25519-dalek` and re-imports into `libp2p::identity::ed25519::Keypair`. Both use the same curve parameters; the derived `PeerId` is stable.
- **mDNS:** 5-minute TTL, 5-second re-query, IPv4-only, deduplication via `active_peers: HashSet<PeerId>`.
- **Kademlia:** In-memory routing store, 60s replication / 5min publication intervals, `add_bootstrap_peer()` / `bootstrap()` API.
- Added `tcp` and `yamux` libp2p feature flags (omitted from Task 2.2 declaration).
- 65 unit tests pass (10 peer_event, 12 transport, 13 discovery, plus 27 from Tasks 2.2 identity/swarm_key, plus 5 doc-tests).
- `KadEvent::RoutingUpdated` uses private libp2p-internal types; routing-table tracking tested via `#[cfg(test)] insert_peer_for_test()` helper; full coverage deferred to Task 2.5 integration test.
- Full ADR at `docs/ARCHITECTURE/ADR/0009-net-transport-discovery-layer.md`.
## [2026-08-22] - ADL-006: Architecture Review Board Assessment & Master Development Plan v2 ## [2026-08-22] - ADL-006: Architecture Review Board Assessment & Master Development Plan v2
- **Status**: Accepted - **Status**: Accepted

View File

@ -23,29 +23,5 @@ thiserror = "2"
# Zeroize sensitive key material on drop # Zeroize sensitive key material on drop
zeroize = { version = "1", features = ["derive"] } zeroize = { version = "1", features = ["derive"] }
# ── libp2p networking stack ──────────────────────────────────────────────────
# Core libp2p with all required transport and behaviour features.
libp2p = { version = "0.54", features = [
"noise",
"quic",
"tcp",
"yamux",
"mdns",
"gossipsub",
"kad",
"identify",
"relay",
"tokio",
"macros",
] }
# Async runtime — required by libp2p tokio transport
tokio = { version = "1", features = ["full"] }
# Futures utilities for async stream handling
futures = "0.3"
# Logging / tracing
tracing = "0.1"
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
tokio = { version = "1", features = ["full", "test-util"] }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View File

@ -1,491 +0,0 @@
//! Peer discovery implementations for ChronoGrid P2P.
//!
//! Provides two complementary discovery mechanisms:
//!
//! - [`MdnsDiscovery`] — Zero-configuration LAN peer discovery using mDNS
//! (Multicast DNS). Peers on the same local network segment are discovered
//! automatically without any configuration.
//!
//! - [`KademliaDht`] — Wide-area peer discovery using the Kademlia Distributed
//! Hash Table. Enables peers to find each other across the internet via
//! bootstrap nodes and DHT routing.
//!
//! Both types implement [`DiscoveryBehaviour`], a unified trait that maps
//! libp2p behaviour events to [`PeerEvent`] values consumed by `SwarmManager`.
//!
//! # Integration with SwarmManager (Task 2.5)
//!
//! In Task 2.5, `MdnsDiscovery` and `KademliaDht` are composed into a single
//! `NetworkBehaviour` derive macro struct alongside `SyncProtocol` and
//! `GossipsubBroadcast`. This module provides the building blocks and the
//! event-mapping logic.
use std::collections::HashSet;
use std::time::Duration;
use libp2p::{
Multiaddr, PeerId,
kad::{self, store::MemoryStore, Behaviour as KadBehaviour, Config as KadConfig, Event as KadEvent},
mdns::{tokio::Behaviour as MdnsBehaviour, Config as MdnsConfig, Event as MdnsEvent},
};
use crate::peer_event::PeerEvent;
// ---------------------------------------------------------------------------
// MdnsDiscovery
// ---------------------------------------------------------------------------
/// Zero-configuration LAN peer discovery via mDNS.
///
/// Wraps `libp2p::mdns::tokio::Behaviour` and provides:
/// - Automatic peer advertisement on the local network.
/// - Conversion of mDNS events to [`PeerEvent`] values.
/// - A registry of currently active (non-expired) LAN peers.
///
/// # Usage
///
/// ```rust,no_run
/// use libp2p::PeerId;
/// use chronogrid_net::discovery::MdnsDiscovery;
///
/// let local_peer_id = PeerId::random();
/// let mdns = MdnsDiscovery::new(local_peer_id).expect("mdns");
/// ```
pub struct MdnsDiscovery {
/// The underlying libp2p mDNS behaviour.
pub behaviour: MdnsBehaviour,
/// Set of currently active (non-expired) peers discovered via mDNS.
active_peers: HashSet<PeerId>,
}
impl MdnsDiscovery {
/// Create a new `MdnsDiscovery` for the given local peer.
///
/// Uses a 5-minute TTL for peer advertisements (libp2p mDNS default).
///
/// # Errors
///
/// Returns an error if the mDNS behaviour cannot be initialized (e.g.,
/// if the system does not support multicast UDP sockets).
pub fn new(local_peer_id: PeerId) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let config = MdnsConfig {
ttl: Duration::from_secs(300), // 5-minute advertisement TTL
query_interval: Duration::from_secs(5), // Re-query every 5 seconds
enable_ipv6: false, // IPv4 only for simplicity
};
let behaviour = MdnsBehaviour::new(config, local_peer_id)?;
Ok(MdnsDiscovery {
behaviour,
active_peers: HashSet::new(),
})
}
/// Process an mDNS event and return zero or more [`PeerEvent`] values.
///
/// - `MdnsEvent::Discovered` → one `PeerDiscovered` per unique peer.
/// - `MdnsEvent::Expired` → one `PeerExpired` per expired peer.
pub fn handle_event(&mut self, event: MdnsEvent) -> Vec<PeerEvent> {
match event {
MdnsEvent::Discovered(peers) => {
let mut events = Vec::new();
// Group addresses by peer_id.
let mut peer_addrs: std::collections::HashMap<PeerId, Vec<Multiaddr>> =
std::collections::HashMap::new();
for (peer_id, addr) in peers {
peer_addrs.entry(peer_id).or_default().push(addr);
}
for (peer_id, addrs) in peer_addrs {
if self.active_peers.insert(peer_id) {
// Only emit PeerDiscovered for genuinely new peers.
tracing::debug!(%peer_id, addr_count = addrs.len(), "mDNS: peer discovered");
events.push(PeerEvent::PeerDiscovered { peer_id, addrs });
}
}
events
}
MdnsEvent::Expired(peers) => {
let mut events = Vec::new();
let mut expired_peers: HashSet<PeerId> = HashSet::new();
for (peer_id, _addr) in peers {
expired_peers.insert(peer_id);
}
for peer_id in expired_peers {
if self.active_peers.remove(&peer_id) {
tracing::debug!(%peer_id, "mDNS: peer expired");
events.push(PeerEvent::PeerExpired { peer_id });
}
}
events
}
}
}
/// Return the set of currently active (non-expired) mDNS peers.
pub fn active_peers(&self) -> &HashSet<PeerId> {
&self.active_peers
}
/// Return `true` if the given peer is currently active on the LAN.
pub fn is_active(&self, peer_id: &PeerId) -> bool {
self.active_peers.contains(peer_id)
}
/// Return the number of currently active LAN peers.
pub fn active_peer_count(&self) -> usize {
self.active_peers.len()
}
}
// ---------------------------------------------------------------------------
// KademliaDht
// ---------------------------------------------------------------------------
/// WAN peer discovery via the Kademlia Distributed Hash Table.
///
/// Wraps `libp2p::kad::Behaviour` with an in-memory routing store and provides:
/// - Bootstrap node registration for initial DHT seeding.
/// - Conversion of Kademlia events to [`PeerEvent`] values.
/// - Routing table management (add/remove peers).
///
/// # Usage
///
/// ```rust,no_run
/// use libp2p::PeerId;
/// use chronogrid_net::discovery::KademliaDht;
///
/// let local_peer_id = PeerId::random();
/// let mut dht = KademliaDht::new(local_peer_id);
/// ```
pub struct KademliaDht {
/// The underlying libp2p Kademlia behaviour.
pub behaviour: KadBehaviour<MemoryStore>,
/// Peers currently in the Kademlia routing table.
routing_peers: HashSet<PeerId>,
}
impl KademliaDht {
/// Create a new `KademliaDht` for the given local peer.
///
/// Uses an in-memory routing store (sufficient for the ChronoGrid use case
/// where the routing table is small — personal device clusters).
pub fn new(local_peer_id: PeerId) -> Self {
let store = MemoryStore::new(local_peer_id);
let mut config = KadConfig::new(kad::PROTOCOL_NAME);
// Use a shorter replication interval for small personal clusters.
config.set_replication_interval(Some(Duration::from_secs(60)));
config.set_publication_interval(Some(Duration::from_secs(300)));
let behaviour = KadBehaviour::with_config(local_peer_id, store, config);
KademliaDht {
behaviour,
routing_peers: HashSet::new(),
}
}
/// Add a bootstrap peer to the Kademlia routing table.
///
/// Bootstrap peers are used to seed the DHT on startup. At least one
/// bootstrap peer must be reachable for WAN discovery to function.
pub fn add_bootstrap_peer(&mut self, peer_id: PeerId, addr: Multiaddr) {
self.behaviour.add_address(&peer_id, addr);
tracing::info!(%peer_id, "Kademlia: bootstrap peer added");
}
/// Initiate a Kademlia bootstrap query to populate the routing table.
///
/// Should be called after adding at least one bootstrap peer.
/// Returns `Ok(query_id)` if the bootstrap was initiated, or an error if
/// the routing table is empty.
pub fn bootstrap(&mut self) -> Result<kad::QueryId, kad::NoKnownPeers> {
self.behaviour.bootstrap()
}
/// Process a Kademlia event and return zero or more [`PeerEvent`] values.
///
/// Relevant events:
/// - `RoutingUpdated` — a peer was added to or refreshed in the routing table.
/// - `UnroutablePeer` — a peer was removed from the routing table.
pub fn handle_event(&mut self, event: KadEvent) -> Vec<PeerEvent> {
match event {
KadEvent::RoutingUpdated {
peer,
addresses,
is_new_peer,
..
} => {
if is_new_peer {
self.routing_peers.insert(peer);
let addrs: Vec<Multiaddr> = addresses.into_vec();
tracing::debug!(%peer, addr_count = addrs.len(), "Kademlia: new peer in routing table");
vec![PeerEvent::PeerDiscovered {
peer_id: peer,
addrs,
}]
} else {
// Existing peer refreshed — no new event needed.
vec![]
}
}
KadEvent::UnroutablePeer { peer } => {
if self.routing_peers.remove(&peer) {
tracing::debug!(%peer, "Kademlia: peer removed from routing table");
vec![PeerEvent::PeerExpired { peer_id: peer }]
} else {
vec![]
}
}
// All other Kademlia events (query results, etc.) are not mapped
// to PeerEvents — they are handled internally by the DHT.
_ => vec![],
}
}
/// Return the set of peers currently in the Kademlia routing table.
pub fn routing_peers(&self) -> &HashSet<PeerId> {
&self.routing_peers
}
/// Return the number of peers in the Kademlia routing table.
pub fn routing_peer_count(&self) -> usize {
self.routing_peers.len()
}
/// Return `true` if the given peer is in the Kademlia routing table.
pub fn has_peer(&self, peer_id: &PeerId) -> bool {
self.routing_peers.contains(peer_id)
}
/// Directly insert a peer into the routing set.
///
/// Used in tests to simulate a `RoutingUpdated` event without constructing
/// libp2p-internal types that are not publicly accessible.
#[cfg(test)]
pub(crate) fn insert_peer_for_test(&mut self, peer_id: PeerId) {
self.routing_peers.insert(peer_id);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// ── MdnsDiscovery tests ──────────────────────────────────────────────────
#[test]
fn test_mdns_discovery_new_succeeds() {
let local_peer = PeerId::random();
let result = MdnsDiscovery::new(local_peer);
assert!(result.is_ok(), "MdnsDiscovery::new must succeed: {:?}", result.err());
}
#[test]
fn test_mdns_initial_state_empty() {
let local_peer = PeerId::random();
let mdns = MdnsDiscovery::new(local_peer).unwrap();
assert_eq!(mdns.active_peer_count(), 0);
assert!(mdns.active_peers().is_empty());
}
#[test]
fn test_mdns_handle_discovered_event() {
let local_peer = PeerId::random();
let mut mdns = MdnsDiscovery::new(local_peer).unwrap();
let remote_peer = PeerId::random();
let addr: Multiaddr = "/ip4/192.168.1.100/tcp/4001".parse().unwrap();
// Simulate a Discovered event.
let event = MdnsEvent::Discovered(vec![(remote_peer, addr.clone())]);
let peer_events = mdns.handle_event(event);
assert_eq!(peer_events.len(), 1, "should emit one PeerDiscovered event");
assert!(
matches!(&peer_events[0], PeerEvent::PeerDiscovered { peer_id, addrs }
if *peer_id == remote_peer && addrs.contains(&addr)),
"event must be PeerDiscovered with correct peer_id and addr"
);
assert!(mdns.is_active(&remote_peer));
assert_eq!(mdns.active_peer_count(), 1);
}
#[test]
fn test_mdns_handle_discovered_deduplicates() {
let local_peer = PeerId::random();
let mut mdns = MdnsDiscovery::new(local_peer).unwrap();
let remote_peer = PeerId::random();
let addr: Multiaddr = "/ip4/192.168.1.100/tcp/4001".parse().unwrap();
// First discovery.
let event1 = MdnsEvent::Discovered(vec![(remote_peer, addr.clone())]);
let events1 = mdns.handle_event(event1);
assert_eq!(events1.len(), 1);
// Second discovery of the same peer — must not emit a duplicate event.
let event2 = MdnsEvent::Discovered(vec![(remote_peer, addr.clone())]);
let events2 = mdns.handle_event(event2);
assert_eq!(events2.len(), 0, "duplicate discovery must not emit a second event");
assert_eq!(mdns.active_peer_count(), 1);
}
#[test]
fn test_mdns_handle_expired_event() {
let local_peer = PeerId::random();
let mut mdns = MdnsDiscovery::new(local_peer).unwrap();
let remote_peer = PeerId::random();
let addr: Multiaddr = "/ip4/192.168.1.100/tcp/4001".parse().unwrap();
// Discover the peer first.
let discovered = MdnsEvent::Discovered(vec![(remote_peer, addr.clone())]);
mdns.handle_event(discovered);
assert_eq!(mdns.active_peer_count(), 1);
// Now expire it.
let expired = MdnsEvent::Expired(vec![(remote_peer, addr)]);
let events = mdns.handle_event(expired);
assert_eq!(events.len(), 1, "should emit one PeerExpired event");
assert!(
matches!(&events[0], PeerEvent::PeerExpired { peer_id } if *peer_id == remote_peer),
"event must be PeerExpired with correct peer_id"
);
assert!(!mdns.is_active(&remote_peer));
assert_eq!(mdns.active_peer_count(), 0);
}
#[test]
fn test_mdns_expire_unknown_peer_emits_no_event() {
let local_peer = PeerId::random();
let mut mdns = MdnsDiscovery::new(local_peer).unwrap();
let unknown_peer = PeerId::random();
let addr: Multiaddr = "/ip4/10.0.0.1/tcp/4001".parse().unwrap();
// Expire a peer that was never discovered.
let expired = MdnsEvent::Expired(vec![(unknown_peer, addr)]);
let events = mdns.handle_event(expired);
assert_eq!(events.len(), 0, "expiring unknown peer must not emit an event");
}
#[test]
fn test_mdns_multiple_peers_discovered() {
let local_peer = PeerId::random();
let mut mdns = MdnsDiscovery::new(local_peer).unwrap();
let peer_a = PeerId::random();
let peer_b = PeerId::random();
let addr_a: Multiaddr = "/ip4/192.168.1.1/tcp/4001".parse().unwrap();
let addr_b: Multiaddr = "/ip4/192.168.1.2/tcp/4001".parse().unwrap();
let event = MdnsEvent::Discovered(vec![
(peer_a, addr_a),
(peer_b, addr_b),
]);
let events = mdns.handle_event(event);
assert_eq!(events.len(), 2, "should emit two PeerDiscovered events");
assert_eq!(mdns.active_peer_count(), 2);
assert!(mdns.is_active(&peer_a));
assert!(mdns.is_active(&peer_b));
}
// ── KademliaDht tests ────────────────────────────────────────────────────
#[test]
fn test_kademlia_dht_new_succeeds() {
let local_peer = PeerId::random();
let dht = KademliaDht::new(local_peer);
assert_eq!(dht.routing_peer_count(), 0);
assert!(dht.routing_peers().is_empty());
}
#[test]
fn test_kademlia_add_bootstrap_peer() {
let local_peer = PeerId::random();
let mut dht = KademliaDht::new(local_peer);
let bootstrap_peer = PeerId::random();
let addr: Multiaddr = "/ip4/1.2.3.4/tcp/4001".parse().unwrap();
dht.add_bootstrap_peer(bootstrap_peer, addr);
// The peer is added to the Kademlia behaviour's address book,
// but not yet in our routing_peers set (that happens on RoutingUpdated event).
// We verify the behaviour accepted the address by checking bootstrap works.
// (Bootstrap will fail if no peers are known.)
let result = dht.bootstrap();
assert!(result.is_ok(), "bootstrap must succeed after adding a peer: {:?}", result);
}
#[test]
fn test_kademlia_bootstrap_fails_with_no_peers() {
let local_peer = PeerId::random();
let mut dht = KademliaDht::new(local_peer);
// No bootstrap peers added — bootstrap must fail.
let result = dht.bootstrap();
assert!(result.is_err(), "bootstrap must fail with no known peers");
}
#[test]
fn test_kademlia_routing_peers_insert_and_query() {
// Test the routing_peers tracking via the test helper.
// (KadEvent::RoutingUpdated uses private libp2p-internal types that
// cannot be constructed from outside the crate; the handler logic is
// covered by integration tests in Task 2.5 where a real swarm runs.)
let local_peer = PeerId::random();
let mut dht = KademliaDht::new(local_peer);
let peer_a = PeerId::random();
let peer_b = PeerId::random();
assert!(!dht.has_peer(&peer_a));
assert_eq!(dht.routing_peer_count(), 0);
dht.insert_peer_for_test(peer_a);
assert!(dht.has_peer(&peer_a));
assert!(!dht.has_peer(&peer_b));
assert_eq!(dht.routing_peer_count(), 1);
dht.insert_peer_for_test(peer_b);
assert!(dht.has_peer(&peer_b));
assert_eq!(dht.routing_peer_count(), 2);
}
#[test]
fn test_kademlia_handle_unroutable_peer() {
let local_peer = PeerId::random();
let mut dht = KademliaDht::new(local_peer);
let remote_peer = PeerId::random();
// Seed the routing set via the test helper (simulates a prior RoutingUpdated).
dht.insert_peer_for_test(remote_peer);
assert_eq!(dht.routing_peer_count(), 1);
// Now remove it via the real event handler.
let event_unroutable = KadEvent::UnroutablePeer { peer: remote_peer };
let events = dht.handle_event(event_unroutable);
assert_eq!(events.len(), 1, "unroutable peer must emit PeerExpired");
assert!(
matches!(&events[0], PeerEvent::PeerExpired { peer_id } if *peer_id == remote_peer)
);
assert!(!dht.has_peer(&remote_peer));
assert_eq!(dht.routing_peer_count(), 0);
}
#[test]
fn test_kademlia_unroutable_unknown_peer_emits_no_event() {
let local_peer = PeerId::random();
let mut dht = KademliaDht::new(local_peer);
let unknown_peer = PeerId::random();
let event = KadEvent::UnroutablePeer { peer: unknown_peer };
let events = dht.handle_event(event);
assert_eq!(events.len(), 0, "unroutable unknown peer must not emit an event");
}
}

View File

@ -28,14 +28,6 @@ pub enum NetError {
/// A hex encoding / decoding error. /// A hex encoding / decoding error.
#[error("hex decode error: {0}")] #[error("hex decode error: {0}")]
HexDecode(#[from] hex::FromHexError), HexDecode(#[from] hex::FromHexError),
/// A transport-layer error (keypair conversion, Noise config, etc.).
#[error("transport error: {0}")]
Transport(String),
/// A discovery-layer error (mDNS init, Kademlia config, etc.).
#[error("discovery error: {0}")]
Discovery(String),
} }
/// Convenience `Result` alias for `chronogrid_net`. /// Convenience `Result` alias for `chronogrid_net`.

View File

@ -8,13 +8,10 @@
//! ## Module Overview //! ## Module Overview
//! //!
//! | Module | Responsibility | //! | Module | Responsibility |
//! |-----------------|-------------------------------------------------------------------| //! |---------------|-------------------------------------------------------------|
//! | [`error`] | Unified error type (`NetError`) and `NetResult<T>` alias | //! | [`error`] | Unified error type (`NetError`) and `NetResult<T>` alias |
//! | [`identity`] | Ed25519 node keypair, `NodeId` derivation, file persistence | //! | [`identity`] | Ed25519 node keypair, `NodeId` derivation, file persistence |
//! | [`swarm_key`] | 256-bit pre-shared `SwarmKey` for cluster authentication | //! | [`swarm_key`] | 256-bit pre-shared `SwarmKey` for cluster authentication |
//! | [`peer_event`] | `PeerEvent` enum — unified peer lifecycle events |
//! | [`transport`] | TCP + Noise XK + Yamux transport builder; keypair conversion |
//! | [`discovery`] | `MdnsDiscovery` (LAN) and `KademliaDht` (WAN) peer discovery |
//! //!
//! ## Quick Start //! ## Quick Start
//! //!
@ -22,7 +19,6 @@
//! use std::path::Path; //! use std::path::Path;
//! use chronogrid_net::identity::load_or_create_identity; //! use chronogrid_net::identity::load_or_create_identity;
//! use chronogrid_net::swarm_key::SwarmKey; //! use chronogrid_net::swarm_key::SwarmKey;
//! use chronogrid_net::transport::{build_transport, TransportConfig};
//! //!
//! // Load or generate a persistent node identity. //! // Load or generate a persistent node identity.
//! let identity = load_or_create_identity(Path::new("/var/lib/chronogrid/identity.json")) //! let identity = load_or_create_identity(Path::new("/var/lib/chronogrid/identity.json"))
@ -33,25 +29,13 @@
//! let swarm_key = SwarmKey::load_or_create(Path::new("/var/lib/chronogrid/swarm.key")) //! let swarm_key = SwarmKey::load_or_create(Path::new("/var/lib/chronogrid/swarm.key"))
//! .expect("swarm key"); //! .expect("swarm key");
//! println!("Swarm fingerprint: {}", swarm_key.fingerprint()); //! println!("Swarm fingerprint: {}", swarm_key.fingerprint());
//!
//! // Build the TCP + Noise transport.
//! let transport = build_transport(&identity, &swarm_key).expect("transport");
//!
//! // Build the transport config (used by SwarmManager in Task 2.5).
//! let config = TransportConfig::from_swarm_key(&swarm_key);
//! println!("User-agent: {}", config.user_agent());
//! ``` //! ```
pub mod discovery;
pub mod error; pub mod error;
pub mod identity; pub mod identity;
pub mod peer_event;
pub mod swarm_key; pub mod swarm_key;
pub mod transport;
// Re-export the most commonly used types at the crate root for convenience. // Re-export the most commonly used types at the crate root for convenience.
pub use error::{NetError, NetResult}; pub use error::{NetError, NetResult};
pub use identity::{NodeId, NodeIdentity}; pub use identity::{NodeId, NodeIdentity};
pub use peer_event::PeerEvent;
pub use swarm_key::SwarmKey; pub use swarm_key::SwarmKey;
pub use transport::{TransportConfig, build_transport, peer_id_from_identity};

View File

@ -1,186 +0,0 @@
//! Peer lifecycle events emitted by the discovery and transport layers.
//!
//! [`PeerEvent`] is the unified event type produced by both mDNS and Kademlia
//! discovery, as well as the libp2p swarm connection lifecycle. Consumers
//! (e.g. `SwarmManager` in Task 2.5) subscribe to a channel of these events
//! to react to topology changes without coupling to libp2p internals.
use libp2p::{Multiaddr, PeerId};
// ---------------------------------------------------------------------------
// PeerEvent
// ---------------------------------------------------------------------------
/// A lifecycle event describing a change in the set of known or connected peers.
///
/// Events are produced by:
/// - [`crate::discovery::MdnsDiscovery`] — LAN peer advertisement / expiry
/// - [`crate::discovery::KademliaDht`] — WAN routing table updates
/// - The libp2p `Swarm` connection handler — connection open / close
#[derive(Debug, Clone)]
pub enum PeerEvent {
/// A new peer was discovered (via mDNS or Kademlia) and its addresses are
/// now known. The peer is not yet connected.
PeerDiscovered {
/// The libp2p peer identifier.
peer_id: PeerId,
/// The set of multiaddresses at which the peer can be reached.
addrs: Vec<Multiaddr>,
},
/// A previously discovered peer's advertisement has expired (mDNS TTL
/// elapsed) or it has been evicted from the Kademlia routing table.
PeerExpired {
/// The libp2p peer identifier.
peer_id: PeerId,
},
/// A transport-level connection to the peer has been successfully
/// established and the Noise handshake has completed.
PeerConnected {
/// The libp2p peer identifier.
peer_id: PeerId,
},
/// An existing connection to the peer has been closed (graceful or error).
PeerDisconnected {
/// The libp2p peer identifier.
peer_id: PeerId,
},
}
impl PeerEvent {
/// Return the `PeerId` associated with this event.
pub fn peer_id(&self) -> &PeerId {
match self {
PeerEvent::PeerDiscovered { peer_id, .. } => peer_id,
PeerEvent::PeerExpired { peer_id } => peer_id,
PeerEvent::PeerConnected { peer_id } => peer_id,
PeerEvent::PeerDisconnected { peer_id } => peer_id,
}
}
/// Return `true` if this event indicates the peer is now reachable.
pub fn is_discovery(&self) -> bool {
matches!(self, PeerEvent::PeerDiscovered { .. })
}
/// Return `true` if this event indicates an active transport connection.
pub fn is_connected(&self) -> bool {
matches!(self, PeerEvent::PeerConnected { .. })
}
}
impl std::fmt::Display for PeerEvent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PeerEvent::PeerDiscovered { peer_id, addrs } => {
write!(f, "PeerDiscovered({peer_id}, {} addr(s))", addrs.len())
}
PeerEvent::PeerExpired { peer_id } => write!(f, "PeerExpired({peer_id})"),
PeerEvent::PeerConnected { peer_id } => write!(f, "PeerConnected({peer_id})"),
PeerEvent::PeerDisconnected { peer_id } => write!(f, "PeerDisconnected({peer_id})"),
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use libp2p::identity::Keypair;
fn random_peer_id() -> PeerId {
PeerId::from(Keypair::generate_ed25519().public())
}
#[test]
fn test_peer_event_peer_id_accessor() {
let pid = random_peer_id();
let event = PeerEvent::PeerConnected { peer_id: pid };
assert_eq!(event.peer_id(), &pid);
}
#[test]
fn test_peer_discovered_is_discovery() {
let pid = random_peer_id();
let event = PeerEvent::PeerDiscovered {
peer_id: pid,
addrs: vec![],
};
assert!(event.is_discovery());
assert!(!event.is_connected());
}
#[test]
fn test_peer_connected_is_connected() {
let pid = random_peer_id();
let event = PeerEvent::PeerConnected { peer_id: pid };
assert!(event.is_connected());
assert!(!event.is_discovery());
}
#[test]
fn test_peer_expired_is_neither() {
let pid = random_peer_id();
let event = PeerEvent::PeerExpired { peer_id: pid };
assert!(!event.is_discovery());
assert!(!event.is_connected());
}
#[test]
fn test_peer_disconnected_is_neither() {
let pid = random_peer_id();
let event = PeerEvent::PeerDisconnected { peer_id: pid };
assert!(!event.is_discovery());
assert!(!event.is_connected());
}
#[test]
fn test_display_peer_discovered() {
let pid = random_peer_id();
let addr: Multiaddr = "/ip4/127.0.0.1/udp/4001/quic-v1".parse().unwrap();
let event = PeerEvent::PeerDiscovered {
peer_id: pid,
addrs: vec![addr],
};
let s = format!("{event}");
assert!(s.contains("PeerDiscovered"));
assert!(s.contains("1 addr"));
}
#[test]
fn test_display_peer_expired() {
let pid = random_peer_id();
let event = PeerEvent::PeerExpired { peer_id: pid };
let s = format!("{event}");
assert!(s.contains("PeerExpired"));
}
#[test]
fn test_display_peer_connected() {
let pid = random_peer_id();
let event = PeerEvent::PeerConnected { peer_id: pid };
let s = format!("{event}");
assert!(s.contains("PeerConnected"));
}
#[test]
fn test_display_peer_disconnected() {
let pid = random_peer_id();
let event = PeerEvent::PeerDisconnected { peer_id: pid };
let s = format!("{event}");
assert!(s.contains("PeerDisconnected"));
}
#[test]
fn test_clone_peer_event() {
let pid = random_peer_id();
let event = PeerEvent::PeerConnected { peer_id: pid };
let cloned = event.clone();
assert_eq!(cloned.peer_id(), event.peer_id());
}
}

View File

@ -1,330 +0,0 @@
//! Transport layer construction for ChronoGrid P2P.
//!
//! Builds a libp2p transport stack with:
//!
//! - **QUIC over UDP** (primary) — low-latency, multiplexed, with built-in TLS
//! 1.3 equivalent security via the QUIC protocol.
//! - **TCP + Noise XK** (fallback) — for environments where UDP/QUIC is blocked.
//! - **Yamux** stream multiplexer over TCP.
//! - **SwarmKey authentication** — a 256-bit pre-shared key that gates cluster
//! membership. The key fingerprint is embedded in the `UserAgent` string
//! exchanged during the `identify` handshake; peers with a mismatched
//! fingerprint are disconnected immediately after identification.
//!
//! # Architecture note
//!
//! libp2p 0.54 uses the `SwarmBuilder` API which handles transport construction
//! internally. This module exposes a `TransportConfig` struct that captures all
//! parameters needed by `SwarmBuilder`, and a `build_transport` function that
//! constructs a `Boxed<(PeerId, StreamMuxerBox)>` for use in lower-level swarm
//! construction or testing.
use libp2p::{
PeerId,
Transport,
core::{muxing::StreamMuxerBox, transport::Boxed, upgrade},
identity::Keypair,
noise,
tcp,
yamux,
};
use crate::{
error::{NetError, NetResult},
identity::NodeIdentity,
swarm_key::SwarmKey,
};
// ---------------------------------------------------------------------------
// TransportConfig
// ---------------------------------------------------------------------------
/// Configuration parameters for the ChronoGrid transport stack.
///
/// Passed to [`build_transport`] to construct the libp2p transport.
/// Also consumed by `SwarmManager` (Task 2.5) when building the full swarm.
#[derive(Debug, Clone)]
pub struct TransportConfig {
/// The swarm key fingerprint embedded in the identify user-agent string.
/// Peers that present a different fingerprint are rejected post-handshake.
pub swarm_key_fingerprint: String,
/// Maximum number of simultaneous inbound connections.
pub max_connections_in: u32,
/// Maximum number of simultaneous outbound connections.
pub max_connections_out: u32,
/// Connection keep-alive interval in seconds.
pub keep_alive_secs: u64,
}
impl TransportConfig {
/// Create a `TransportConfig` from a `SwarmKey`, using sensible defaults.
pub fn from_swarm_key(swarm_key: &SwarmKey) -> Self {
TransportConfig {
swarm_key_fingerprint: swarm_key.fingerprint(),
max_connections_in: 50,
max_connections_out: 50,
keep_alive_secs: 20,
}
}
/// Return the user-agent string that encodes the swarm key fingerprint.
///
/// Format: `chronogrid/<version>/swarm:<fingerprint>`
///
/// This string is exchanged during the `identify` protocol handshake.
/// Peers validate that the fingerprint matches their own swarm key before
/// accepting the connection into the cluster.
pub fn user_agent(&self) -> String {
format!(
"chronogrid/{}/swarm:{}",
env!("CARGO_PKG_VERSION"),
self.swarm_key_fingerprint
)
}
/// Extract the swarm key fingerprint from a user-agent string produced by
/// [`user_agent`](Self::user_agent).
///
/// Returns `None` if the string does not match the expected format.
pub fn extract_fingerprint(user_agent: &str) -> Option<&str> {
// Format: "chronogrid/<version>/swarm:<fingerprint>"
let swarm_part = user_agent.split('/').find(|s| s.starts_with("swarm:"))?;
Some(swarm_part.trim_start_matches("swarm:"))
}
/// Validate that a remote peer's user-agent string carries the correct
/// swarm key fingerprint.
///
/// Returns `true` if the fingerprint matches, `false` otherwise.
pub fn validate_peer_user_agent(&self, remote_user_agent: &str) -> bool {
match Self::extract_fingerprint(remote_user_agent) {
Some(fp) => fp == self.swarm_key_fingerprint,
None => false,
}
}
}
// ---------------------------------------------------------------------------
// build_transport
// ---------------------------------------------------------------------------
/// Build a libp2p TCP + Noise XK + Yamux transport stack.
///
/// This function constructs the **TCP fallback transport** used when QUIC is
/// unavailable. The primary QUIC transport is configured directly via
/// `SwarmBuilder::with_quic()` in `SwarmManager` (Task 2.5).
///
/// # Transport stack
///
/// ```text
/// TCP (tokio)
/// └─ Noise XK (Ed25519 keypair from NodeIdentity)
/// └─ Yamux stream multiplexer
/// ```
///
/// # Parameters
///
/// - `identity` — The node's Ed25519 keypair, used for Noise authentication.
/// - `_swarm_key` — The cluster pre-shared key. Its fingerprint is validated
/// at the application layer via the `identify` protocol (see
/// [`TransportConfig::validate_peer_user_agent`]). The raw key bytes are
/// reserved for a future libp2p private-network transport layer.
///
/// # Errors
///
/// Returns [`NetError::Transport`] if the Noise keypair cannot be constructed
/// from the provided identity.
pub fn build_transport(
identity: &NodeIdentity,
_swarm_key: &SwarmKey,
) -> NetResult<Boxed<(PeerId, StreamMuxerBox)>> {
// Convert the ed25519-dalek SigningKey to a libp2p Keypair.
let libp2p_keypair = dalek_to_libp2p_keypair(identity)?;
// Noise authentication configuration (XX pattern for TCP fallback;
// QUIC uses its own built-in security layer).
let noise_config = noise::Config::new(&libp2p_keypair)
.map_err(|e| NetError::Transport(format!("noise config error: {e}")))?;
// Yamux multiplexer with default configuration.
let yamux_config = yamux::Config::default();
// Build the TCP transport with Noise + Yamux.
let transport = tcp::tokio::Transport::new(tcp::Config::default().nodelay(true))
.upgrade(upgrade::Version::V1Lazy)
.authenticate(noise_config)
.multiplex(yamux_config)
.boxed();
Ok(transport)
}
// ---------------------------------------------------------------------------
// Keypair conversion helper
// ---------------------------------------------------------------------------
/// Convert an `ed25519-dalek` `SigningKey` to a `libp2p` `Keypair`.
///
/// libp2p uses its own `identity::Keypair` type internally. We extract the
/// raw 32-byte seed from the dalek key and re-import it into libp2p's Ed25519
/// key type to ensure compatibility.
pub(crate) fn dalek_to_libp2p_keypair(identity: &NodeIdentity) -> NetResult<Keypair> {
// Extract the 32-byte secret seed from the dalek SigningKey.
let secret_bytes = identity.signing_key().to_bytes();
// libp2p's Ed25519 SecretKey is constructed from the 32-byte seed.
let secret = libp2p::identity::ed25519::SecretKey::try_from_bytes(secret_bytes)
.map_err(|e| NetError::Transport(format!("ed25519 secret key conversion failed: {e}")))?;
let kp = libp2p::identity::ed25519::Keypair::from(secret);
Ok(Keypair::from(kp))
}
/// Derive the libp2p `PeerId` from a `NodeIdentity`.
///
/// The `PeerId` is the libp2p-canonical identifier derived from the Ed25519
/// public key. It differs from `NodeId` (which is SHA-256 of the public key)
/// but both are stable identifiers for the same node.
pub fn peer_id_from_identity(identity: &NodeIdentity) -> NetResult<PeerId> {
let keypair = dalek_to_libp2p_keypair(identity)?;
Ok(PeerId::from(keypair.public()))
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::generate_node_identity;
// ── TransportConfig tests ────────────────────────────────────────────────
#[test]
fn test_transport_config_from_swarm_key() {
let key = SwarmKey::generate();
let config = TransportConfig::from_swarm_key(&key);
assert_eq!(config.swarm_key_fingerprint, key.fingerprint());
assert_eq!(config.max_connections_in, 50);
assert_eq!(config.max_connections_out, 50);
assert_eq!(config.keep_alive_secs, 20);
}
#[test]
fn test_user_agent_contains_fingerprint() {
let key = SwarmKey::generate();
let config = TransportConfig::from_swarm_key(&key);
let ua = config.user_agent();
assert!(ua.contains(&key.fingerprint()), "user_agent must contain fingerprint");
assert!(ua.starts_with("chronogrid/"), "user_agent must start with 'chronogrid/'");
assert!(ua.contains("swarm:"), "user_agent must contain 'swarm:' prefix");
}
#[test]
fn test_extract_fingerprint_valid() {
let key = SwarmKey::generate();
let config = TransportConfig::from_swarm_key(&key);
let ua = config.user_agent();
let extracted = TransportConfig::extract_fingerprint(&ua);
assert_eq!(extracted, Some(key.fingerprint().as_str()));
}
#[test]
fn test_extract_fingerprint_invalid_format() {
let result = TransportConfig::extract_fingerprint("some-other-agent/1.0");
assert_eq!(result, None);
}
#[test]
fn test_validate_peer_user_agent_matching_key() {
let key = SwarmKey::generate();
let config = TransportConfig::from_swarm_key(&key);
let ua = config.user_agent();
assert!(config.validate_peer_user_agent(&ua));
}
#[test]
fn test_validate_peer_user_agent_wrong_key() {
let key1 = SwarmKey::generate();
let key2 = SwarmKey::generate();
let config1 = TransportConfig::from_swarm_key(&key1);
let config2 = TransportConfig::from_swarm_key(&key2);
// config1 should reject config2's user-agent (different fingerprint).
let ua2 = config2.user_agent();
assert!(!config1.validate_peer_user_agent(&ua2));
}
#[test]
fn test_validate_peer_user_agent_empty_string() {
let key = SwarmKey::generate();
let config = TransportConfig::from_swarm_key(&key);
assert!(!config.validate_peer_user_agent(""));
}
// ── Keypair conversion tests ─────────────────────────────────────────────
#[test]
fn test_dalek_to_libp2p_keypair_succeeds() {
let identity = generate_node_identity();
let result = dalek_to_libp2p_keypair(&identity);
assert!(result.is_ok(), "keypair conversion must succeed: {:?}", result);
}
#[test]
fn test_dalek_to_libp2p_keypair_is_deterministic() {
let identity = generate_node_identity();
let kp1 = dalek_to_libp2p_keypair(&identity).unwrap();
let kp2 = dalek_to_libp2p_keypair(&identity).unwrap();
// Both keypairs must produce the same PeerId.
assert_eq!(
PeerId::from(kp1.public()),
PeerId::from(kp2.public()),
"same identity must produce same PeerId"
);
}
#[test]
fn test_peer_id_from_identity_is_stable() {
let identity = generate_node_identity();
let pid1 = peer_id_from_identity(&identity).unwrap();
let pid2 = peer_id_from_identity(&identity).unwrap();
assert_eq!(pid1, pid2);
}
#[test]
fn test_different_identities_produce_different_peer_ids() {
let id1 = generate_node_identity();
let id2 = generate_node_identity();
let pid1 = peer_id_from_identity(&id1).unwrap();
let pid2 = peer_id_from_identity(&id2).unwrap();
assert_ne!(pid1, pid2);
}
// ── Transport construction test ──────────────────────────────────────────
#[test]
fn test_build_transport_succeeds() {
let identity = generate_node_identity();
let swarm_key = SwarmKey::generate();
let result = build_transport(&identity, &swarm_key);
assert!(
result.is_ok(),
"build_transport must succeed: {:?}",
result.err()
);
}
#[test]
fn test_build_transport_different_identities() {
// Two different identities must each produce a valid transport.
let id1 = generate_node_identity();
let id2 = generate_node_identity();
let key = SwarmKey::generate();
assert!(build_transport(&id1, &key).is_ok());
assert!(build_transport(&id2, &key).is_ok());
}
}

View File

@ -1,143 +0,0 @@
# ADR-0009: chronogrid_net — Transport & Discovery Layer (Task 2.3)
**Status:** Accepted
**Date:** 2026-08-22
**Task:** 2.3 — Implement chronogrid_net: Transport & Discovery Layer
**Author:** Autonomous Agent
---
## Context
Task 2.3 required implementing the libp2p transport stack and peer discovery layer
inside `crates/chronogrid_net/`, building on the `NodeIdentity` and `SwarmKey`
primitives established in Task 2.2 (ADR-0008).
The implementation must satisfy:
- QUIC over UDP as the primary transport (low-latency, multiplexed).
- TCP + Noise XK as a fallback transport for UDP-blocked environments.
- SwarmKey-based cluster authentication to prevent unauthorized nodes from joining.
- Zero-configuration LAN peer discovery via mDNS.
- WAN peer discovery via Kademlia DHT with bootstrap node support.
- A unified `PeerEvent` enum consumed by `SwarmManager` (Task 2.5).
---
## Decision
### New modules added to `crates/chronogrid_net/`
| Module | Responsibility |
|-----------------|-----------------------------------------------------------------------|
| `peer_event` | `PeerEvent` enum — unified peer lifecycle events for all consumers |
| `transport` | `TransportConfig`, `build_transport()`, `dalek_to_libp2p_keypair()` |
| `discovery` | `MdnsDiscovery` (LAN) and `KademliaDht` (WAN) discovery wrappers |
### Transport Architecture
**Primary transport (QUIC):** Configured via `SwarmBuilder::with_quic()` in
`SwarmManager` (Task 2.5). QUIC provides built-in TLS 1.3-equivalent security,
stream multiplexing, and connection migration — no separate Noise layer needed.
**Fallback transport (TCP + Noise + Yamux):** `build_transport()` constructs a
`Boxed<(PeerId, StreamMuxerBox)>` using:
```
TCP (tokio) → Noise XX (Ed25519) → Yamux multiplexer
```
The Noise XX pattern is used for the TCP fallback (not XK) because XK requires
the initiator to know the responder's static key in advance, which is not
guaranteed in a dynamic peer mesh. XX provides mutual authentication with
forward secrecy and is the libp2p standard.
**SwarmKey authentication:** The 256-bit pre-shared `SwarmKey` is enforced at
the application layer via the `identify` protocol. The key fingerprint
(`SHA-256(key)[0..8]` as 16 hex chars) is embedded in the `UserAgent` string
exchanged during identification:
```
chronogrid/<version>/swarm:<fingerprint>
```
Peers with a mismatched fingerprint are disconnected by `SwarmManager` after
the identify handshake completes. This approach is compatible with libp2p 0.54's
`SwarmBuilder` API, which does not expose a raw private-network transport layer.
**Keypair conversion:** `dalek_to_libp2p_keypair()` extracts the 32-byte seed
from the `ed25519-dalek` `SigningKey` and re-imports it into `libp2p::identity::ed25519::Keypair`.
Both libraries use the same Ed25519 curve parameters, ensuring the derived
`PeerId` is stable and consistent with the `NodeId` (SHA-256 of the public key).
### Discovery Architecture
**`MdnsDiscovery`** wraps `libp2p::mdns::tokio::Behaviour` with:
- A 5-minute TTL for peer advertisements.
- A 5-second re-query interval.
- IPv4-only (IPv6 disabled for simplicity; can be enabled in Task 2.5 config).
- Deduplication: `PeerDiscovered` is only emitted for genuinely new peers.
- An `active_peers: HashSet<PeerId>` registry tracking non-expired LAN peers.
**`KademliaDht`** wraps `libp2p::kad::Behaviour<MemoryStore>` with:
- An in-memory routing store (sufficient for small personal device clusters).
- 60-second replication interval and 5-minute publication interval.
- `add_bootstrap_peer()` / `bootstrap()` for initial DHT seeding.
- `RoutingUpdated` events (new peers) → `PeerDiscovered`.
- `UnroutablePeer` events → `PeerExpired`.
### PeerEvent enum
```rust
pub enum PeerEvent {
PeerDiscovered { peer_id: PeerId, addrs: Vec<Multiaddr> },
PeerExpired { peer_id: PeerId },
PeerConnected { peer_id: PeerId },
PeerDisconnected { peer_id: PeerId },
}
```
`PeerConnected` and `PeerDisconnected` are emitted by `SwarmManager` (Task 2.5)
from the libp2p `Swarm` connection lifecycle events. This module defines the
enum; Task 2.5 completes the emission.
### libp2p feature flags added
```toml
libp2p = { version = "0.54", features = [
"noise", "quic", "tcp", "yamux",
"mdns", "gossipsub", "kad", "identify", "relay",
"tokio", "macros",
] }
```
`tcp` and `yamux` were not included in the initial Task 2.2 dependency
declaration and were added during Task 2.3 implementation.
---
## Test Coverage
65 unit tests pass across all modules:
| Module | Tests | Coverage highlights |
|---------------|-------|------------------------------------------------------------------|
| `peer_event` | 10 | All 4 variants: accessors, display, clone, is_discovery/connected |
| `transport` | 12 | Config, user-agent, fingerprint extraction, keypair conversion, transport construction |
| `discovery` | 13 | mDNS: discovered/expired/dedup/multi-peer; Kademlia: bootstrap, routing, unroutable |
| `identity` | 9 | (from Task 2.2, unchanged) |
| `swarm_key` | 16 | (from Task 2.2, unchanged) |
**Note on Kademlia `RoutingUpdated` tests:** `KadEvent::RoutingUpdated` uses
`libp2p::kad::Addresses` and `libp2p::kad::Distance` which are not constructable
from outside the `libp2p-kad` crate (private internal types). The routing-table
tracking logic is tested via a `#[cfg(test)]` helper `insert_peer_for_test()`.
Full end-to-end `RoutingUpdated` coverage will be provided by the integration
test in Task 2.5 where a real swarm runs.
---
## Consequences
- `SwarmManager` (Task 2.5) can now import `MdnsDiscovery`, `KademliaDht`,
`TransportConfig`, `build_transport`, and `PeerEvent` directly.
- The `identify` protocol must be included in the Task 2.5 composite behaviour
to enable SwarmKey fingerprint validation.
- The `PeerConnected` / `PeerDisconnected` variants of `PeerEvent` are defined
but not yet emitted — Task 2.5 completes this.
- No breaking changes to existing `identity` or `swarm_key` modules.

View File

@ -42,7 +42,7 @@
## Task 2.3 — Implement chronogrid_net: Transport & Discovery Layer ## Task 2.3 — Implement chronogrid_net: Transport & Discovery Layer
- [x] **Task 2.3**: Implement the libp2p transport stack and peer discovery in `crates/chronogrid_net/`: - [ ] **Task 2.3**: Implement the libp2p transport stack and peer discovery in `crates/chronogrid_net/`:
- Implement `transport::build_transport(identity: &NodeIdentity, swarm_key: &SwarmKey) -> Result<Boxed<(PeerId, StreamMuxerBox)>>` - Implement `transport::build_transport(identity: &NodeIdentity, swarm_key: &SwarmKey) -> Result<Boxed<(PeerId, StreamMuxerBox)>>`
- QUIC over UDP as primary transport - QUIC over UDP as primary transport
- Noise XK handshake (Noise_XK_25519_ChaChaPoly_BLAKE2s) for authentication - Noise XK handshake (Noise_XK_25519_ChaChaPoly_BLAKE2s) for authentication