Compare commits
2 Commits
7f74656889
...
7b567b3f78
| Author | SHA1 | Date |
|---|---|---|
|
|
7b567b3f78 | |
|
|
e441c22d4e |
File diff suppressed because it is too large
Load Diff
|
|
@ -94,6 +94,22 @@
|
||||||
- 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
|
||||||
|
|
|
||||||
|
|
@ -23,5 +23,29 @@ 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"] }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,491 @@
|
||||||
|
//! 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,14 @@ 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`.
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,14 @@
|
||||||
//!
|
//!
|
||||||
//! ## 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
|
||||||
//!
|
//!
|
||||||
|
|
@ -19,6 +22,7 @@
|
||||||
//! 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"))
|
||||||
|
|
@ -29,13 +33,25 @@
|
||||||
//! 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};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
//! 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,330 @@
|
||||||
|
//! 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
# 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.
|
||||||
|
|
@ -42,7 +42,7 @@
|
||||||
|
|
||||||
## Task 2.3 — Implement chronogrid_net: Transport & Discovery Layer
|
## Task 2.3 — Implement chronogrid_net: Transport & Discovery Layer
|
||||||
|
|
||||||
- [ ] **Task 2.3**: Implement the libp2p transport stack and peer discovery in `crates/chronogrid_net/`:
|
- [x] **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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue