Skip to main content

pot_o_extensions/
gossip_client.rs

1//! Gossip client for peer-to-peer challenge distribution using async HTTP.
2
3use crate::peer_auth::NodeIdentity;
4use serde_json;
5use sha2::{Digest, Sha256};
6use std::sync::Arc;
7use std::time::Duration;
8use tokio::sync::RwLock;
9
10/// Async HTTP-based gossip client for broadcasting challenges to peers.
11pub struct GossipClient {
12    /// Client for making HTTP requests
13    client: reqwest::Client,
14    /// Thread-safe list of peer URLs
15    peers: Arc<RwLock<Vec<String>>>,
16    /// Request timeout duration
17    timeout: Duration,
18    /// Optional node identity for request signing
19    identity: Option<Arc<NodeIdentity>>,
20}
21
22impl GossipClient {
23    /// Create a new gossip client with the given peer URLs and timeout.
24    ///
25    /// # Arguments
26    /// * `peer_urls` - List of peer URLs for challenge distribution
27    /// * `timeout_secs` - Request timeout in seconds
28    pub fn new(peer_urls: Vec<String>, timeout_secs: u64) -> Self {
29        Self {
30            client: reqwest::Client::new(),
31            peers: Arc::new(RwLock::new(peer_urls)),
32            timeout: Duration::from_secs(timeout_secs),
33            identity: None,
34        }
35    }
36
37    /// Create a new gossip client with authentication enabled.
38    ///
39    /// # Arguments
40    /// * `peer_urls` - List of peer URLs for challenge distribution
41    /// * `timeout_secs` - Request timeout in seconds
42    /// * `identity` - Node identity for signing requests
43    pub fn with_identity(
44        peer_urls: Vec<String>,
45        timeout_secs: u64,
46        identity: Arc<NodeIdentity>,
47    ) -> Self {
48        Self {
49            client: reqwest::Client::new(),
50            peers: Arc::new(RwLock::new(peer_urls)),
51            timeout: Duration::from_secs(timeout_secs),
52            identity: Some(identity),
53        }
54    }
55
56    /// Sign a request and return authentication headers.
57    ///
58    /// # Arguments
59    /// * `method` - HTTP method (GET, POST, etc.)
60    /// * `path` - Request path
61    /// * `body` - Request body bytes
62    fn sign_request(&self, method: &str, path: &str, body: &[u8]) -> Vec<(String, String)> {
63        let Some(identity) = &self.identity else {
64            return vec![];
65        };
66
67        let timestamp = chrono::Utc::now().timestamp();
68        let body_hash = hex::encode(Sha256::digest(body));
69        let message = format!("{}:{}:{}:{}", timestamp, method, path, body_hash);
70        let signature = identity.sign_message(message.as_bytes());
71
72        vec![
73            ("X-Node-Id".to_string(), identity.node_id().to_string()),
74            (
75                "X-Node-Pubkey".to_string(),
76                hex::encode(identity.public_key_bytes()),
77            ),
78            ("X-Node-Signature".to_string(), hex::encode(signature)),
79            ("X-Node-Timestamp".to_string(), timestamp.to_string()),
80        ]
81    }
82
83    /// Broadcast a challenge to all peers, returning the count of successful broadcasts.
84    ///
85    /// # Arguments
86    /// * `challenge_id` - Unique identifier for the challenge
87    /// * `challenge_json` - Challenge data as JSON
88    ///
89    /// # Returns
90    /// Number of peers that successfully received the challenge
91    pub async fn broadcast_challenge(
92        &self,
93        challenge_id: &str,
94        challenge_json: &serde_json::Value,
95    ) -> Result<usize, Box<dyn std::error::Error>> {
96        let peers = self.peers.read().await.clone();
97        let mut success_count = 0;
98
99        for peer_url in peers {
100            let payload = serde_json::json!({
101                "challenge_id": challenge_id,
102                "challenge": challenge_json,
103            });
104            let body_bytes = serde_json::to_vec(&payload)?;
105            let headers = self.sign_request("POST", "/challenge", &body_bytes);
106
107            let mut request = self
108                .client
109                .post(format!("{}/challenge", peer_url))
110                .timeout(self.timeout)
111                .body(body_bytes)
112                .header("content-type", "application/json");
113
114            for (key, value) in headers {
115                request = request.header(key, value);
116            }
117
118            let result = request.send().await;
119
120            if let Ok(response) = result {
121                if response.status().is_success() {
122                    success_count += 1;
123                }
124            }
125        }
126
127        Ok(success_count)
128    }
129
130    /// Pull a challenge from a specific peer.
131    ///
132    /// # Arguments
133    /// * `peer_url` - URL of the peer to pull from
134    pub async fn pull_challenge(
135        &self,
136        peer_url: &str,
137    ) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
138        let response = self
139            .client
140            .get(format!("{}/challenge", peer_url))
141            .timeout(self.timeout)
142            .send()
143            .await?;
144
145        let challenge = response.json::<serde_json::Value>().await?;
146        Ok(challenge)
147    }
148
149    /// Register this node with a peer.
150    ///
151    /// # Arguments
152    /// * `peer_url` - Peer URL to register with
153    /// * `our_url` - Our node's URL
154    /// * `node_id` - Our node's identifier
155    pub async fn register_peer(
156        &self,
157        peer_url: &str,
158        our_url: &str,
159        node_id: &str,
160    ) -> Result<(), Box<dyn std::error::Error>> {
161        self.client
162            .post(format!("{}/register", peer_url))
163            .timeout(self.timeout)
164            .json(&serde_json::json!({
165                "node_id": node_id,
166                "url": our_url,
167            }))
168            .send()
169            .await?;
170
171        Ok(())
172    }
173
174    /// Check health status of a peer, returning false if unreachable.
175    ///
176    /// # Arguments
177    /// * `peer_url` - URL of the peer to check
178    pub async fn health_check(&self, peer_url: &str) -> bool {
179        let result = self
180            .client
181            .get(format!("{}/health", peer_url))
182            .timeout(self.timeout)
183            .send()
184            .await;
185
186        result.is_ok()
187    }
188
189    /// Update the list of known peers.
190    ///
191    /// # Arguments
192    /// * `new_peers` - New list of peer URLs
193    pub async fn update_peers(&self, new_peers: Vec<String>) {
194        let mut peers = self.peers.write().await;
195        *peers = new_peers;
196    }
197
198    /// Broadcast a hexchain lattice snapshot to all peers.
199    ///
200    /// # Arguments
201    /// * `node_id` - This node's identifier
202    /// * `snapshot` - JSON-serialized lattice snapshot (LatticeSnapshot)
203    ///
204    /// # Returns
205    /// Number of peers that successfully received the snapshot
206    pub async fn broadcast_lattice(
207        &self,
208        node_id: &str,
209        snapshot: &serde_json::Value,
210    ) -> Result<usize, Box<dyn std::error::Error>> {
211        let peers = self.peers.read().await.clone();
212        let mut success_count = 0;
213
214        for peer_url in peers {
215            let payload = serde_json::json!({
216                "node_id": node_id,
217                "snapshot": snapshot,
218            });
219            let body_bytes = serde_json::to_vec(&payload)?;
220            let headers = self.sign_request("POST", "/hexchain/lattice/sync", &body_bytes);
221
222            let mut request = self
223                .client
224                .post(format!("{}/hexchain/lattice/sync", peer_url))
225                .timeout(self.timeout)
226                .body(body_bytes)
227                .header("content-type", "application/json");
228
229            for (key, value) in headers {
230                request = request.header(key, value);
231            }
232
233            let result = request.send().await;
234
235            if let Ok(response) = result {
236                if response.status().is_success() {
237                    success_count += 1;
238                }
239            }
240        }
241
242        Ok(success_count)
243    }
244
245    pub async fn broadcast_block(
246        &self,
247        proof: &serde_json::Value,
248    ) -> Result<usize, Box<dyn std::error::Error>> {
249        let peers = self.peers.read().await.clone();
250        let mut success_count = 0;
251
252        for peer_url in peers {
253            let body_bytes = serde_json::to_vec(proof)?;
254            let headers = self.sign_request("POST", "/hexchain/submit", &body_bytes);
255
256            let mut request = self
257                .client
258                .post(format!("{}/hexchain/submit", peer_url))
259                .timeout(self.timeout)
260                .body(body_bytes)
261                .header("content-type", "application/json");
262
263            for (key, value) in headers {
264                request = request.header(key, value);
265            }
266
267            let result = request.send().await;
268
269            if let Ok(response) = result {
270                if response.status().is_success() {
271                    success_count += 1;
272                }
273            }
274        }
275
276        Ok(success_count)
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use serde_json::json;
284
285    #[test]
286    fn test_new_creates_client_with_correct_timeout() {
287        let peer_urls = vec!["http://peer1.local".to_string()];
288        let gossip = GossipClient::new(peer_urls.clone(), 30);
289
290        // Verify timeout is set correctly
291        assert_eq!(gossip.timeout, Duration::from_secs(30));
292    }
293
294    #[test]
295    fn test_new_creates_client_with_peer_urls() {
296        let peer_urls = vec![
297            "http://peer1.local".to_string(),
298            "http://peer2.local".to_string(),
299        ];
300        let _gossip = GossipClient::new(peer_urls.clone(), 30);
301
302        // Verify peers are stored (we'll test this through async block)
303        assert_eq!(peer_urls.len(), 2);
304    }
305
306    #[tokio::test]
307    async fn test_broadcast_challenge_counts_successful_peers() {
308        let mut server = mockito::Server::new_async().await;
309        let peer1_url = server.url();
310
311        // Mock successful response for peer1
312        let _m = server
313            .mock("POST", "/challenge")
314            .with_status(200)
315            .with_body(r#"{"status": "ok"}"#)
316            .expect(1)
317            .create_async()
318            .await;
319
320        let gossip = GossipClient::new(vec![peer1_url], 5);
321        let challenge_id = "test-challenge-123";
322        let challenge_json = json!({"difficulty": 100, "nonce": 42});
323
324        let result = gossip
325            .broadcast_challenge(challenge_id, &challenge_json)
326            .await;
327
328        assert!(result.is_ok());
329        assert_eq!(result.unwrap(), 1);
330    }
331
332    #[tokio::test]
333    async fn test_broadcast_challenge_handles_partial_failures() {
334        let mut server1 = mockito::Server::new_async().await;
335        let mut server2 = mockito::Server::new_async().await;
336
337        let peer1_url = server1.url();
338        let peer2_url = server2.url();
339
340        // Peer1 responds successfully
341        let _m1 = server1
342            .mock("POST", "/challenge")
343            .with_status(200)
344            .expect(1)
345            .create_async()
346            .await;
347
348        // Peer2 doesn't respond (timeout/error)
349        let _m2 = server2
350            .mock("POST", "/challenge")
351            .with_status(500)
352            .expect(1)
353            .create_async()
354            .await;
355
356        let gossip = GossipClient::new(vec![peer1_url, peer2_url], 5);
357        let challenge_id = "test-challenge";
358        let challenge_json = json!({"difficulty": 100});
359
360        let result = gossip
361            .broadcast_challenge(challenge_id, &challenge_json)
362            .await;
363
364        // Should succeed with count of successful peers
365        assert!(result.is_ok());
366        // Only 1 peer succeeded
367        assert_eq!(result.unwrap(), 1);
368    }
369
370    #[tokio::test]
371    async fn test_pull_challenge_returns_challenge_json() {
372        let mut server = mockito::Server::new_async().await;
373        let peer_url = server.url();
374
375        let expected_challenge = json!({
376            "challenge_id": "ch-123",
377            "difficulty": 100,
378            "nonce": 42
379        });
380
381        let _m = server
382            .mock("GET", "/challenge")
383            .with_status(200)
384            .with_body(expected_challenge.to_string())
385            .expect(1)
386            .create_async()
387            .await;
388
389        let gossip = GossipClient::new(vec![], 5);
390        let result = gossip.pull_challenge(&peer_url).await;
391
392        assert!(result.is_ok());
393        let received = result.unwrap();
394        assert_eq!(received["challenge_id"], "ch-123");
395        assert_eq!(received["difficulty"], 100);
396    }
397
398    #[tokio::test]
399    async fn test_register_peer_sends_correct_payload() {
400        let mut server = mockito::Server::new_async().await;
401        let peer_url = server.url();
402
403        let _m = server
404            .mock("POST", "/register")
405            .match_body(mockito::Matcher::Json(json!({
406                "node_id": "node-123",
407                "url": "http://our-node"
408            })))
409            .with_status(200)
410            .expect(1)
411            .create_async()
412            .await;
413
414        let gossip = GossipClient::new(vec![], 5);
415        let result = gossip
416            .register_peer(&peer_url, "http://our-node", "node-123")
417            .await;
418
419        assert!(result.is_ok());
420    }
421
422    #[tokio::test]
423    async fn test_health_check_returns_true_for_healthy_peer() {
424        let mut server = mockito::Server::new_async().await;
425        let peer_url = server.url();
426
427        let _m = server
428            .mock("GET", "/health")
429            .with_status(200)
430            .with_body(r#"{"status": "healthy"}"#)
431            .expect(1)
432            .create_async()
433            .await;
434
435        let gossip = GossipClient::new(vec![], 5);
436        let result = gossip.health_check(&peer_url).await;
437
438        assert!(result);
439    }
440
441    #[tokio::test]
442    async fn test_health_check_returns_false_for_unreachable_peer() {
443        let gossip = GossipClient::new(vec![], 1); // 1 second timeout
444
445        // Try to reach a non-existent peer (should timeout/fail)
446        let result = gossip.health_check("http://127.0.0.1:1").await;
447
448        assert!(!result);
449    }
450
451    #[tokio::test]
452    async fn test_update_peers_modifies_peer_list() {
453        let initial_peers = vec!["http://peer1.local".to_string()];
454        let gossip = GossipClient::new(initial_peers, 30);
455
456        let new_peers = vec![
457            "http://peer2.local".to_string(),
458            "http://peer3.local".to_string(),
459        ];
460        gossip.update_peers(new_peers.clone()).await;
461
462        // Verify peers were updated
463        let peers = gossip.peers.read().await;
464        assert_eq!(peers.len(), 2);
465        assert_eq!(*peers, new_peers);
466    }
467
468    #[tokio::test]
469    async fn test_broadcast_challenge_to_multiple_peers() {
470        let mut server1 = mockito::Server::new_async().await;
471        let mut server2 = mockito::Server::new_async().await;
472        let mut server3 = mockito::Server::new_async().await;
473
474        let peer1_url = server1.url();
475        let peer2_url = server2.url();
476        let peer3_url = server3.url();
477
478        // All peers respond successfully
479        let _m1 = server1
480            .mock("POST", "/challenge")
481            .with_status(200)
482            .expect(1)
483            .create_async()
484            .await;
485
486        let _m2 = server2
487            .mock("POST", "/challenge")
488            .with_status(200)
489            .expect(1)
490            .create_async()
491            .await;
492
493        let _m3 = server3
494            .mock("POST", "/challenge")
495            .with_status(200)
496            .expect(1)
497            .create_async()
498            .await;
499
500        let gossip = GossipClient::new(vec![peer1_url, peer2_url, peer3_url], 5);
501
502        let challenge_id = "test-ch";
503        let challenge_json = json!({"difficulty": 100});
504
505        let result = gossip
506            .broadcast_challenge(challenge_id, &challenge_json)
507            .await;
508
509        assert!(result.is_ok());
510        assert_eq!(result.unwrap(), 3);
511    }
512}