Skip to main content

btlightning/
client.rs

1use crate::error::{LightningError, Result};
2use crate::registry::MinerRegistry;
3use crate::signing::Signer;
4use crate::types::{
5    handshake_request_message, handshake_response_message, read_frame, write_frame_and_finish,
6    HandshakeRequest, HandshakeResponse, MessageType, PeerAddr, QuicAxonInfo, QuicRequest,
7    QuicResponse, StreamChunk, StreamEnd, SynapsePacket, SynapseResponse,
8    DEFAULT_MAX_FRAME_PAYLOAD,
9};
10use crate::util::unix_timestamp_secs;
11use base64::{prelude::BASE64_STANDARD, Engine};
12use quinn::{ClientConfig, Connection, Endpoint, IdleTimeout, TransportConfig};
13use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
14use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
15use rustls::ClientConfig as RustlsClientConfig;
16use sp_core::blake2_256;
17use std::collections::HashMap;
18use std::net::SocketAddr;
19use std::sync::Arc;
20use std::time::Duration;
21use tokio::sync::RwLock;
22use tokio::time::Instant;
23use tracing::{debug, error, info, instrument, warn};
24
25#[cfg(feature = "subtensor")]
26use crate::metagraph::{connect_subtensor, Metagraph, MetagraphMonitorConfig};
27#[cfg(feature = "subtensor")]
28use subxt::PolkadotConfig;
29
30/// Configuration for [`LightningClient`].
31///
32/// All timeouts use [`Duration`] and have sensible defaults via [`Default`].
33#[derive(Clone)]
34pub struct LightningClientConfig {
35    /// Timeout for QUIC connection establishment and handshake. Default: 10s.
36    pub connect_timeout: Duration,
37    /// QUIC idle timeout before the connection is closed. Default: 150s.
38    pub idle_timeout: Duration,
39    /// Interval for QUIC keep-alive pings. Default: 30s.
40    pub keep_alive_interval: Duration,
41    /// Initial backoff delay after a failed reconnection attempt. Default: 1s.
42    pub reconnect_initial_backoff: Duration,
43    /// Maximum backoff delay between reconnection attempts. Default: 60s.
44    pub reconnect_max_backoff: Duration,
45    /// Maximum consecutive reconnection attempts before giving up. Default: 5.
46    pub reconnect_max_retries: u32,
47    /// After fast retries are exhausted, interval between periodic slow probe attempts.
48    /// Keeps scoring data flowing without wasting resources. `None` disables slow
49    /// probing (hard exhaustion, original behavior). Default: 60s.
50    pub reconnect_slow_probe_interval: Option<Duration>,
51    /// Maximum number of concurrent QUIC connections. Default: 1024.
52    pub max_connections: usize,
53    /// Maximum single-frame payload size in bytes. Default: 64 MiB.
54    pub max_frame_payload_bytes: usize,
55    /// Aggregate byte limit for `collect_all()` across all chunks in a streaming response.
56    /// Defaults to `DEFAULT_MAX_FRAME_PAYLOAD` (64 MiB). Increase for multi-chunk streams
57    /// that exceed a single frame. Must be >= `max_frame_payload_bytes`.
58    pub max_stream_payload_bytes: usize,
59    /// Per-chunk read timeout for streaming responses. When set, each `next_chunk()` call
60    /// aborts if no data arrives within this duration. Default: `None` (no timeout).
61    pub stream_chunk_timeout: Option<Duration>,
62    /// Metagraph monitor configuration. When set, `initialize_connections` starts
63    /// a background task that periodically re-syncs the subnet and updates connections.
64    #[cfg(feature = "subtensor")]
65    pub metagraph: Option<MetagraphMonitorConfig>,
66}
67
68impl Default for LightningClientConfig {
69    fn default() -> Self {
70        Self {
71            connect_timeout: Duration::from_secs(10),
72            idle_timeout: Duration::from_secs(150),
73            keep_alive_interval: Duration::from_secs(30),
74            reconnect_initial_backoff: Duration::from_secs(1),
75            reconnect_max_backoff: Duration::from_secs(60),
76            reconnect_max_retries: 5,
77            reconnect_slow_probe_interval: Some(Duration::from_secs(60)),
78            max_connections: 1024,
79            max_frame_payload_bytes: DEFAULT_MAX_FRAME_PAYLOAD,
80            max_stream_payload_bytes: DEFAULT_MAX_FRAME_PAYLOAD,
81            stream_chunk_timeout: None,
82            #[cfg(feature = "subtensor")]
83            metagraph: None,
84        }
85    }
86}
87
88impl LightningClientConfig {
89    pub fn builder() -> LightningClientConfigBuilder {
90        LightningClientConfigBuilder {
91            config: Self::default(),
92        }
93    }
94
95    fn validate(&self) -> Result<()> {
96        if self.connect_timeout.is_zero() {
97            return Err(LightningError::Config(
98                "connect_timeout must be non-zero".into(),
99            ));
100        }
101        if self.idle_timeout.is_zero() {
102            return Err(LightningError::Config(
103                "idle_timeout must be non-zero".into(),
104            ));
105        }
106        if self.keep_alive_interval.is_zero() {
107            return Err(LightningError::Config(
108                "keep_alive_interval must be non-zero".into(),
109            ));
110        }
111        if self.keep_alive_interval >= self.idle_timeout {
112            return Err(LightningError::Config(format!(
113                "keep_alive_interval ({:?}) must be less than idle_timeout ({:?})",
114                self.keep_alive_interval, self.idle_timeout
115            )));
116        }
117        if self.reconnect_initial_backoff.is_zero() {
118            return Err(LightningError::Config(
119                "reconnect_initial_backoff must be non-zero".into(),
120            ));
121        }
122        if self.reconnect_max_backoff.is_zero() {
123            return Err(LightningError::Config(
124                "reconnect_max_backoff must be non-zero".into(),
125            ));
126        }
127        if self.reconnect_initial_backoff > self.reconnect_max_backoff {
128            return Err(LightningError::Config(format!(
129                "reconnect_initial_backoff ({:?}) must be <= reconnect_max_backoff ({:?})",
130                self.reconnect_initial_backoff, self.reconnect_max_backoff
131            )));
132        }
133        if self.reconnect_max_retries == 0 {
134            return Err(LightningError::Config(
135                "reconnect_max_retries must be at least 1".into(),
136            ));
137        }
138        if self
139            .reconnect_slow_probe_interval
140            .is_some_and(|d| d.is_zero())
141        {
142            return Err(LightningError::Config(
143                "reconnect_slow_probe_interval must be non-zero when set".into(),
144            ));
145        }
146        if self.max_connections == 0 {
147            return Err(LightningError::Config(
148                "max_connections must be at least 1".into(),
149            ));
150        }
151        if self.max_frame_payload_bytes < 1_048_576 {
152            return Err(LightningError::Config(format!(
153                "max_frame_payload_bytes ({}) must be at least 1048576 (1 MB)",
154                self.max_frame_payload_bytes
155            )));
156        }
157        if self.max_frame_payload_bytes > u32::MAX as usize {
158            return Err(LightningError::Config(format!(
159                "max_frame_payload_bytes ({}) must not exceed {} (u32::MAX)",
160                self.max_frame_payload_bytes,
161                u32::MAX
162            )));
163        }
164        if self.stream_chunk_timeout.is_some_and(|d| d.is_zero()) {
165            return Err(LightningError::Config(
166                "stream_chunk_timeout must be non-zero".into(),
167            ));
168        }
169        if self.max_stream_payload_bytes < self.max_frame_payload_bytes {
170            return Err(LightningError::Config(format!(
171                "max_stream_payload_bytes ({}) must be >= max_frame_payload_bytes ({})",
172                self.max_stream_payload_bytes, self.max_frame_payload_bytes
173            )));
174        }
175        Ok(())
176    }
177}
178
179pub struct LightningClientConfigBuilder {
180    config: LightningClientConfig,
181}
182
183impl LightningClientConfigBuilder {
184    pub fn connect_timeout(mut self, val: Duration) -> Self {
185        self.config.connect_timeout = val;
186        self
187    }
188    pub fn idle_timeout(mut self, val: Duration) -> Self {
189        self.config.idle_timeout = val;
190        self
191    }
192    pub fn keep_alive_interval(mut self, val: Duration) -> Self {
193        self.config.keep_alive_interval = val;
194        self
195    }
196    pub fn reconnect_initial_backoff(mut self, val: Duration) -> Self {
197        self.config.reconnect_initial_backoff = val;
198        self
199    }
200    pub fn reconnect_max_backoff(mut self, val: Duration) -> Self {
201        self.config.reconnect_max_backoff = val;
202        self
203    }
204    pub fn reconnect_max_retries(mut self, val: u32) -> Self {
205        self.config.reconnect_max_retries = val;
206        self
207    }
208    pub fn reconnect_slow_probe_interval(mut self, val: Option<Duration>) -> Self {
209        self.config.reconnect_slow_probe_interval = val;
210        self
211    }
212    pub fn max_connections(mut self, val: usize) -> Self {
213        self.config.max_connections = val;
214        self
215    }
216    pub fn max_frame_payload_bytes(mut self, val: usize) -> Self {
217        self.config.max_frame_payload_bytes = val;
218        self
219    }
220    pub fn max_stream_payload_bytes(mut self, val: usize) -> Self {
221        self.config.max_stream_payload_bytes = val;
222        self
223    }
224    pub fn stream_chunk_timeout(mut self, val: Duration) -> Self {
225        self.config.stream_chunk_timeout = Some(val);
226        self
227    }
228    #[cfg(feature = "subtensor")]
229    pub fn metagraph(mut self, val: MetagraphMonitorConfig) -> Self {
230        self.config.metagraph = Some(val);
231        self
232    }
233    pub fn build(self) -> Result<LightningClientConfig> {
234        self.config.validate()?;
235        Ok(self.config)
236    }
237}
238
239struct ClientState {
240    registry: MinerRegistry,
241    #[cfg(feature = "subtensor")]
242    metagraph_shutdown: Option<tokio::sync::watch::Sender<bool>>,
243    #[cfg(feature = "subtensor")]
244    metagraph_handle: Option<tokio::task::JoinHandle<()>>,
245}
246
247/// Handle for reading a chunked streaming response from a miner.
248///
249/// Returned by [`LightningClient::query_axon_stream`]. Call [`next_chunk`](Self::next_chunk)
250/// in a loop or use [`collect_all`](Self::collect_all) to buffer the full response.
251pub struct StreamingResponse {
252    recv: quinn::RecvStream,
253    max_payload: usize,
254    max_stream_payload: usize,
255    chunk_timeout: Option<Duration>,
256}
257
258impl StreamingResponse {
259    /// Reads the next chunk from the stream. Returns `None` on successful completion.
260    pub async fn next_chunk(&mut self) -> Result<Option<Vec<u8>>> {
261        let frame_result = match self.chunk_timeout {
262            Some(timeout) => {
263                match tokio::time::timeout(timeout, read_frame(&mut self.recv, self.max_payload))
264                    .await
265                {
266                    Ok(r) => r,
267                    Err(_) => {
268                        self.recv.stop(0u32.into()).ok();
269                        return Err(LightningError::Stream("chunk read timed out".to_string()));
270                    }
271                }
272            }
273            None => read_frame(&mut self.recv, self.max_payload).await,
274        };
275        match frame_result {
276            Ok((MessageType::StreamChunk, payload)) => {
277                let chunk: StreamChunk = rmp_serde::from_slice(&payload).map_err(|e| {
278                    LightningError::Serialization(format!("Failed to parse stream chunk: {}", e))
279                })?;
280                Ok(Some(chunk.data))
281            }
282            Ok((MessageType::StreamEnd, payload)) => {
283                let end: StreamEnd = rmp_serde::from_slice(&payload).map_err(|e| {
284                    LightningError::Serialization(format!("Failed to parse stream end: {}", e))
285                })?;
286                if end.success {
287                    Ok(None)
288                } else {
289                    Err(LightningError::Stream(end.error.unwrap_or_else(|| {
290                        "stream ended with failure status".to_string()
291                    })))
292                }
293            }
294            Ok((MessageType::SynapseResponse, payload)) => {
295                let detail = rmp_serde::from_slice::<SynapseResponse>(&payload)
296                    .ok()
297                    .and_then(|r| r.error)
298                    .unwrap_or_else(|| "no detail".to_string());
299                Err(LightningError::Stream(format!(
300                    "server returned SynapseResponse error on streaming path: {}",
301                    detail
302                )))
303            }
304            Ok((msg_type, _)) => Err(LightningError::Stream(format!(
305                "unexpected message type during streaming: {:?}",
306                msg_type
307            ))),
308            Err(e) => Err(e),
309        }
310    }
311
312    /// Reads all remaining chunks into a `Vec`. Enforces `max_stream_payload_bytes`.
313    pub async fn collect_all(&mut self) -> Result<Vec<Vec<u8>>> {
314        let mut chunks = Vec::new();
315        let mut total_size: usize = 0;
316        while let Some(chunk) = self.next_chunk().await? {
317            total_size = total_size.checked_add(chunk.len()).ok_or_else(|| {
318                LightningError::Stream("streaming response size overflow".to_string())
319            })?;
320            if total_size > self.max_stream_payload {
321                return Err(LightningError::Stream(format!(
322                    "streaming response exceeded {} byte aggregate limit",
323                    self.max_stream_payload
324                )));
325            }
326            chunks.push(chunk);
327        }
328        Ok(chunks)
329    }
330}
331
332/// QUIC client for sending synapse requests to Bittensor miners.
333///
334/// Manages persistent, authenticated QUIC connections to one or more miners.
335/// Each connection is established with a mutual sr25519 handshake; subsequent
336/// synapse requests reuse the connection without re-authenticating.
337pub struct LightningClient {
338    config: LightningClientConfig,
339    wallet_hotkey: String,
340    signer: Option<Arc<dyn Signer>>,
341    state: Arc<RwLock<ClientState>>,
342    endpoint: Option<Endpoint>,
343}
344
345impl LightningClient {
346    /// Creates a client with default configuration. Panics only if defaults are invalid.
347    pub fn new(wallet_hotkey: String) -> Self {
348        Self::with_config(wallet_hotkey, LightningClientConfig::default())
349            .expect("default config is always valid")
350    }
351
352    /// Creates a client with the given configuration, validating constraints.
353    pub fn with_config(wallet_hotkey: String, config: LightningClientConfig) -> Result<Self> {
354        config.validate()?;
355        Ok(Self {
356            config,
357            wallet_hotkey,
358            signer: None,
359            state: Arc::new(RwLock::new(ClientState {
360                registry: MinerRegistry::new(),
361                #[cfg(feature = "subtensor")]
362                metagraph_shutdown: None,
363                #[cfg(feature = "subtensor")]
364                metagraph_handle: None,
365            })),
366            endpoint: None,
367        })
368    }
369
370    /// Sets the [`Signer`] used for handshake authentication. Must be called before
371    /// `initialize_connections`.
372    pub fn set_signer(&mut self, signer: Box<dyn Signer>) {
373        self.signer = Some(Arc::from(signer));
374        info!("Signer configured");
375    }
376
377    /// Loads the signer from a Bittensor wallet on disk. Requires the `btwallet` feature.
378    #[cfg(feature = "btwallet")]
379    pub fn set_wallet(
380        &mut self,
381        wallet_name: &str,
382        wallet_path: &str,
383        hotkey_name: &str,
384    ) -> Result<()> {
385        let signer =
386            crate::signing::BtWalletSigner::from_wallet(wallet_name, wallet_path, hotkey_name)?;
387        self.set_signer(Box::new(signer));
388        Ok(())
389    }
390
391    /// Opens QUIC connections and performs sr25519 handshakes with the given miners.
392    ///
393    /// Miners sharing an `ip:port` are multiplexed over a single QUIC connection.
394    /// If `metagraph` is configured, a background monitor is also started.
395    #[instrument(skip(self, miners), fields(miner_count = miners.len()))]
396    pub async fn initialize_connections(&mut self, miners: Vec<QuicAxonInfo>) -> Result<()> {
397        self.create_endpoint().await?;
398
399        let endpoint = self
400            .endpoint
401            .as_ref()
402            .ok_or_else(|| LightningError::Connection("QUIC endpoint not initialized".into()))?
403            .clone();
404        let wallet_hotkey = self.wallet_hotkey.clone();
405        let signer = self
406            .signer
407            .as_ref()
408            .ok_or_else(|| LightningError::Signing("No signer configured".into()))?
409            .clone();
410        let timeout = self.config.connect_timeout;
411
412        let mut addr_groups: HashMap<PeerAddr, Vec<QuicAxonInfo>> = HashMap::new();
413        for miner in miners {
414            addr_groups.entry(miner.addr_key()).or_default().push(miner);
415        }
416
417        let (active_count, remaining_capacity) = {
418            let state = self.state.read().await;
419            let active = state.registry.connection_count();
420            (active, self.config.max_connections.saturating_sub(active))
421        };
422
423        let addr_groups: Vec<(PeerAddr, Vec<QuicAxonInfo>)> =
424            if addr_groups.len() > remaining_capacity {
425                warn!(
426                    "Connection limit ({}) reached with {} active, skipping {} of {} new addresses",
427                    self.config.max_connections,
428                    active_count,
429                    addr_groups.len() - remaining_capacity,
430                    addr_groups.len()
431                );
432                addr_groups.into_iter().take(remaining_capacity).collect()
433            } else {
434                addr_groups.into_iter().collect()
435            };
436
437        let max_fp = self.config.max_frame_payload_bytes;
438        let mut set = tokio::task::JoinSet::new();
439        for (addr_key, miners_at_addr) in addr_groups {
440            let ep = endpoint.clone();
441            let wh = wallet_hotkey.clone();
442            let s = signer.clone();
443            set.spawn(connect_and_authenticate_per_address(
444                ep,
445                wh,
446                s,
447                addr_key,
448                miners_at_addr,
449                timeout,
450                max_fp,
451            ));
452        }
453
454        let mut results = Vec::new();
455        while let Some(join_result) = set.join_next().await {
456            match join_result {
457                Ok((addr_key, conn_result, authenticated)) => {
458                    results.push((addr_key, conn_result, authenticated));
459                }
460                Err(e) => {
461                    error!("Connection task panicked: {}", e);
462                }
463            }
464        }
465
466        let mut state = self.state.write().await;
467        for (addr_key, conn_result, authenticated) in results {
468            match conn_result {
469                Ok(connection) => {
470                    if authenticated.is_empty() {
471                        warn!(
472                            "No hotkeys authenticated at {}, dropping connection",
473                            addr_key
474                        );
475                        connection.close(0u32.into(), b"no_authenticated_hotkeys");
476                    } else {
477                        for miner in authenticated {
478                            info!("Authenticated miner {} at {}", miner.hotkey, addr_key);
479                            state.registry.register(miner);
480                        }
481                        state.registry.set_connection(addr_key, connection);
482                    }
483                }
484                Err(e) => {
485                    error!("Failed to connect to {}: {}", addr_key, e);
486                }
487            }
488        }
489
490        #[cfg(feature = "subtensor")]
491        if let Some(metagraph_config) = self.config.metagraph.clone() {
492            self.start_metagraph_monitor(metagraph_config).await?;
493        }
494
495        Ok(())
496    }
497
498    /// Creates the QUIC client endpoint bound to `0.0.0.0:0`. Called automatically by
499    /// `initialize_connections`; only call directly if you need the endpoint before connecting.
500    #[instrument(skip(self))]
501    pub async fn create_endpoint(&mut self) -> Result<()> {
502        let mut tls_config = RustlsClientConfig::builder_with_provider(
503            rustls::crypto::ring::default_provider().into(),
504        )
505        .with_safe_default_protocol_versions()
506        .map_err(|e| LightningError::Config(format!("Failed to set TLS versions: {}", e)))?
507        .dangerous()
508        .with_custom_certificate_verifier(Arc::new(AcceptAnyCertVerifier))
509        .with_no_client_auth();
510
511        tls_config.alpn_protocols = vec![b"btlightning".to_vec()];
512
513        let mut transport_config = TransportConfig::default();
514
515        let idle_timeout = IdleTimeout::try_from(self.config.idle_timeout)
516            .map_err(|e| LightningError::Config(format!("Failed to set idle timeout: {}", e)))?;
517        transport_config.max_idle_timeout(Some(idle_timeout));
518        transport_config.keep_alive_interval(Some(self.config.keep_alive_interval));
519
520        let quic_crypto =
521            quinn::crypto::rustls::QuicClientConfig::try_from(tls_config).map_err(|e| {
522                LightningError::Config(format!("Failed to create QUIC crypto config: {}", e))
523            })?;
524        let mut client_config = ClientConfig::new(Arc::new(quic_crypto));
525        client_config.transport_config(Arc::new(transport_config));
526
527        let bind_addr: SocketAddr = "0.0.0.0:0"
528            .parse()
529            .map_err(|e| LightningError::Config(format!("Failed to parse bind address: {}", e)))?;
530        let mut endpoint = Endpoint::client(bind_addr).map_err(|e| {
531            LightningError::Connection(format!("Failed to create QUIC endpoint: {}", e))
532        })?;
533        endpoint.set_default_client_config(client_config);
534        self.endpoint = Some(endpoint);
535
536        info!("QUIC client endpoint created");
537        Ok(())
538    }
539
540    /// Sends a synapse request to a miner and waits for the full response.
541    ///
542    /// Transparently reconnects if the underlying QUIC connection has died.
543    #[instrument(skip(self, axon_info, request), fields(miner_ip = %axon_info.ip, miner_port = axon_info.port))]
544    pub async fn query_axon(
545        &self,
546        axon_info: QuicAxonInfo,
547        request: QuicRequest,
548    ) -> Result<QuicResponse> {
549        self.query_axon_inner(axon_info, request, None).await
550    }
551
552    async fn query_axon_inner(
553        &self,
554        axon_info: QuicAxonInfo,
555        request: QuicRequest,
556        response_timeout: Option<Duration>,
557    ) -> Result<QuicResponse> {
558        let addr_key = axon_info.addr_key();
559
560        let (connection, bound) = {
561            let state = self.state.read().await;
562            (
563                state.registry.get_connection(&addr_key),
564                state
565                    .registry
566                    .is_authenticated_at(&axon_info.hotkey, &addr_key),
567            )
568        };
569
570        let max_fp = self.config.max_frame_payload_bytes;
571        match connection {
572            Some(conn) if conn.close_reason().is_none() => {
573                if !bound {
574                    return Err(LightningError::Handshake(format!(
575                        "no authenticated route for {} at {}",
576                        axon_info.hotkey, addr_key
577                    )));
578                }
579                debug!(
580                    addr = %addr_key,
581                    stable_id = conn.stable_id(),
582                    "query_axon: connection alive, sending synapse"
583                );
584                send_synapse_packet(&conn, request, max_fp, response_timeout).await
585            }
586            Some(conn) => {
587                let reason = conn.close_reason();
588                warn!(
589                    addr = %addr_key,
590                    stable_id = conn.stable_id(),
591                    close_reason = ?reason,
592                    "QUIC connection closed, triggering reconnect"
593                );
594                self.try_reconnect_and_query(&addr_key, &axon_info, request, response_timeout)
595                    .await
596            }
597            None => {
598                debug!(addr = %addr_key, "query_axon: no connection in registry");
599                self.try_reconnect_and_query(&addr_key, &axon_info, request, response_timeout)
600                    .await
601            }
602        }
603    }
604
605    /// Like [`query_axon`](Self::query_axon) but bounds how long the peer may
606    /// take to answer. The timeout budgets only the wait for the response
607    /// frame: the request transfer has its own size-proportional budget (see
608    /// [`write_budget_for`]) and the dial is bounded by `connect_timeout`, so
609    /// every phase is individually bounded. Wrapping the whole query instead
610    /// would drop the future mid-write for large payloads on slow paths,
611    /// resetting the stream so the peer reads a truncated request frame it
612    /// never had a chance to serve.
613    #[instrument(skip(self, axon_info, request), fields(miner_ip = %axon_info.ip, miner_port = axon_info.port, timeout_ms = timeout.as_millis() as u64))]
614    pub async fn query_axon_with_timeout(
615        &self,
616        axon_info: QuicAxonInfo,
617        request: QuicRequest,
618        timeout: Duration,
619    ) -> Result<QuicResponse> {
620        self.query_axon_inner(axon_info, request, Some(timeout))
621            .await
622    }
623
624    /// Sends a synapse request and returns a [`StreamingResponse`] for incremental chunk reading.
625    #[instrument(skip(self, axon_info, request), fields(miner_ip = %axon_info.ip, miner_port = axon_info.port))]
626    pub async fn query_axon_stream(
627        &self,
628        axon_info: QuicAxonInfo,
629        request: QuicRequest,
630    ) -> Result<StreamingResponse> {
631        let addr_key = axon_info.addr_key();
632
633        let (connection, bound) = {
634            let state = self.state.read().await;
635            (
636                state.registry.get_connection(&addr_key),
637                state
638                    .registry
639                    .is_authenticated_at(&axon_info.hotkey, &addr_key),
640            )
641        };
642
643        let max_fp = self.config.max_frame_payload_bytes;
644        let max_sp = self.config.max_stream_payload_bytes;
645        match connection {
646            Some(conn) if conn.close_reason().is_none() => {
647                if !bound {
648                    return Err(LightningError::Handshake(format!(
649                        "no authenticated route for {} at {}",
650                        axon_info.hotkey, addr_key
651                    )));
652                }
653                open_streaming_synapse(
654                    &conn,
655                    request,
656                    max_fp,
657                    max_sp,
658                    self.config.stream_chunk_timeout,
659                )
660                .await
661            }
662            Some(conn) => {
663                let reason = conn.close_reason();
664                warn!(
665                    addr = %addr_key,
666                    close_reason = ?reason,
667                    "QUIC connection closed, triggering reconnect (stream)"
668                );
669                self.try_reconnect_and_stream(&addr_key, &axon_info, request)
670                    .await
671            }
672            None => {
673                self.try_reconnect_and_stream(&addr_key, &axon_info, request)
674                    .await
675            }
676        }
677    }
678
679    async fn try_reconnect_and_query(
680        &self,
681        addr_key: &PeerAddr,
682        axon_info: &QuicAxonInfo,
683        request: QuicRequest,
684        response_timeout: Option<Duration>,
685    ) -> Result<QuicResponse> {
686        let connection = self.try_reconnect(addr_key, axon_info).await?;
687        send_synapse_packet(
688            &connection,
689            request,
690            self.config.max_frame_payload_bytes,
691            response_timeout,
692        )
693        .await
694    }
695
696    async fn try_reconnect_and_stream(
697        &self,
698        addr_key: &PeerAddr,
699        axon_info: &QuicAxonInfo,
700        request: QuicRequest,
701    ) -> Result<StreamingResponse> {
702        let connection = self.try_reconnect(addr_key, axon_info).await?;
703        open_streaming_synapse(
704            &connection,
705            request,
706            self.config.max_frame_payload_bytes,
707            self.config.max_stream_payload_bytes,
708            self.config.stream_chunk_timeout,
709        )
710        .await
711    }
712
713    async fn try_reconnect(
714        &self,
715        addr_key: &PeerAddr,
716        axon_info: &QuicAxonInfo,
717    ) -> Result<Connection> {
718        let endpoint = self
719            .endpoint
720            .as_ref()
721            .ok_or_else(|| LightningError::Connection("QUIC endpoint not initialized".into()))?
722            .clone();
723        let signer = self
724            .signer
725            .as_ref()
726            .ok_or_else(|| LightningError::Signing("No signer configured".into()))?
727            .clone();
728
729        {
730            let mut state = self.state.write().await;
731            if let Err(rejection) = state.registry.try_start_reconnect(
732                addr_key.clone(),
733                self.config.reconnect_max_retries,
734                self.config.reconnect_slow_probe_interval,
735            ) {
736                use crate::registry::ReconnectRejection;
737                return match rejection {
738                    ReconnectRejection::Backoff { next } => {
739                        Err(LightningError::Connection(format!(
740                            "Reconnection to {} in backoff, next retry in {:?}",
741                            addr_key,
742                            next.saturating_duration_since(Instant::now())
743                        )))
744                    }
745                    ReconnectRejection::Exhausted { attempts } => {
746                        Err(LightningError::Connection(format!(
747                            "Reconnection attempts exhausted for {} ({}/{}), awaiting registry refresh",
748                            addr_key, attempts, self.config.reconnect_max_retries
749                        )))
750                    }
751                    ReconnectRejection::InProgress => {
752                        Err(LightningError::Connection(format!(
753                            "Reconnection to {} already in progress",
754                            addr_key
755                        )))
756                    }
757                };
758            }
759        }
760
761        warn!("Connection to {} dead, attempting reconnection", addr_key);
762
763        let reconnect_result = tokio::time::timeout(
764            self.config.connect_timeout,
765            connect_and_handshake(
766                endpoint,
767                axon_info.clone(),
768                self.wallet_hotkey.clone(),
769                signer.clone(),
770                self.config.max_frame_payload_bytes,
771            ),
772        )
773        .await;
774
775        let reconnect_result = match reconnect_result {
776            Ok(r) => r,
777            Err(_) => Err(LightningError::Connection(format!(
778                "Reconnection to {} timed out",
779                addr_key
780            ))),
781        };
782
783        match reconnect_result {
784            Ok(connection) => {
785                let co_located: Vec<String> = {
786                    let state = self.state.read().await;
787                    state
788                        .registry
789                        .hotkeys_at_addr(addr_key)
790                        .into_iter()
791                        .filter(|hk| *hk != axon_info.hotkey)
792                        .collect()
793                };
794                let mut failed_hotkeys = Vec::new();
795                for hk in &co_located {
796                    match tokio::time::timeout(
797                        self.config.connect_timeout,
798                        authenticate_handshake(
799                            &connection,
800                            hk,
801                            &self.wallet_hotkey,
802                            &signer,
803                            self.config.max_frame_payload_bytes,
804                        ),
805                    )
806                    .await
807                    {
808                        Ok(Ok(())) => {
809                            info!(
810                                "Re-authenticated co-located miner {} on reconnected {}",
811                                hk, addr_key
812                            );
813                        }
814                        Ok(Err(e)) => {
815                            warn!(
816                                "Re-authentication failed for co-located miner {} at {}: {}",
817                                hk, addr_key, e
818                            );
819                            failed_hotkeys.push(hk.clone());
820                        }
821                        Err(_) => {
822                            warn!(
823                                "Re-authentication timed out for co-located miner {} at {}",
824                                hk, addr_key
825                            );
826                            failed_hotkeys.push(hk.clone());
827                        }
828                    }
829                }
830
831                let mut state = self.state.write().await;
832                for hk in &failed_hotkeys {
833                    state.registry.deregister(hk);
834                }
835                state.registry.register(axon_info.clone());
836                state
837                    .registry
838                    .set_connection(addr_key.clone(), connection.clone());
839                state.registry.remove_reconnect_state(addr_key);
840                info!("Reconnected to {}", addr_key);
841                Ok(connection)
842            }
843            Err(e) => {
844                let mut state = self.state.write().await;
845                let rs = state.registry.reconnect_state_or_insert(addr_key.clone());
846                rs.in_progress = false;
847                let shift = rs.attempts.min(20);
848                rs.attempts += 1;
849                let in_slow_probe = rs.attempts >= self.config.reconnect_max_retries;
850                if in_slow_probe {
851                    if let Some(probe_interval) = self.config.reconnect_slow_probe_interval {
852                        rs.next_retry_at = Instant::now() + probe_interval;
853                        warn!(
854                            "Slow probe to {} failed, next probe in {:?}: {}",
855                            addr_key, probe_interval, e
856                        );
857                    }
858                } else {
859                    let backoff = self
860                        .config
861                        .reconnect_initial_backoff
862                        .checked_mul(2u32.pow(shift))
863                        .map(|d| d.min(self.config.reconnect_max_backoff))
864                        .unwrap_or(self.config.reconnect_max_backoff);
865                    rs.next_retry_at = Instant::now() + backoff;
866                    error!(
867                        "Reconnection to {} failed (attempt {}/{}), next retry in {:?}: {}",
868                        addr_key, rs.attempts, self.config.reconnect_max_retries, backoff, e
869                    );
870                }
871                Err(e)
872            }
873        }
874    }
875
876    /// Reconciles the active miner set: adds new miners, removes stale ones,
877    /// and opens/closes QUIC connections as needed.
878    #[instrument(skip(self, miners), fields(miner_count = miners.len()))]
879    pub async fn update_miner_registry(&self, miners: Vec<QuicAxonInfo>) -> Result<()> {
880        let endpoint = self
881            .endpoint
882            .as_ref()
883            .ok_or_else(|| LightningError::Connection("QUIC endpoint not initialized".into()))?
884            .clone();
885        let signer = self
886            .signer
887            .as_ref()
888            .ok_or_else(|| LightningError::Signing("No signer configured".into()))?
889            .clone();
890        update_miner_registry_inner(
891            &self.state,
892            &endpoint,
893            &self.wallet_hotkey,
894            &signer,
895            &self.config,
896            miners,
897        )
898        .await
899    }
900
901    /// Returns the set of miner hotkeys that have completed the authentication
902    /// handshake and hold an active registry entry. A hotkey stays listed while
903    /// registered even if its connection is momentarily re-establishing, so
904    /// callers receive every peer whose identity has been confirmed, not only
905    /// those with a live connection at this instant.
906    pub async fn authenticated_hotkeys(&self) -> std::collections::HashSet<String> {
907        let state = self.state.read().await;
908        state.registry.active_hotkeys().into_iter().collect()
909    }
910
911    /// Returns a map of connection statistics (total connections, active miners, per-address status).
912    #[instrument(skip(self))]
913    pub async fn get_connection_stats(&self) -> Result<HashMap<String, String>> {
914        let state = self.state.read().await;
915
916        let mut stats = HashMap::new();
917        stats.insert(
918            "total_connections".to_string(),
919            state.registry.connection_count().to_string(),
920        );
921        stats.insert(
922            "active_miners".to_string(),
923            state.registry.active_miner_count().to_string(),
924        );
925
926        for addr_key in state.registry.connection_addrs() {
927            let status = match state.registry.get_connection(addr_key) {
928                Some(conn) => {
929                    if let Some(reason) = conn.close_reason() {
930                        format!("closed({:?})", reason)
931                    } else {
932                        "active".to_string()
933                    }
934                }
935                None => "missing".to_string(),
936            };
937            stats.insert(format!("connection_{}", addr_key), status);
938        }
939
940        Ok(stats)
941    }
942
943    /// Starts a background task that periodically syncs the metagraph and updates connections.
944    /// Requires the `subtensor` feature.
945    #[cfg(feature = "subtensor")]
946    pub async fn start_metagraph_monitor(
947        &self,
948        monitor_config: MetagraphMonitorConfig,
949    ) -> Result<()> {
950        if monitor_config.sync_interval.is_zero() {
951            return Err(LightningError::Config(
952                "sync_interval must be non-zero".into(),
953            ));
954        }
955
956        self.stop_metagraph_monitor().await;
957
958        let endpoint = self
959            .endpoint
960            .as_ref()
961            .ok_or_else(|| LightningError::Connection("QUIC endpoint not initialized".into()))?
962            .clone();
963        let signer = self
964            .signer
965            .as_ref()
966            .ok_or_else(|| LightningError::Signing("No signer configured".into()))?
967            .clone();
968
969        let subtensor = tokio::time::timeout(
970            Duration::from_secs(30),
971            connect_subtensor::<PolkadotConfig>(&monitor_config.subtensor_endpoint),
972        )
973        .await
974        .map_err(|_| LightningError::Handler("subtensor connection timed out after 30s".into()))?
975        .map_err(|e| LightningError::Handler(format!("connecting to subtensor: {}", e)))?;
976
977        let mut metagraph = Metagraph::new(monitor_config.netuid);
978        tokio::time::timeout(Duration::from_secs(60), metagraph.sync(&subtensor))
979            .await
980            .map_err(|_| {
981                LightningError::Handler("initial metagraph sync timed out after 60s".into())
982            })??;
983
984        let miners = metagraph.quic_miners();
985        info!(
986            netuid = monitor_config.netuid,
987            miners = miners.len(),
988            "initial metagraph sync complete"
989        );
990
991        update_miner_registry_inner(
992            &self.state,
993            &endpoint,
994            &self.wallet_hotkey,
995            &signer,
996            &self.config,
997            miners,
998        )
999        .await?;
1000
1001        let (shutdown_tx, mut shutdown_rx) = tokio::sync::watch::channel(false);
1002        let state = self.state.clone();
1003        let wallet_hotkey = self.wallet_hotkey.clone();
1004        let config = self.config.clone();
1005        let sync_interval = monitor_config.sync_interval;
1006        let subtensor_url = monitor_config.subtensor_endpoint.clone();
1007
1008        let handle = tokio::spawn(async move {
1009            let mut interval = tokio::time::interval(sync_interval);
1010            interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1011            interval.tick().await;
1012            let mut subtensor = subtensor;
1013
1014            loop {
1015                tokio::select! {
1016                    _ = interval.tick() => {}
1017                    _ = shutdown_rx.changed() => {
1018                        info!("metagraph monitor shutting down");
1019                        return;
1020                    }
1021                }
1022
1023                let sync_result =
1024                    tokio::time::timeout(Duration::from_secs(60), metagraph.sync(&subtensor)).await;
1025
1026                let needs_reconnect = match sync_result {
1027                    Ok(Ok(())) => {
1028                        let miners = metagraph.quic_miners();
1029                        info!(
1030                            netuid = metagraph.netuid,
1031                            miners = miners.len(),
1032                            block = metagraph.block,
1033                            "metagraph resync complete"
1034                        );
1035                        if let Err(e) = update_miner_registry_inner(
1036                            &state,
1037                            &endpoint,
1038                            &wallet_hotkey,
1039                            &signer,
1040                            &config,
1041                            miners,
1042                        )
1043                        .await
1044                        {
1045                            error!("registry update after metagraph sync failed: {}", e);
1046                        }
1047                        false
1048                    }
1049                    Ok(Err(e)) => {
1050                        error!("metagraph sync failed, reconnecting to subtensor: {}", e);
1051                        true
1052                    }
1053                    Err(_) => {
1054                        error!("metagraph sync timed out after 60s, reconnecting to subtensor");
1055                        true
1056                    }
1057                };
1058
1059                if needs_reconnect {
1060                    match tokio::time::timeout(
1061                        Duration::from_secs(30),
1062                        connect_subtensor::<PolkadotConfig>(&subtensor_url),
1063                    )
1064                    .await
1065                    {
1066                        Ok(Ok(new_client)) => {
1067                            subtensor = new_client;
1068                            info!("subtensor client reconnected");
1069                        }
1070                        Ok(Err(e)) => {
1071                            error!("subtensor reconnection failed: {}", e);
1072                        }
1073                        Err(_) => {
1074                            error!("subtensor reconnection timed out after 30s");
1075                        }
1076                    }
1077                }
1078            }
1079        });
1080
1081        let mut st = self.state.write().await;
1082        st.metagraph_shutdown = Some(shutdown_tx);
1083        st.metagraph_handle = Some(handle);
1084        Ok(())
1085    }
1086
1087    /// Stops the metagraph monitor background task if running.
1088    #[cfg(feature = "subtensor")]
1089    pub async fn stop_metagraph_monitor(&self) {
1090        let (shutdown_tx, handle) = {
1091            let mut st = self.state.write().await;
1092            (st.metagraph_shutdown.take(), st.metagraph_handle.take())
1093        };
1094        if let Some(tx) = shutdown_tx {
1095            let _ = tx.send(true);
1096        }
1097        if let Some(mut handle) = handle {
1098            if tokio::time::timeout(Duration::from_secs(5), &mut handle)
1099                .await
1100                .is_err()
1101            {
1102                warn!("metagraph monitor did not shut down within 5s, aborting");
1103                handle.abort();
1104                let _ = handle.await;
1105            }
1106        }
1107    }
1108
1109    /// Gracefully closes all QUIC connections and clears the miner registry.
1110    #[instrument(skip(self))]
1111    pub async fn close_all_connections(&self) -> Result<()> {
1112        #[cfg(feature = "subtensor")]
1113        self.stop_metagraph_monitor().await;
1114
1115        let mut state = self.state.write().await;
1116
1117        for (_, connection) in state.registry.drain_connections() {
1118            connection.close(0u32.into(), b"client_shutdown");
1119        }
1120
1121        state.registry.clear();
1122
1123        info!("All Lightning QUIC connections closed");
1124        Ok(())
1125    }
1126}
1127
1128async fn update_miner_registry_inner(
1129    state: &Arc<RwLock<ClientState>>,
1130    endpoint: &Endpoint,
1131    wallet_hotkey: &str,
1132    signer: &Arc<dyn Signer>,
1133    config: &LightningClientConfig,
1134    miners: Vec<QuicAxonInfo>,
1135) -> Result<()> {
1136    let new_by_hotkey: HashMap<String, QuicAxonInfo> = miners
1137        .iter()
1138        .map(|m| (m.hotkey.clone(), m.clone()))
1139        .collect();
1140
1141    let new_hotkeys_needing_auth: Vec<QuicAxonInfo>;
1142    let new_addrs_needing_connect: HashMap<PeerAddr, Vec<QuicAxonInfo>>;
1143    {
1144        let mut st = state.write().await;
1145
1146        let active_hotkeys = st.registry.active_hotkeys();
1147        for hotkey in active_hotkeys {
1148            if !new_by_hotkey.contains_key(&hotkey) {
1149                if let Some(miner) = st.registry.deregister(&hotkey) {
1150                    let addr_key = miner.addr_key();
1151                    info!("Miner {} deregistered from {}", hotkey, addr_key);
1152                    if !st.registry.addr_has_hotkeys(&addr_key) {
1153                        if let Some(connection) = st.registry.remove_connection(&addr_key) {
1154                            connection.close(0u32.into(), b"miner_deregistered");
1155                        }
1156                        st.registry.remove_reconnect_state(&addr_key);
1157                    }
1158                }
1159            }
1160        }
1161
1162        let active_addrs = st.registry.active_addrs();
1163        for addr_key in &active_addrs {
1164            if st.registry.remove_reconnect_state(addr_key) {
1165                info!(
1166                    "Registry refresh reset reconnection backoff for {}",
1167                    addr_key
1168                );
1169            }
1170        }
1171
1172        let dead_addrs: Vec<PeerAddr> = active_addrs
1173            .iter()
1174            .filter(|addr| {
1175                st.registry
1176                    .get_connection(addr)
1177                    .is_some_and(|c| c.close_reason().is_some())
1178            })
1179            .cloned()
1180            .collect();
1181        for addr_key in &dead_addrs {
1182            if let Some(conn) = st.registry.remove_connection(addr_key) {
1183                let hotkeys = st.registry.hotkeys_at_addr(addr_key);
1184                info!(
1185                    addr = %addr_key,
1186                    close_reason = ?conn.close_reason(),
1187                    hotkeys = ?hotkeys,
1188                    "Pruning dead connection and deregistering miners"
1189                );
1190                for hk in &hotkeys {
1191                    st.registry.deregister(hk);
1192                }
1193            }
1194        }
1195
1196        for new_miner in new_by_hotkey.values() {
1197            if let Some(old_miner) = st.registry.active_miner(&new_miner.hotkey) {
1198                let old_addr = old_miner.addr_key();
1199                let new_addr = new_miner.addr_key();
1200                if old_addr != new_addr {
1201                    info!(
1202                        "Miner {} changed address from {} to {}",
1203                        new_miner.hotkey, old_addr, new_addr
1204                    );
1205                    st.registry.deregister(&new_miner.hotkey);
1206                    if !st.registry.addr_has_hotkeys(&old_addr) {
1207                        if let Some(conn) = st.registry.remove_connection(&old_addr) {
1208                            conn.close(0u32.into(), b"miner_addr_changed");
1209                        }
1210                        st.registry.remove_reconnect_state(&old_addr);
1211                    }
1212                }
1213            }
1214        }
1215
1216        let new_hotkeys: Vec<QuicAxonInfo> = new_by_hotkey
1217            .values()
1218            .filter(|m| !st.registry.contains_active_miner(&m.hotkey))
1219            .cloned()
1220            .collect();
1221
1222        let mut need_auth = Vec::new();
1223        let mut need_connect: HashMap<PeerAddr, Vec<QuicAxonInfo>> = HashMap::new();
1224        for miner in new_hotkeys {
1225            let addr_key = miner.addr_key();
1226            if st.registry.contains_connection(&addr_key) {
1227                need_auth.push(miner);
1228            } else {
1229                need_connect.entry(addr_key).or_default().push(miner);
1230            }
1231        }
1232
1233        let active_count = st.registry.connection_count();
1234        let remaining_capacity = config.max_connections.saturating_sub(active_count);
1235        if need_connect.len() > remaining_capacity {
1236            warn!(
1237                "Connection limit ({}) reached with {} active, skipping {} of {} new addresses",
1238                config.max_connections,
1239                active_count,
1240                need_connect.len() - remaining_capacity,
1241                need_connect.len()
1242            );
1243        }
1244
1245        new_hotkeys_needing_auth = need_auth;
1246        new_addrs_needing_connect = need_connect.into_iter().take(remaining_capacity).collect();
1247    }
1248
1249    let timeout = config.connect_timeout;
1250    let max_fp = config.max_frame_payload_bytes;
1251
1252    if !new_hotkeys_needing_auth.is_empty() {
1253        let miners_with_conns: Vec<(QuicAxonInfo, Connection)> = {
1254            let st = state.read().await;
1255            new_hotkeys_needing_auth
1256                .into_iter()
1257                .filter_map(|miner| {
1258                    let addr_key = miner.addr_key();
1259                    st.registry
1260                        .get_connection(&addr_key)
1261                        .map(|conn| (miner, conn))
1262                })
1263                .collect()
1264        };
1265
1266        let mut authenticated = Vec::new();
1267        for (miner, conn) in &miners_with_conns {
1268            let addr_key = miner.addr_key();
1269            match tokio::time::timeout(
1270                timeout,
1271                authenticate_handshake(conn, &miner.hotkey, wallet_hotkey, signer, max_fp),
1272            )
1273            .await
1274            {
1275                Ok(Ok(())) => {
1276                    info!(
1277                        "Authenticated new miner {} on existing connection to {}",
1278                        miner.hotkey, addr_key
1279                    );
1280                    authenticated.push(miner.clone());
1281                }
1282                Ok(Err(e)) => {
1283                    warn!(
1284                        "Handshake failed for new hotkey {} at {}: {}",
1285                        miner.hotkey, addr_key, e
1286                    );
1287                }
1288                Err(_) => {
1289                    warn!(
1290                        "Handshake timed out for new hotkey {} at {}",
1291                        miner.hotkey, addr_key
1292                    );
1293                }
1294            }
1295        }
1296
1297        let mut st = state.write().await;
1298        for miner in authenticated {
1299            st.registry.register(miner);
1300        }
1301    }
1302
1303    if !new_addrs_needing_connect.is_empty() {
1304        let mut set = tokio::task::JoinSet::new();
1305        for (addr_key, miners_at_addr) in new_addrs_needing_connect {
1306            info!(
1307                "New address detected, establishing QUIC connection: {}",
1308                addr_key
1309            );
1310            let ep = endpoint.clone();
1311            let wh = wallet_hotkey.to_string();
1312            let s = signer.clone();
1313            set.spawn(connect_and_authenticate_per_address(
1314                ep,
1315                wh,
1316                s,
1317                addr_key,
1318                miners_at_addr,
1319                timeout,
1320                max_fp,
1321            ));
1322        }
1323
1324        let mut results = Vec::new();
1325        while let Some(join_result) = set.join_next().await {
1326            match join_result {
1327                Ok((addr_key, conn_result, authenticated)) => {
1328                    results.push((addr_key, conn_result, authenticated));
1329                }
1330                Err(e) => {
1331                    error!("Connection task panicked: {}", e);
1332                }
1333            }
1334        }
1335
1336        let mut st = state.write().await;
1337        for (addr_key, conn_result, authenticated) in results {
1338            match conn_result {
1339                Ok(connection) => {
1340                    if authenticated.is_empty() {
1341                        warn!(
1342                            "No hotkeys authenticated at {}, dropping connection",
1343                            addr_key
1344                        );
1345                        connection.close(0u32.into(), b"no_authenticated_hotkeys");
1346                    } else {
1347                        for miner in authenticated {
1348                            st.registry.register(miner);
1349                        }
1350                        st.registry.set_connection(addr_key, connection);
1351                    }
1352                }
1353                Err(e) => {
1354                    error!("Failed to connect to {}: {}", addr_key, e);
1355                }
1356            }
1357        }
1358    }
1359
1360    Ok(())
1361}
1362
1363fn get_peer_cert_fingerprint(connection: &Connection) -> Option<[u8; 32]> {
1364    let identity = connection.peer_identity()?;
1365    let certs = identity.downcast::<Vec<CertificateDer<'static>>>().ok()?;
1366    let first = certs.first()?;
1367    Some(blake2_256(first.as_ref()))
1368}
1369
1370async fn quic_connect(
1371    endpoint: &Endpoint,
1372    addr_key: &PeerAddr,
1373    server_name: &str,
1374) -> Result<Connection> {
1375    let addr: SocketAddr = addr_key
1376        .as_ref()
1377        .parse()
1378        .map_err(|e| LightningError::Connection(format!("Invalid address: {}", e)))?;
1379
1380    endpoint
1381        .connect(addr, server_name)
1382        .map_err(|e| LightningError::Connection(format!("Connection failed: {}", e)))?
1383        .await
1384        .map_err(|e| LightningError::Connection(format!("Connection handshake failed: {}", e)))
1385}
1386
1387async fn connect_and_authenticate_per_address(
1388    endpoint: Endpoint,
1389    wallet_hotkey: String,
1390    signer: Arc<dyn Signer>,
1391    addr_key: PeerAddr,
1392    miners_at_addr: Vec<QuicAxonInfo>,
1393    timeout: Duration,
1394    max_frame_payload: usize,
1395) -> (PeerAddr, Result<Connection>, Vec<QuicAxonInfo>) {
1396    let first = match miners_at_addr.first() {
1397        Some(m) => m,
1398        None => {
1399            return (
1400                addr_key,
1401                Err(LightningError::Connection("no miners for address".into())),
1402                vec![],
1403            );
1404        }
1405    };
1406
1407    let conn = match tokio::time::timeout(timeout, quic_connect(&endpoint, &addr_key, &first.ip))
1408        .await
1409    {
1410        Ok(Ok(c)) => c,
1411        Ok(Err(e)) => return (addr_key, Err(e), vec![]),
1412        Err(_) => {
1413            let err = LightningError::Connection(format!("Connection to {} timed out", addr_key));
1414            return (addr_key, Err(err), vec![]);
1415        }
1416    };
1417
1418    let mut authenticated = Vec::new();
1419    for miner in &miners_at_addr {
1420        match tokio::time::timeout(
1421            timeout,
1422            authenticate_handshake(
1423                &conn,
1424                &miner.hotkey,
1425                &wallet_hotkey,
1426                &signer,
1427                max_frame_payload,
1428            ),
1429        )
1430        .await
1431        {
1432            Ok(Ok(())) => authenticated.push(miner.clone()),
1433            Ok(Err(e)) => {
1434                warn!(
1435                    "Handshake failed for hotkey {} at {}: {}",
1436                    miner.hotkey, addr_key, e
1437                );
1438            }
1439            Err(_) => {
1440                warn!(
1441                    "Handshake timed out for hotkey {} at {}",
1442                    miner.hotkey, addr_key
1443                );
1444            }
1445        }
1446    }
1447
1448    (addr_key, Ok(conn), authenticated)
1449}
1450
1451async fn authenticate_handshake(
1452    connection: &Connection,
1453    expected_hotkey: &str,
1454    wallet_hotkey: &str,
1455    signer: &Arc<dyn Signer>,
1456    max_frame_payload: usize,
1457) -> Result<()> {
1458    let peer_cert_fp = get_peer_cert_fingerprint(connection).ok_or_else(|| {
1459        LightningError::Handshake("peer certificate not available for fingerprinting".to_string())
1460    })?;
1461    let peer_cert_fp_b64 = BASE64_STANDARD.encode(peer_cert_fp);
1462
1463    let nonce = generate_nonce();
1464    let timestamp = unix_timestamp_secs();
1465    let message = handshake_request_message(wallet_hotkey, timestamp, &nonce, &peer_cert_fp_b64);
1466    let msg_bytes = message.into_bytes();
1467    let signer_clone = signer.clone();
1468    let signature_bytes = tokio::task::spawn_blocking(move || signer_clone.sign(&msg_bytes))
1469        .await
1470        .map_err(|e| LightningError::Signing(format!("signer task failed: {}", e)))??;
1471
1472    let handshake_request = HandshakeRequest {
1473        validator_hotkey: wallet_hotkey.to_string(),
1474        timestamp,
1475        nonce: nonce.clone(),
1476        signature: BASE64_STANDARD.encode(&signature_bytes),
1477    };
1478
1479    let response = send_handshake(connection, handshake_request, max_frame_payload).await?;
1480    if !response.accepted {
1481        return Err(LightningError::Handshake(
1482            "Handshake rejected by miner".into(),
1483        ));
1484    }
1485
1486    if response.miner_hotkey != expected_hotkey {
1487        return Err(LightningError::Handshake(format!(
1488            "Miner hotkey mismatch: expected {}, got {}",
1489            expected_hotkey, response.miner_hotkey
1490        )));
1491    }
1492
1493    match response.cert_fingerprint {
1494        Some(ref resp_fp) if *resp_fp == peer_cert_fp_b64 => {}
1495        Some(_) => {
1496            return Err(LightningError::Handshake(
1497                "Cert fingerprint mismatch between TLS session and handshake response".to_string(),
1498            ));
1499        }
1500        None => {
1501            return Err(LightningError::Handshake(
1502                "Miner handshake response omitted required cert fingerprint".to_string(),
1503            ));
1504        }
1505    }
1506
1507    verify_miner_response_signature(&response, wallet_hotkey, &nonce, &peer_cert_fp_b64).await?;
1508
1509    info!("Handshake successful with miner {}", expected_hotkey);
1510    Ok(())
1511}
1512
1513async fn connect_and_handshake(
1514    endpoint: Endpoint,
1515    miner: QuicAxonInfo,
1516    wallet_hotkey: String,
1517    signer: Arc<dyn Signer>,
1518    max_frame_payload: usize,
1519) -> Result<Connection> {
1520    let addr_key = miner.addr_key();
1521    let connection = quic_connect(&endpoint, &addr_key, &miner.ip).await?;
1522    authenticate_handshake(
1523        &connection,
1524        &miner.hotkey,
1525        &wallet_hotkey,
1526        &signer,
1527        max_frame_payload,
1528    )
1529    .await?;
1530    Ok(connection)
1531}
1532
1533async fn verify_miner_response_signature(
1534    response: &HandshakeResponse,
1535    validator_hotkey: &str,
1536    nonce: &str,
1537    cert_fp_b64: &str,
1538) -> Result<()> {
1539    if response.signature.is_empty() {
1540        return Err(LightningError::Handshake(
1541            "Miner returned empty signature".to_string(),
1542        ));
1543    }
1544
1545    let expected_message = handshake_response_message(
1546        validator_hotkey,
1547        &response.miner_hotkey,
1548        response.timestamp,
1549        nonce,
1550        cert_fp_b64,
1551    );
1552
1553    let valid = crate::signing::verify_sr25519_signature(
1554        &response.miner_hotkey,
1555        &response.signature,
1556        &expected_message,
1557    )
1558    .await?;
1559
1560    if !valid {
1561        return Err(LightningError::Handshake(
1562            "Miner response signature verification failed".to_string(),
1563        ));
1564    }
1565
1566    Ok(())
1567}
1568
1569async fn send_handshake(
1570    connection: &Connection,
1571    request: HandshakeRequest,
1572    max_frame_payload: usize,
1573) -> Result<HandshakeResponse> {
1574    let (mut send, mut recv) = connection.open_bi().await.map_err(|e| {
1575        LightningError::Connection(format!("Failed to open bidirectional stream: {}", e))
1576    })?;
1577
1578    let request_bytes = rmp_serde::to_vec(&request).map_err(|e| {
1579        LightningError::Serialization(format!("Failed to serialize handshake: {}", e))
1580    })?;
1581
1582    write_frame_and_finish(&mut send, MessageType::HandshakeRequest, &request_bytes).await?;
1583
1584    let (msg_type, payload) = read_frame(&mut recv, max_frame_payload).await?;
1585    if msg_type != MessageType::HandshakeResponse {
1586        return Err(LightningError::Handshake(format!(
1587            "Expected HandshakeResponse, got {:?}",
1588            msg_type
1589        )));
1590    }
1591
1592    let response: HandshakeResponse = rmp_serde::from_slice(&payload).map_err(|e| {
1593        LightningError::Serialization(format!("Failed to parse handshake response: {}", e))
1594    })?;
1595
1596    Ok(response)
1597}
1598
1599/// Seconds granted to a request transfer regardless of size, covering stream
1600/// scheduling and flow-control round trips on an otherwise healthy path.
1601const WRITE_BUDGET_FLOOR_SECS: u64 = 5;
1602/// Minimum sustained transfer rate a peer must accept before the transfer is
1603/// abandoned. One MiB/s keeps multi-megabyte payloads deliverable over
1604/// residential-grade paths while still bounding the write phase.
1605const WRITE_BUDGET_MIN_BYTES_PER_SEC: u64 = 1024 * 1024;
1606/// Hard ceiling on any single request transfer. A peer that reads slower than
1607/// this bound cannot pin a dispatch slot indefinitely by dribbling acks.
1608const WRITE_BUDGET_MAX_SECS: u64 = 60;
1609
1610/// Time budget for writing a request frame of `len` bytes: a fixed floor plus
1611/// a size-proportional allowance, capped. Sized to the payload rather than to
1612/// response latency so that a large request over a slow path is never reset
1613/// mid-frame by a timeout calibrated on how fast peers *answer*.
1614fn write_budget_for(len: usize) -> Duration {
1615    let transfer_secs = (len as u64).div_ceil(WRITE_BUDGET_MIN_BYTES_PER_SEC);
1616    Duration::from_secs((WRITE_BUDGET_FLOOR_SECS + transfer_secs).min(WRITE_BUDGET_MAX_SECS))
1617}
1618
1619async fn send_synapse_frame(send: &mut quinn::SendStream, request: QuicRequest) -> Result<()> {
1620    let synapse_packet = SynapsePacket {
1621        synapse_type: request.synapse_type,
1622        data: request.data,
1623        timestamp: unix_timestamp_secs(),
1624    };
1625
1626    let packet_bytes = rmp_serde::to_vec(&synapse_packet).map_err(|e| {
1627        LightningError::Serialization(format!("Failed to serialize synapse packet: {}", e))
1628    })?;
1629
1630    let budget = write_budget_for(packet_bytes.len());
1631    tokio::time::timeout(
1632        budget,
1633        write_frame_and_finish(send, MessageType::SynapsePacket, &packet_bytes),
1634    )
1635    .await
1636    .map_err(|_| {
1637        LightningError::Transport(format!(
1638            "request transfer timed out after {:?} ({} bytes unacknowledged by peer)",
1639            budget,
1640            packet_bytes.len()
1641        ))
1642    })?
1643}
1644
1645async fn send_synapse_packet(
1646    connection: &Connection,
1647    request: QuicRequest,
1648    max_frame_payload: usize,
1649    response_timeout: Option<Duration>,
1650) -> Result<QuicResponse> {
1651    let stable_id = connection.stable_id();
1652    debug!(stable_id, "send_synapse_packet: opening bi stream");
1653    let (mut send, mut recv) = connection
1654        .open_bi()
1655        .await
1656        .map_err(|e| LightningError::Connection(format!("Failed to open stream: {}", e)))?;
1657    debug!(stable_id, "send_synapse_packet: bi stream opened");
1658
1659    let start = Instant::now();
1660
1661    send_synapse_frame(&mut send, request).await?;
1662    debug!(
1663        stable_id,
1664        "send_synapse_packet: frame sent, awaiting response"
1665    );
1666
1667    let read = read_frame(&mut recv, max_frame_payload);
1668    let (msg_type, payload) = match response_timeout {
1669        Some(t) => tokio::time::timeout(t, read)
1670            .await
1671            .map_err(|_| LightningError::Transport("query timed out".into()))??,
1672        None => read.await?,
1673    };
1674    debug!(stable_id, msg_type = ?msg_type, elapsed_ms = start.elapsed().as_millis() as u64, "send_synapse_packet: response received");
1675
1676    match msg_type {
1677        MessageType::SynapseResponse => {
1678            let latency_ms = start.elapsed().as_secs_f64() * 1000.0;
1679            let synapse_response: SynapseResponse =
1680                rmp_serde::from_slice(&payload).map_err(|e| {
1681                    LightningError::Serialization(format!(
1682                        "Failed to parse synapse response: {}",
1683                        e
1684                    ))
1685                })?;
1686
1687            Ok(QuicResponse {
1688                success: synapse_response.success,
1689                data: synapse_response.data,
1690                latency_ms,
1691                error: synapse_response.error,
1692            })
1693        }
1694        MessageType::StreamChunk => Err(LightningError::Transport(
1695            "received StreamChunk on non-streaming query; use query_axon_stream for streaming synapses".to_string(),
1696        )),
1697        other => Err(LightningError::Transport(format!(
1698            "unexpected response type: {:?}",
1699            other
1700        ))),
1701    }
1702}
1703
1704async fn open_streaming_synapse(
1705    connection: &Connection,
1706    request: QuicRequest,
1707    max_frame_payload: usize,
1708    max_stream_payload: usize,
1709    chunk_timeout: Option<Duration>,
1710) -> Result<StreamingResponse> {
1711    let (mut send, recv) = connection
1712        .open_bi()
1713        .await
1714        .map_err(|e| LightningError::Connection(format!("Failed to open stream: {}", e)))?;
1715
1716    send_synapse_frame(&mut send, request).await?;
1717
1718    Ok(StreamingResponse {
1719        recv,
1720        max_payload: max_frame_payload,
1721        max_stream_payload,
1722        chunk_timeout,
1723    })
1724}
1725
1726fn generate_nonce() -> String {
1727    use rand::Rng;
1728    let bytes: [u8; 16] = rand::thread_rng().gen();
1729    format!("{:032x}", u128::from_be_bytes(bytes))
1730}
1731
1732#[cfg(test)]
1733mod tests {
1734    use super::*;
1735    use sp_core::{crypto::Ss58Codec, sr25519, Pair};
1736
1737    const MINER_SEED: [u8; 32] = [1u8; 32];
1738    const VALIDATOR_SEED: [u8; 32] = [2u8; 32];
1739
1740    #[test]
1741    fn write_budget_scales_with_payload_size() {
1742        let floor = write_budget_for(0);
1743        assert_eq!(floor, Duration::from_secs(WRITE_BUDGET_FLOOR_SECS));
1744
1745        let one_mib = write_budget_for(1024 * 1024);
1746        assert_eq!(one_mib, Duration::from_secs(WRITE_BUDGET_FLOOR_SECS + 1));
1747
1748        let ten_mib = write_budget_for(10 * 1024 * 1024);
1749        assert_eq!(ten_mib, Duration::from_secs(WRITE_BUDGET_FLOOR_SECS + 10));
1750    }
1751
1752    #[test]
1753    fn write_budget_is_capped() {
1754        let huge = write_budget_for(usize::MAX);
1755        assert_eq!(huge, Duration::from_secs(WRITE_BUDGET_MAX_SECS));
1756
1757        let at_cap = write_budget_for((WRITE_BUDGET_MAX_SECS as usize) * 1024 * 1024 * 2);
1758        assert_eq!(at_cap, Duration::from_secs(WRITE_BUDGET_MAX_SECS));
1759    }
1760
1761    #[test]
1762    fn write_budget_exceeds_typical_response_timeouts_for_large_payloads() {
1763        // The regression this guards: a multi-megabyte request must get more
1764        // transfer budget than the adaptive response timeout (single-digit
1765        // seconds), so the response clock can never truncate a request frame
1766        // mid-write again.
1767        let five_mib = write_budget_for(5 * 1024 * 1024);
1768        assert!(five_mib >= Duration::from_secs(10));
1769    }
1770
1771    fn make_signed_response(
1772        miner_seed: [u8; 32],
1773        validator_hotkey: &str,
1774        nonce: &str,
1775        cert_fp_b64: &str,
1776    ) -> HandshakeResponse {
1777        let pair = sr25519::Pair::from_seed(&miner_seed);
1778        let miner_hotkey = pair.public().to_ss58check();
1779        let timestamp = unix_timestamp_secs();
1780        let message = handshake_response_message(
1781            validator_hotkey,
1782            &miner_hotkey,
1783            timestamp,
1784            nonce,
1785            cert_fp_b64,
1786        );
1787        let signature = pair.sign(message.as_bytes());
1788        HandshakeResponse {
1789            miner_hotkey,
1790            timestamp,
1791            signature: BASE64_STANDARD.encode(signature.0),
1792            accepted: true,
1793            connection_id: "test".to_string(),
1794            cert_fingerprint: Some(cert_fp_b64.to_string()),
1795        }
1796    }
1797
1798    fn validator_hotkey() -> String {
1799        sr25519::Pair::from_seed(&VALIDATOR_SEED)
1800            .public()
1801            .to_ss58check()
1802    }
1803
1804    #[tokio::test]
1805    async fn verify_valid_miner_signature() {
1806        let nonce = "test-nonce";
1807        let fp = "dGVzdC1mcA==";
1808        let resp = make_signed_response(MINER_SEED, &validator_hotkey(), nonce, fp);
1809        assert!(
1810            verify_miner_response_signature(&resp, &validator_hotkey(), nonce, fp)
1811                .await
1812                .is_ok()
1813        );
1814    }
1815
1816    #[tokio::test]
1817    async fn verify_rejects_empty_signature() {
1818        let mut resp = make_signed_response(MINER_SEED, &validator_hotkey(), "n", "fp");
1819        resp.signature = String::new();
1820        let err = verify_miner_response_signature(&resp, &validator_hotkey(), "n", "fp")
1821            .await
1822            .unwrap_err();
1823        assert!(err.to_string().contains("empty signature"));
1824    }
1825
1826    #[tokio::test]
1827    async fn verify_rejects_invalid_base64() {
1828        let mut resp = make_signed_response(MINER_SEED, &validator_hotkey(), "n", "fp");
1829        resp.signature = "not-valid-base64!!!".to_string();
1830        let err = verify_miner_response_signature(&resp, &validator_hotkey(), "n", "fp")
1831            .await
1832            .unwrap_err();
1833        assert!(err.to_string().contains("Failed to decode signature"));
1834    }
1835
1836    #[tokio::test]
1837    async fn verify_rejects_wrong_signature_length() {
1838        let mut resp = make_signed_response(MINER_SEED, &validator_hotkey(), "n", "fp");
1839        resp.signature = BASE64_STANDARD.encode([0u8; 32]);
1840        let err = verify_miner_response_signature(&resp, &validator_hotkey(), "n", "fp")
1841            .await
1842            .unwrap_err();
1843        assert!(err.to_string().contains("Invalid signature length"));
1844    }
1845
1846    #[tokio::test]
1847    async fn verify_rejects_bad_ss58_address() {
1848        let mut resp = make_signed_response(MINER_SEED, &validator_hotkey(), "n", "fp");
1849        resp.miner_hotkey = "not_a_valid_ss58".to_string();
1850        let err = verify_miner_response_signature(&resp, &validator_hotkey(), "n", "fp")
1851            .await
1852            .unwrap_err();
1853        assert!(err.to_string().contains("Invalid SS58 address"));
1854    }
1855
1856    #[tokio::test]
1857    async fn verify_rejects_wrong_signer() {
1858        let nonce = "n";
1859        let fp = "fp";
1860        let mut resp = make_signed_response(MINER_SEED, &validator_hotkey(), nonce, fp);
1861        let wrong_pair = sr25519::Pair::from_seed(&[99u8; 32]);
1862        resp.miner_hotkey = wrong_pair.public().to_ss58check();
1863        let err = verify_miner_response_signature(&resp, &validator_hotkey(), nonce, fp)
1864            .await
1865            .unwrap_err();
1866        assert!(err.to_string().contains("signature verification failed"));
1867    }
1868
1869    #[tokio::test]
1870    async fn verify_rejects_tampered_nonce() {
1871        let fp = "fp";
1872        let resp = make_signed_response(MINER_SEED, &validator_hotkey(), "original-nonce", fp);
1873        let err = verify_miner_response_signature(&resp, &validator_hotkey(), "tampered-nonce", fp)
1874            .await
1875            .unwrap_err();
1876        assert!(err.to_string().contains("signature verification failed"));
1877    }
1878
1879    #[test]
1880    fn with_config_rejects_frame_payload_below_minimum() {
1881        let cfg = LightningClientConfig {
1882            max_frame_payload_bytes: 512,
1883            ..LightningClientConfig::default()
1884        };
1885        assert!(LightningClient::with_config("hk".into(), cfg).is_err());
1886    }
1887
1888    #[test]
1889    fn with_config_rejects_frame_payload_above_u32_max() {
1890        let too_big: u128 = u32::MAX as u128 + 1;
1891        let val = match usize::try_from(too_big) {
1892            Ok(v) => v,
1893            Err(_) => return,
1894        };
1895        let cfg = LightningClientConfig {
1896            max_frame_payload_bytes: val,
1897            max_stream_payload_bytes: val,
1898            ..LightningClientConfig::default()
1899        };
1900        assert!(LightningClient::with_config("hk".into(), cfg).is_err());
1901    }
1902
1903    #[test]
1904    fn with_config_rejects_stream_below_frame() {
1905        let base = LightningClientConfig::default();
1906        let cfg = LightningClientConfig {
1907            max_stream_payload_bytes: base.max_frame_payload_bytes - 1,
1908            ..base
1909        };
1910        assert!(LightningClient::with_config("hk".into(), cfg).is_err());
1911    }
1912
1913    #[test]
1914    fn with_config_rejects_zero_stream_chunk_timeout() {
1915        let cfg = LightningClientConfig {
1916            stream_chunk_timeout: Some(Duration::ZERO),
1917            ..LightningClientConfig::default()
1918        };
1919        assert!(LightningClient::with_config("hk".into(), cfg).is_err());
1920    }
1921
1922    #[test]
1923    fn with_config_default_succeeds() {
1924        assert!(
1925            LightningClient::with_config("hk".into(), LightningClientConfig::default()).is_ok()
1926        );
1927    }
1928}
1929
1930// Deliberately disables TLS PKI certificate validation. TLS still provides transport
1931// encryption but not identity authentication. Authenticity is instead enforced at the
1932// application layer: the handshake exchanges certificate fingerprints and verifies
1933// sr25519 signatures over them (see connect_and_authenticate_per_address / authenticate_handshake).
1934#[derive(Debug)]
1935struct AcceptAnyCertVerifier;
1936
1937impl ServerCertVerifier for AcceptAnyCertVerifier {
1938    fn verify_server_cert(
1939        &self,
1940        _end_entity: &CertificateDer<'_>,
1941        _intermediates: &[CertificateDer<'_>],
1942        _server_name: &ServerName<'_>,
1943        _ocsp_response: &[u8],
1944        _now: UnixTime,
1945    ) -> std::result::Result<ServerCertVerified, rustls::Error> {
1946        Ok(ServerCertVerified::assertion())
1947    }
1948
1949    fn verify_tls12_signature(
1950        &self,
1951        _message: &[u8],
1952        _cert: &CertificateDer<'_>,
1953        _dss: &rustls::DigitallySignedStruct,
1954    ) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
1955        Err(rustls::Error::PeerIncompatible(
1956            rustls::PeerIncompatible::Tls12NotOffered,
1957        ))
1958    }
1959
1960    fn verify_tls13_signature(
1961        &self,
1962        _message: &[u8],
1963        _cert: &CertificateDer<'_>,
1964        _dss: &rustls::DigitallySignedStruct,
1965    ) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
1966        Ok(HandshakeSignatureValid::assertion())
1967    }
1968
1969    fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
1970        rustls::crypto::ring::default_provider()
1971            .signature_verification_algorithms
1972            .supported_schemes()
1973    }
1974}