Skip to main content

tenzro_network/
gossip.rs

1//! Gossipsub topics and message handling for Tenzro Network
2//!
3//! This module provides message deduplication, validation, and topic management
4//! for the gossipsub protocol. Message deduplication is handled at two levels:
5//!
6//! 1. **libp2p gossipsub built-in deduplication**: The gossipsub protocol uses
7//!    a custom message ID function (SHA-256 hash of message data) defined in
8//!    `behaviour.rs` to automatically deduplicate messages at the protocol level.
9//!
10//! 2. **Application-level deduplication**: The `MessageDeduplicator` provides
11//!    an additional layer of deduplication tracking with configurable cache size
12//!    and expiry times for defense-in-depth against amplification attacks.
13
14use libp2p::gossipsub::{IdentTopic, TopicHash};
15use std::collections::{HashMap, VecDeque};
16use std::time::Instant;
17
18/// Topic constants for Tenzro Network
19pub struct GossipTopics {
20    topics: HashMap<String, IdentTopic>,
21}
22
23impl GossipTopics {
24    /// Creates a new GossipTopics instance
25    pub fn new() -> Self {
26        let mut topics = HashMap::new();
27
28        // Core protocol topics
29        topics.insert("blocks".to_string(), IdentTopic::new("tenzro/blocks"));
30        topics.insert("transactions".to_string(), IdentTopic::new("tenzro/transactions"));
31        topics.insert("consensus".to_string(), IdentTopic::new("tenzro/consensus"));
32
33        // Provider topics
34        topics.insert("attestations".to_string(), IdentTopic::new("tenzro/attestations"));
35        topics.insert("models".to_string(), IdentTopic::new("tenzro/models"));
36        topics.insert("inference".to_string(), IdentTopic::new("tenzro/inference"));
37
38        // Status and discovery
39        topics.insert("status".to_string(), IdentTopic::new("tenzro/status"));
40
41        // Agent and provider discovery
42        topics.insert("agents".to_string(), IdentTopic::new("tenzro/agents"));
43        topics.insert("providers".to_string(), IdentTopic::new("tenzro/providers"));
44
45        // Cortex worker discovery (must match tenzro_cortex::CORTEX_TOPIC)
46        topics.insert("cortex".to_string(), IdentTopic::new("tenzro/cortex"));
47
48        // Tenzro Train: outer-gradient broadcast and syncer round publication
49        topics.insert("training".to_string(), IdentTopic::new("tenzro/training"));
50        topics.insert("training_syncer".to_string(), IdentTopic::new("tenzro/training/syncer"));
51
52        // SeedAgent (Spec 10) — provisioning lifecycle, refill, sunset
53        topics.insert("seed_agents".to_string(), IdentTopic::new("tenzro/seed-agents"));
54
55        Self { topics }
56    }
57
58    /// Gets a topic by name
59    pub fn get(&self, name: &str) -> Option<&IdentTopic> {
60        self.topics.get(name)
61    }
62
63    /// Gets all topic hashes
64    pub fn all_hashes(&self) -> Vec<TopicHash> {
65        self.topics.values().map(|t| t.hash()).collect()
66    }
67
68    /// Gets all topics
69    pub fn all(&self) -> Vec<&IdentTopic> {
70        self.topics.values().collect()
71    }
72
73    /// Adds a custom topic
74    pub fn add_custom(&mut self, name: String, topic: IdentTopic) {
75        self.topics.insert(name, topic);
76    }
77
78    /// Blocks topic
79    pub fn blocks(&self) -> &IdentTopic {
80        self.topics.get("blocks").expect("blocks topic must exist")
81    }
82
83    /// Transactions topic
84    pub fn transactions(&self) -> &IdentTopic {
85        self.topics.get("transactions").expect("transactions topic must exist")
86    }
87
88    /// Consensus topic
89    pub fn consensus(&self) -> &IdentTopic {
90        self.topics.get("consensus").expect("consensus topic must exist")
91    }
92
93    /// Attestations topic
94    pub fn attestations(&self) -> &IdentTopic {
95        self.topics.get("attestations").expect("attestations topic must exist")
96    }
97
98    /// Models topic
99    pub fn models(&self) -> &IdentTopic {
100        self.topics.get("models").expect("models topic must exist")
101    }
102
103    /// Inference topic
104    pub fn inference(&self) -> &IdentTopic {
105        self.topics.get("inference").expect("inference topic must exist")
106    }
107
108    /// Status topic
109    pub fn status(&self) -> &IdentTopic {
110        self.topics.get("status").expect("status topic must exist")
111    }
112
113    /// Agents topic
114    pub fn agents(&self) -> &IdentTopic {
115        self.topics.get("agents").expect("agents topic must exist")
116    }
117
118    /// Providers topic
119    pub fn providers(&self) -> &IdentTopic {
120        self.topics.get("providers").expect("providers topic must exist")
121    }
122
123    /// Cortex worker-advertisement topic
124    pub fn cortex(&self) -> &IdentTopic {
125        self.topics.get("cortex").expect("cortex topic must exist")
126    }
127
128    /// SeedAgent lifecycle topic (Spec 10)
129    pub fn seed_agents(&self) -> &IdentTopic {
130        self.topics.get("seed_agents").expect("seed_agents topic must exist")
131    }
132}
133
134impl Default for GossipTopics {
135    fn default() -> Self {
136        Self::new()
137    }
138}
139
140/// Topic subscription manager
141pub struct TopicSubscriptions {
142    subscribed: HashMap<TopicHash, bool>,
143}
144
145impl TopicSubscriptions {
146    /// Creates a new TopicSubscriptions instance
147    pub fn new() -> Self {
148        Self {
149            subscribed: HashMap::new(),
150        }
151    }
152
153    /// Marks a topic as subscribed
154    pub fn subscribe(&mut self, topic: &TopicHash) {
155        self.subscribed.insert(topic.clone(), true);
156    }
157
158    /// Marks a topic as unsubscribed
159    pub fn unsubscribe(&mut self, topic: &TopicHash) {
160        self.subscribed.remove(topic);
161    }
162
163    /// Checks if subscribed to a topic
164    pub fn is_subscribed(&self, topic: &TopicHash) -> bool {
165        self.subscribed.get(topic).copied().unwrap_or(false)
166    }
167
168    /// Gets all subscribed topics
169    pub fn subscribed_topics(&self) -> Vec<&TopicHash> {
170        self.subscribed.keys().collect()
171    }
172
173    /// Gets the number of subscribed topics
174    pub fn count(&self) -> usize {
175        self.subscribed.len()
176    }
177}
178
179impl Default for TopicSubscriptions {
180    fn default() -> Self {
181        Self::new()
182    }
183}
184
185/// Message deduplication cache using SHA-256 hashes of message content.
186///
187/// Tracks recently seen messages to reject duplicates and prevent amplification attacks.
188/// Uses a bounded FIFO queue with time-based expiry.
189pub struct MessageDeduplicator {
190    /// SHA-256 hashes of recently seen messages, ordered by insertion time
191    seen: VecDeque<([u8; 32], Instant)>,
192    /// Fast lookup set for O(1) duplicate checks
193    seen_set: HashMap<[u8; 32], ()>,
194    /// Maximum number of entries to retain
195    max_entries: usize,
196    /// Expiry duration in seconds
197    expiry_secs: u64,
198}
199
200impl MessageDeduplicator {
201    /// Create a new deduplicator with configurable bounds
202    pub fn new(max_entries: usize, expiry_secs: u64) -> Self {
203        Self {
204            seen: VecDeque::with_capacity(max_entries),
205            seen_set: HashMap::with_capacity(max_entries),
206            max_entries,
207            expiry_secs,
208        }
209    }
210
211    /// Check if a message has been seen. If not, insert it and return false.
212    /// Returns true if the message is a duplicate.
213    pub fn is_duplicate(&mut self, data: &[u8]) -> bool {
214        use sha2::{Digest, Sha256};
215        let hash: [u8; 32] = Sha256::digest(data).into();
216
217        // Evict expired entries
218        let now = Instant::now();
219        while let Some(&(_, ts)) = self.seen.front() {
220            if now.duration_since(ts).as_secs() > self.expiry_secs {
221                if let Some((old_hash, _)) = self.seen.pop_front() {
222                    self.seen_set.remove(&old_hash);
223                }
224            } else {
225                break;
226            }
227        }
228
229        // Evict oldest if at capacity
230        if self.seen.len() >= self.max_entries
231            && let Some((old_hash, _)) = self.seen.pop_front()
232        {
233            self.seen_set.remove(&old_hash);
234        }
235
236        if self.seen_set.contains_key(&hash) {
237            return true;
238        }
239
240        self.seen_set.insert(hash, ());
241        self.seen.push_back((hash, now));
242        false
243    }
244
245    /// Return the number of cached message hashes
246    pub fn len(&self) -> usize {
247        self.seen.len()
248    }
249
250    /// Return whether the cache is empty
251    pub fn is_empty(&self) -> bool {
252        self.seen.is_empty()
253    }
254}
255
256impl Default for MessageDeduplicator {
257    fn default() -> Self {
258        // Default: 100k messages, 5 minute expiry
259        Self::new(100_000, 300)
260    }
261}
262
263/// Message validation result
264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265pub enum MessageValidation {
266    /// Message is valid and should be propagated
267    Accept,
268    /// Message is invalid and should be rejected
269    Reject,
270    /// Message validation is pending (for async validation)
271    Ignore,
272}
273
274/// Validates gossip messages based on topic and content
275pub fn validate_gossip_message(_topic: &TopicHash, data: &[u8]) -> MessageValidation {
276    // Basic validation: check if message is not empty
277    if data.is_empty() {
278        return MessageValidation::Reject;
279    }
280
281    // Check message size (hardened 1 MiB cap — matches gossipsub max_transmit_size
282    // and prevents large-message flood amplification attacks).
283    if data.len() > 1_048_576 {
284        return MessageValidation::Reject;
285    }
286
287    // Try to parse as NetworkMessage
288    match crate::message::NetworkMessage::from_bytes(data) {
289        Ok(msg) => {
290            // Validate message structure
291            if let Err(e) = crate::message::validate_message(&msg) {
292                tracing::warn!("Message validation failed: {}", e);
293                return MessageValidation::Reject;
294            }
295            MessageValidation::Accept
296        }
297        Err(e) => {
298            tracing::warn!("Failed to parse network message: {}", e);
299            MessageValidation::Reject
300        }
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn test_gossip_topics() {
310        let topics = GossipTopics::new();
311        assert!(topics.get("blocks").is_some());
312        assert!(topics.get("transactions").is_some());
313        assert!(topics.get("consensus").is_some());
314        assert!(topics.get("nonexistent").is_none());
315    }
316
317    #[test]
318    fn test_topic_subscriptions() {
319        let mut subs = TopicSubscriptions::new();
320        let topics = GossipTopics::new();
321        let blocks_hash = topics.blocks().hash();
322
323        assert!(!subs.is_subscribed(&blocks_hash));
324
325        subs.subscribe(&blocks_hash);
326        assert!(subs.is_subscribed(&blocks_hash));
327        assert_eq!(subs.count(), 1);
328
329        subs.unsubscribe(&blocks_hash);
330        assert!(!subs.is_subscribed(&blocks_hash));
331        assert_eq!(subs.count(), 0);
332    }
333
334    #[test]
335    fn test_message_deduplication() {
336        let mut dedup = MessageDeduplicator::new(100, 60);
337
338        let msg1 = b"message one";
339        let msg2 = b"message two";
340
341        // First time seeing msg1 — not a duplicate
342        assert!(!dedup.is_duplicate(msg1));
343        // Second time — duplicate
344        assert!(dedup.is_duplicate(msg1));
345        // Different message — not a duplicate
346        assert!(!dedup.is_duplicate(msg2));
347
348        assert_eq!(dedup.len(), 2);
349    }
350
351    #[test]
352    fn test_dedup_capacity_eviction() {
353        let mut dedup = MessageDeduplicator::new(3, 300);
354
355        assert!(!dedup.is_duplicate(b"a"));
356        assert!(!dedup.is_duplicate(b"b"));
357        assert!(!dedup.is_duplicate(b"c"));
358        // At capacity, inserting a new one should evict the oldest
359        assert!(!dedup.is_duplicate(b"d"));
360        assert_eq!(dedup.len(), 3);
361        // "a" was evicted, so it's no longer a duplicate
362        assert!(!dedup.is_duplicate(b"a"));
363    }
364
365    #[test]
366    fn test_message_validation() {
367        // Empty message should be rejected
368        assert_eq!(
369            validate_gossip_message(&TopicHash::from_raw("test"), &[]),
370            MessageValidation::Reject
371        );
372
373        // Valid message should be accepted
374        let msg = crate::message::NetworkMessage::new(crate::message::MessagePayload::Ping);
375        let bytes = msg.to_bytes().unwrap();
376        let topics = GossipTopics::new();
377        let result = validate_gossip_message(&topics.status().hash(), &bytes);
378        assert_eq!(result, MessageValidation::Accept);
379    }
380}