Skip to main content

ethrex_p2p/
network.rs

1#[cfg(feature = "l2")]
2use crate::rlpx::l2::l2_connection::P2PBasedContext;
3#[cfg(not(feature = "l2"))]
4#[derive(Clone, Debug)]
5pub struct P2PBasedContext;
6use crate::{
7    discovery::{DiscoveryConfig, DiscoveryServer, DiscoveryServerError},
8    metrics::{CurrentStepValue, METRICS},
9    peer_table::{PeerData, PeerTable, PeerTableServerProtocol as _},
10    rlpx::{
11        connection::server::{PeerConnBroadcastSender, PeerConnection},
12        message::Message,
13        p2p::SUPPORTED_SNAP_CAPABILITIES,
14    },
15    tx_broadcaster::{TxBroadcaster, TxBroadcasterError},
16    types::{NetworkConfig, Node},
17};
18use ethrex_blockchain::Blockchain;
19use ethrex_common::H256;
20use ethrex_storage::Store;
21use secp256k1::SecretKey;
22use spawned_concurrency::tasks::ActorRef;
23use std::{
24    io,
25    net::SocketAddr,
26    sync::{Arc, atomic::Ordering},
27    time::Duration,
28};
29use tokio::net::{TcpListener, TcpSocket, UdpSocket};
30use tokio_util::task::TaskTracker;
31use tracing::{error, info};
32
33pub const MAX_MESSAGES_TO_BROADCAST: usize = 100000;
34
35#[derive(Clone, Debug)]
36pub struct P2PContext {
37    pub tracker: TaskTracker,
38    pub signer: SecretKey,
39    pub table: PeerTable,
40    pub storage: Store,
41    pub blockchain: Arc<Blockchain>,
42    pub(crate) broadcast: PeerConnBroadcastSender,
43    pub local_node: Node,
44    /// Network addressing configuration: bind vs. external addresses.
45    pub network_config: NetworkConfig,
46    pub client_version: String,
47    #[cfg(feature = "l2")]
48    pub based_context: Option<P2PBasedContext>,
49    pub tx_broadcaster: ActorRef<TxBroadcaster>,
50    pub initial_lookup_interval: f64,
51    /// Caps concurrent INBOUND connections (including pre-handshake) so a flood of inbound
52    /// dials can't accumulate connection actors/sockets without bound. Each inbound connection
53    /// actor holds a permit for its lifetime; the permit is released when the actor is dropped.
54    pub(crate) inbound_admission: Arc<tokio::sync::Semaphore>,
55}
56
57/// Max concurrent inbound connections (peer_table TARGET_PEERS = 100, plus headroom for
58/// pre-handshake churn and short-lived overlap).
59const MAX_INBOUND_CONNECTIONS: usize = 150;
60
61impl P2PContext {
62    #[allow(clippy::too_many_arguments)]
63    pub fn new(
64        local_node: Node,
65        network_config: NetworkConfig,
66        tracker: TaskTracker,
67        signer: SecretKey,
68        peer_table: PeerTable,
69        storage: Store,
70        blockchain: Arc<Blockchain>,
71        client_version: String,
72        based_context: Option<P2PBasedContext>,
73        tx_broadcasting_time_interval: u64,
74        lookup_interval: f64,
75    ) -> Result<Self, NetworkError> {
76        let (channel_broadcast_send_end, _) = tokio::sync::broadcast::channel::<(
77            tokio::task::Id,
78            Arc<Message>,
79        )>(MAX_MESSAGES_TO_BROADCAST);
80
81        let tx_broadcaster = TxBroadcaster::spawn(
82            peer_table.clone(),
83            blockchain.clone(),
84            tx_broadcasting_time_interval,
85        )
86        .inspect_err(|e| {
87            error!("Failed to start Tx Broadcaster: {e}");
88        })?;
89
90        #[cfg(not(feature = "l2"))]
91        let _ = &based_context;
92
93        Ok(P2PContext {
94            local_node,
95            network_config,
96            tracker,
97            signer,
98            table: peer_table,
99            storage,
100            blockchain,
101            broadcast: channel_broadcast_send_end,
102            client_version,
103            #[cfg(feature = "l2")]
104            based_context,
105            tx_broadcaster,
106            initial_lookup_interval: lookup_interval,
107            inbound_admission: Arc::new(tokio::sync::Semaphore::new(MAX_INBOUND_CONNECTIONS)),
108        })
109    }
110}
111
112#[derive(Debug, thiserror::Error)]
113pub enum NetworkError {
114    #[error("Failed to start discovery server: {0}")]
115    DiscoveryError(#[from] DiscoveryServerError),
116    #[error("Failed to start Tx Broadcaster: {0}")]
117    TxBroadcasterError(#[from] TxBroadcasterError),
118    #[error("Failed to bind UDP socket: {0}")]
119    UdpSocketError(std::io::Error),
120}
121
122pub async fn start_network(
123    context: P2PContext,
124    bootnodes: Vec<Node>,
125    config: DiscoveryConfig,
126) -> Result<(), NetworkError> {
127    let udp_socket = Arc::new(
128        UdpSocket::bind(context.network_config.bind_udp_addr())
129            .await
130            .map_err(NetworkError::UdpSocketError)?,
131    );
132
133    DiscoveryServer::spawn(
134        context.storage.clone(),
135        context.local_node.clone(),
136        context.signer,
137        udp_socket,
138        context.table.clone(),
139        bootnodes,
140        DiscoveryConfig {
141            initial_lookup_interval: context.initial_lookup_interval,
142            ..config
143        },
144    )
145    .await
146    .inspect_err(|e| {
147        error!("Failed to start discovery server: {e}");
148    })?;
149
150    context.tracker.spawn(serve_p2p_requests(context.clone()));
151
152    Ok(())
153}
154
155pub(crate) async fn serve_p2p_requests(context: P2PContext) {
156    let tcp_addr = context.network_config.bind_tcp_addr();
157    let external_tcp_addr = context.local_node.tcp_addr();
158    let listener = match listener(tcp_addr) {
159        Ok(result) => result,
160        Err(e) => {
161            error!("Error opening tcp socket at {tcp_addr}: {e}. Stopping p2p server");
162            return;
163        }
164    };
165    loop {
166        let (stream, peer_addr) = match listener.accept().await {
167            Ok(result) => result,
168            Err(e) => {
169                error!("Error receiving data from tcp socket {tcp_addr}: {e}. Stopping p2p server");
170                return;
171            }
172        };
173
174        if external_tcp_addr == peer_addr {
175            // Ignore connections from self
176            continue;
177        }
178
179        // Bound concurrent inbound connections: if we're at capacity, drop this one instead
180        // of letting connection actors/sockets accumulate. The permit is moved into the
181        // connection actor and released when the actor is dropped.
182        let permit = match context.inbound_admission.clone().try_acquire_owned() {
183            Ok(permit) => permit,
184            Err(_) => {
185                tracing::debug!(peer = %peer_addr, "Inbound connection cap reached, dropping connection");
186                continue;
187            }
188        };
189
190        let _ = PeerConnection::spawn_as_receiver(context.clone(), peer_addr, stream, permit);
191    }
192}
193
194fn listener(tcp_addr: SocketAddr) -> Result<TcpListener, io::Error> {
195    let tcp_socket = match tcp_addr {
196        SocketAddr::V4(_) => TcpSocket::new_v4(),
197        SocketAddr::V6(_) => TcpSocket::new_v6(),
198    }?;
199    tcp_socket.set_reuseport(true).ok();
200    tcp_socket.set_reuseaddr(true).ok();
201    tcp_socket.bind(tcp_addr)?;
202
203    tcp_socket.listen(50)
204}
205
206pub async fn periodically_show_peer_stats(blockchain: Arc<Blockchain>, peer_table: PeerTable) {
207    periodically_show_peer_stats_during_syncing(blockchain, &peer_table).await;
208    periodically_show_peer_stats_after_sync(&peer_table).await;
209}
210
211/// Tracks metric values at phase start and from the previous interval for rate calculations
212#[derive(Default, Clone, Copy)]
213struct PhaseCounters {
214    headers: u64,
215    accounts: u64,
216    accounts_inserted: u64,
217    storage: u64,
218    storage_inserted: u64,
219    healed_accounts: u64,
220    healed_storage: u64,
221    bytecodes: u64,
222}
223
224impl PhaseCounters {
225    fn capture_current() -> Self {
226        Self {
227            headers: METRICS.downloaded_headers.get(),
228            accounts: METRICS.downloaded_account_tries.load(Ordering::Relaxed),
229            accounts_inserted: METRICS.account_tries_inserted.load(Ordering::Relaxed),
230            storage: METRICS.storage_leaves_downloaded.get(),
231            storage_inserted: METRICS.storage_leaves_inserted.get(),
232            healed_accounts: METRICS
233                .global_state_trie_leafs_healed
234                .load(Ordering::Relaxed),
235            healed_storage: METRICS
236                .global_storage_tries_leafs_healed
237                .load(Ordering::Relaxed),
238            bytecodes: METRICS.downloaded_bytecodes.load(Ordering::Relaxed),
239        }
240    }
241}
242
243pub async fn periodically_show_peer_stats_during_syncing(
244    blockchain: Arc<Blockchain>,
245    peer_table: &PeerTable,
246) {
247    let start = std::time::Instant::now();
248    let mut previous_step = CurrentStepValue::None;
249    let mut phase_start_time = std::time::Instant::now();
250    let mut sync_started_logged = false;
251
252    // Track metrics at phase start for phase summaries
253    let mut phase_start = PhaseCounters::default();
254    // Track metrics from previous interval for rate calculations
255    let mut prev_interval = PhaseCounters::default();
256
257    loop {
258        if blockchain.is_synced() {
259            if !sync_started_logged {
260                info!("Node already has state; following chain via full sync");
261                return;
262            }
263            // Log sync complete summary
264            let total_elapsed = format_duration(start.elapsed());
265            let headers_downloaded = METRICS.downloaded_headers.get();
266            let accounts_downloaded = METRICS.downloaded_account_tries.load(Ordering::Relaxed);
267            let storage_downloaded = METRICS.storage_leaves_downloaded.get();
268            let bytecodes_downloaded = METRICS.downloaded_bytecodes.load(Ordering::Relaxed);
269            let healed_accounts = METRICS
270                .global_state_trie_leafs_healed
271                .load(Ordering::Relaxed);
272            let healed_storage = METRICS
273                .global_storage_tries_leafs_healed
274                .load(Ordering::Relaxed);
275
276            info!("");
277            info!(
278                "╭──────────────────────────────────────────────────────────────────────────────╮"
279            );
280            info!(
281                "│ SNAP SYNC COMPLETE                                                           │"
282            );
283            info!(
284                "├──────────────────────────────────────────────────────────────────────────────┤"
285            );
286            info!("│ {:<76}│", format!("Total time: {}", total_elapsed));
287            info!(
288                "├──────────────────────────────────────────────────────────────────────────────┤"
289            );
290            info!(
291                "│ Data summary:                                                                │"
292            );
293            let headers_accounts = format!(
294                "  Headers: {:<14} │  Accounts: {}",
295                format_thousands(headers_downloaded),
296                format_thousands(accounts_downloaded)
297            );
298            info!("│ {:<76}│", headers_accounts);
299            let storage_bytecodes = format!(
300                "  Storage: {:<14} │  Bytecodes: {}",
301                format_thousands(storage_downloaded),
302                format_thousands(bytecodes_downloaded)
303            );
304            info!("│ {:<76}│", storage_bytecodes);
305            let healed = format!(
306                "  Healed: {} state paths + {} storage accounts",
307                format_thousands(healed_accounts),
308                format_thousands(healed_storage)
309            );
310            info!("│ {:<76}│", healed);
311            info!(
312                "╰──────────────────────────────────────────────────────────────────────────────╯"
313            );
314            return;
315        }
316
317        let metrics_enabled = *METRICS.enabled.lock().await;
318        if !metrics_enabled {
319            tokio::time::sleep(Duration::from_secs(1)).await;
320            continue;
321        }
322
323        let current_step = METRICS.current_step.get();
324        let peer_number = peer_table.peer_count().await.unwrap_or(0);
325
326        // Log sync started banner when we have valid sync head data
327        if !sync_started_logged && current_step != CurrentStepValue::None {
328            let sync_head_block = METRICS.sync_head_block.load(Ordering::Relaxed);
329            let sync_head_hash = *METRICS.sync_head_hash.lock().await;
330
331            // Only show banner when sync_head data is populated (not genesis/default)
332            if sync_head_block > 0 && sync_head_hash != H256::zero() {
333                let head_short = format!("{:x}", sync_head_hash);
334                let head_short = &head_short[..8.min(head_short.len())];
335
336                info!("");
337                info!("╭─────────────────────────────────────────────────────────────╮");
338                info!("│ {:<59} │", "SNAP SYNC STARTED");
339                let target_content = format!(
340                    "Target: {}... (block #{})",
341                    head_short,
342                    format_thousands(sync_head_block)
343                );
344                info!("│ {:<59} │", target_content);
345                info!("│ {:<59} │", format!("Peers: {}", peer_number));
346                info!("╰─────────────────────────────────────────────────────────────╯");
347                sync_started_logged = true;
348            }
349        }
350
351        // Only show phase progress after the SNAP SYNC STARTED banner
352        if !sync_started_logged {
353            tokio::time::sleep(Duration::from_secs(1)).await;
354            continue;
355        }
356
357        // Detect phase transition
358        if current_step != previous_step && current_step != CurrentStepValue::None {
359            // Log completion of previous phase (if any)
360            if previous_step != CurrentStepValue::None {
361                // Force a final progress print so the bar doesn't look incomplete
362                let phase_elapsed = phase_start_time.elapsed();
363                let total_elapsed = format_duration(start.elapsed());
364                log_phase_progress(
365                    previous_step,
366                    phase_elapsed,
367                    &total_elapsed,
368                    peer_number,
369                    &prev_interval,
370                )
371                .await;
372
373                let phase_elapsed_str = format_duration(phase_start_time.elapsed());
374                log_phase_completion(
375                    previous_step,
376                    phase_elapsed_str,
377                    &phase_metrics(previous_step, &phase_start).await,
378                );
379
380                // Emit final metrics for completed phase
381                #[cfg(feature = "metrics")]
382                push_sync_prometheus_metrics(previous_step);
383            }
384
385            // Start new phase
386            phase_start_time = std::time::Instant::now();
387
388            // Record phase start timestamp for Grafana elapsed panels
389            #[cfg(feature = "metrics")]
390            {
391                let (_, phase_name) = phase_info(current_step);
392                let now = std::time::SystemTime::now()
393                    .duration_since(std::time::UNIX_EPOCH)
394                    .unwrap_or_default()
395                    .as_secs();
396                ethrex_metrics::sync::METRICS_SYNC
397                    .phase_start_timestamp
398                    .with_label_values(&[phase_name])
399                    .set(now as i64);
400            }
401
402            // Capture metrics at phase start
403            phase_start = PhaseCounters::capture_current();
404            prev_interval = phase_start;
405
406            log_phase_separator(current_step);
407            previous_step = current_step;
408        }
409
410        // Log phase-specific progress update
411        let phase_elapsed = phase_start_time.elapsed();
412        let total_elapsed = format_duration(start.elapsed());
413
414        log_phase_progress(
415            current_step,
416            phase_elapsed,
417            &total_elapsed,
418            peer_number,
419            &prev_interval,
420        )
421        .await;
422
423        // Push progress + peer health to Prometheus
424        #[cfg(feature = "metrics")]
425        {
426            push_sync_prometheus_metrics(current_step);
427            let diag = peer_table.get_peer_diagnostics().await.unwrap_or_default();
428            let snap_peers = diag
429                .iter()
430                .filter(|p| p.capabilities.iter().any(|c| c.starts_with("snap/")))
431                .count();
432            let eligible = diag.iter().filter(|p| p.eligible).count();
433            let inflight: i64 = diag.iter().map(|p| p.inflight_requests).sum();
434            ethrex_metrics::sync::METRICS_SYNC.set_snap_peers(snap_peers as i64);
435            ethrex_metrics::sync::METRICS_SYNC.set_eligible_peers(eligible as i64);
436            ethrex_metrics::sync::METRICS_SYNC.set_inflight_requests(inflight);
437        }
438
439        // Update previous interval counters for next rate calculation
440        prev_interval = PhaseCounters::capture_current();
441
442        tokio::time::sleep(Duration::from_secs(10)).await;
443    }
444}
445
446/// Returns (phase_number, phase_name) for the current step
447fn phase_info(step: CurrentStepValue) -> (u8, &'static str) {
448    match step {
449        CurrentStepValue::DownloadingHeaders => (1, "BLOCK HEADERS"),
450        CurrentStepValue::RequestingAccountRanges => (2, "ACCOUNT RANGES"),
451        CurrentStepValue::InsertingAccountRanges | CurrentStepValue::InsertingAccountRangesNoDb => {
452            (3, "ACCOUNT INSERTION")
453        }
454        CurrentStepValue::RequestingStorageRanges => (4, "STORAGE RANGES"),
455        CurrentStepValue::InsertingStorageRanges => (5, "STORAGE INSERTION"),
456        CurrentStepValue::HealingState => (6, "STATE HEALING"),
457        CurrentStepValue::HealingStorage => (7, "STORAGE HEALING"),
458        CurrentStepValue::RequestingBytecodes => (8, "BYTECODES"),
459        CurrentStepValue::None => (0, "UNKNOWN"),
460    }
461}
462
463fn log_phase_separator(step: CurrentStepValue) {
464    let (phase_num, phase_name) = phase_info(step);
465    let header = format!("── PHASE {}/8: {} ", phase_num, phase_name);
466    let header_width = header.chars().count();
467    let padding_width = 80usize.saturating_sub(header_width);
468    let padding = "─".repeat(padding_width);
469    info!("");
470    info!("{}{}", header, padding);
471}
472
473fn log_phase_completion(step: CurrentStepValue, elapsed: String, summary: &str) {
474    let (_, phase_name) = phase_info(step);
475    info!("✓ {} complete: {} in {}", phase_name, summary, elapsed);
476}
477
478async fn phase_metrics(step: CurrentStepValue, phase_start: &PhaseCounters) -> String {
479    match step {
480        CurrentStepValue::DownloadingHeaders => {
481            let downloaded = METRICS
482                .downloaded_headers
483                .get()
484                .saturating_sub(phase_start.headers);
485            format!("{} headers", format_thousands(downloaded))
486        }
487        CurrentStepValue::RequestingAccountRanges => {
488            let downloaded = METRICS
489                .downloaded_account_tries
490                .load(Ordering::Relaxed)
491                .saturating_sub(phase_start.accounts);
492            format!("{} accounts", format_thousands(downloaded))
493        }
494        CurrentStepValue::InsertingAccountRanges | CurrentStepValue::InsertingAccountRangesNoDb => {
495            let inserted = METRICS
496                .account_tries_inserted
497                .load(Ordering::Relaxed)
498                .saturating_sub(phase_start.accounts_inserted);
499            format!("{} accounts inserted", format_thousands(inserted))
500        }
501        CurrentStepValue::RequestingStorageRanges => {
502            let downloaded = METRICS
503                .storage_leaves_downloaded
504                .get()
505                .saturating_sub(phase_start.storage);
506            format!("{} storage slots", format_thousands(downloaded))
507        }
508        CurrentStepValue::InsertingStorageRanges => {
509            let inserted = METRICS
510                .storage_leaves_inserted
511                .get()
512                .saturating_sub(phase_start.storage_inserted);
513            format!("{} storage slots inserted", format_thousands(inserted))
514        }
515        CurrentStepValue::HealingState => {
516            let healed = METRICS
517                .global_state_trie_leafs_healed
518                .load(Ordering::Relaxed)
519                .saturating_sub(phase_start.healed_accounts);
520            format!("{} state paths healed", format_thousands(healed))
521        }
522        CurrentStepValue::HealingStorage => {
523            let healed = METRICS
524                .global_storage_tries_leafs_healed
525                .load(Ordering::Relaxed)
526                .saturating_sub(phase_start.healed_storage);
527            format!("{} storage accounts healed", format_thousands(healed))
528        }
529        CurrentStepValue::RequestingBytecodes => {
530            let downloaded = METRICS
531                .downloaded_bytecodes
532                .load(Ordering::Relaxed)
533                .saturating_sub(phase_start.bytecodes);
534            format!("{} bytecodes", format_thousands(downloaded))
535        }
536        CurrentStepValue::None => String::new(),
537    }
538}
539
540/// Interval in seconds between progress updates
541const PROGRESS_INTERVAL_SECS: u64 = 30;
542
543async fn log_phase_progress(
544    step: CurrentStepValue,
545    phase_elapsed: Duration,
546    total_elapsed: &str,
547    peer_count: usize,
548    prev_interval: &PhaseCounters,
549) {
550    let phase_elapsed_str = format_duration(phase_elapsed);
551
552    // Use consistent column widths: left column 40 chars, then │, then right column
553    let col1_width = 40;
554
555    match step {
556        CurrentStepValue::DownloadingHeaders => {
557            let headers_to_download = METRICS.sync_head_block.load(Ordering::Relaxed);
558            let headers_downloaded =
559                u64::min(METRICS.downloaded_headers.get(), headers_to_download);
560            let interval_downloaded = headers_downloaded.saturating_sub(prev_interval.headers);
561            let percentage = if headers_to_download == 0 {
562                0.0
563            } else {
564                (headers_downloaded as f64 / headers_to_download as f64) * 100.0
565            };
566            let rate = interval_downloaded / PROGRESS_INTERVAL_SECS;
567
568            let progress = progress_bar(percentage, 40);
569            info!("  {} {:>5.1}%", progress, percentage);
570            info!("");
571            let col1 = format!(
572                "Headers: {} / {}",
573                format_thousands(headers_downloaded),
574                format_thousands(headers_to_download)
575            );
576            info!("  {:<col1_width$} │  Elapsed: {}", col1, phase_elapsed_str);
577            let col1 = format!("Rate: {} headers/s", format_thousands(rate));
578            info!("  {:<col1_width$} │  Peers: {}", col1, peer_count);
579            info!("  Total time: {}", total_elapsed);
580        }
581        CurrentStepValue::RequestingAccountRanges => {
582            let accounts_downloaded = METRICS.downloaded_account_tries.load(Ordering::Relaxed);
583            let interval_downloaded = accounts_downloaded.saturating_sub(prev_interval.accounts);
584            let rate = interval_downloaded / PROGRESS_INTERVAL_SECS;
585
586            info!("");
587            let col1 = format!(
588                "Accounts fetched: {}",
589                format_thousands(accounts_downloaded)
590            );
591            info!("  {:<col1_width$} │  Elapsed: {}", col1, phase_elapsed_str);
592            let col1 = format!("Rate: {} accounts/s", format_thousands(rate));
593            info!("  {:<col1_width$} │  Peers: {}", col1, peer_count);
594            info!("  Total time: {}", total_elapsed);
595        }
596        CurrentStepValue::InsertingAccountRanges | CurrentStepValue::InsertingAccountRangesNoDb => {
597            let accounts_to_insert = METRICS.downloaded_account_tries.load(Ordering::Relaxed);
598            let accounts_inserted = METRICS.account_tries_inserted.load(Ordering::Relaxed);
599            let interval_inserted =
600                accounts_inserted.saturating_sub(prev_interval.accounts_inserted);
601            let percentage = if accounts_to_insert == 0 {
602                0.0
603            } else {
604                (accounts_inserted as f64 / accounts_to_insert as f64) * 100.0
605            };
606            let rate = interval_inserted / PROGRESS_INTERVAL_SECS;
607
608            let progress = progress_bar(percentage, 40);
609            info!("  {} {:>5.1}%", progress, percentage);
610            info!("");
611            let col1 = format!(
612                "Accounts: {} / {}",
613                format_thousands(accounts_inserted),
614                format_thousands(accounts_to_insert)
615            );
616            info!("  {:<col1_width$} │  Elapsed: {}", col1, phase_elapsed_str);
617            let col1 = format!("Rate: {} accounts/s", format_thousands(rate));
618            info!("  {:<col1_width$} │  Peers: {}", col1, peer_count);
619            info!("  Total time: {}", total_elapsed);
620        }
621        CurrentStepValue::RequestingStorageRanges => {
622            let storage_downloaded = METRICS.storage_leaves_downloaded.get();
623            let interval_downloaded = storage_downloaded.saturating_sub(prev_interval.storage);
624            let rate = interval_downloaded / PROGRESS_INTERVAL_SECS;
625
626            info!("");
627            let col1 = format!(
628                "Storage slots fetched: {}",
629                format_thousands(storage_downloaded)
630            );
631            info!("  {:<col1_width$} │  Elapsed: {}", col1, phase_elapsed_str);
632            let col1 = format!("Rate: {} slots/s", format_thousands(rate));
633            info!("  {:<col1_width$} │  Peers: {}", col1, peer_count);
634            info!("  Total time: {}", total_elapsed);
635        }
636        CurrentStepValue::InsertingStorageRanges => {
637            let storage_inserted = METRICS.storage_leaves_inserted.get();
638            let interval_inserted = storage_inserted.saturating_sub(prev_interval.storage_inserted);
639            let rate = interval_inserted / PROGRESS_INTERVAL_SECS;
640
641            info!("");
642            let col1 = format!(
643                "Storage slots inserted: {}",
644                format_thousands(storage_inserted)
645            );
646            info!("  {:<col1_width$} │  Elapsed: {}", col1, phase_elapsed_str);
647            let col1 = format!("Rate: {} slots/s", format_thousands(rate));
648            info!("  {:<col1_width$} │  Peers: {}", col1, peer_count);
649            info!("  Total time: {}", total_elapsed);
650        }
651        CurrentStepValue::HealingState => {
652            let healed = METRICS
653                .global_state_trie_leafs_healed
654                .load(Ordering::Relaxed);
655            let interval_healed = healed.saturating_sub(prev_interval.healed_accounts);
656            let rate = interval_healed / PROGRESS_INTERVAL_SECS;
657
658            info!("");
659            let col1 = format!("State paths healed: {}", format_thousands(healed));
660            info!("  {:<col1_width$} │  Elapsed: {}", col1, phase_elapsed_str);
661            let col1 = format!("Rate: {} paths/s", format_thousands(rate));
662            info!("  {:<col1_width$} │  Peers: {}", col1, peer_count);
663            info!("  Total time: {}", total_elapsed);
664        }
665        CurrentStepValue::HealingStorage => {
666            let healed = METRICS
667                .global_storage_tries_leafs_healed
668                .load(Ordering::Relaxed);
669            let interval_healed = healed.saturating_sub(prev_interval.healed_storage);
670            let rate = interval_healed / PROGRESS_INTERVAL_SECS;
671
672            info!("");
673            let col1 = format!("Storage accounts healed: {}", format_thousands(healed));
674            info!("  {:<col1_width$} │  Elapsed: {}", col1, phase_elapsed_str);
675            let col1 = format!("Rate: {} accounts/s", format_thousands(rate));
676            info!("  {:<col1_width$} │  Peers: {}", col1, peer_count);
677            info!("  Total time: {}", total_elapsed);
678        }
679        CurrentStepValue::RequestingBytecodes => {
680            let bytecodes_to_download = METRICS.bytecodes_to_download.load(Ordering::Relaxed);
681            let bytecodes_downloaded = METRICS.downloaded_bytecodes.load(Ordering::Relaxed);
682            let interval_downloaded = bytecodes_downloaded.saturating_sub(prev_interval.bytecodes);
683            let percentage = if bytecodes_to_download == 0 {
684                0.0
685            } else {
686                (bytecodes_downloaded as f64 / bytecodes_to_download as f64) * 100.0
687            };
688            let rate = interval_downloaded / PROGRESS_INTERVAL_SECS;
689
690            let progress = progress_bar(percentage, 40);
691            info!("  {} {:>5.1}%", progress, percentage);
692            info!("");
693            let col1 = format!(
694                "Bytecodes: {} / {}",
695                format_thousands(bytecodes_downloaded),
696                format_thousands(bytecodes_to_download)
697            );
698            info!("  {:<col1_width$} │  Elapsed: {}", col1, phase_elapsed_str);
699            let col1 = format!("Rate: {} codes/s", format_thousands(rate));
700            info!("  {:<col1_width$} │  Peers: {}", col1, peer_count);
701            info!("  Total time: {}", total_elapsed);
702        }
703        CurrentStepValue::None => {}
704    }
705}
706
707/// Push snap sync progress to Prometheus gauges (from METRICS atomics).
708/// Called each polling cycle. Rates are NOT computed here — use rate() in Grafana.
709#[cfg(feature = "metrics")]
710fn push_sync_prometheus_metrics(step: CurrentStepValue) {
711    use ethrex_metrics::sync::METRICS_SYNC;
712    use std::sync::atomic::Ordering::Relaxed;
713
714    let (phase_num, _) = phase_info(step);
715    METRICS_SYNC.stage.set(phase_num as i64);
716    METRICS_SYNC
717        .pivot_block
718        .set(METRICS.sync_head_block.load(Relaxed) as i64);
719
720    // Push raw pivot timestamp — Grafana computes age via time() - timestamp
721    let pivot_ts = METRICS.pivot_timestamp.load(Relaxed);
722    if pivot_ts > 0 {
723        METRICS_SYNC.pivot_timestamp.set(pivot_ts as i64);
724    }
725    // Also update pivot_age_seconds for RPC/peer_top consumers
726    if pivot_ts > 0 {
727        let now = std::time::SystemTime::now()
728            .duration_since(std::time::UNIX_EPOCH)
729            .unwrap_or_default()
730            .as_secs();
731        METRICS_SYNC
732            .pivot_age_seconds
733            .set(now.saturating_sub(pivot_ts) as i64);
734    }
735
736    match step {
737        CurrentStepValue::DownloadingHeaders => {
738            let total = METRICS.sync_head_block.load(Relaxed);
739            let downloaded = u64::min(METRICS.downloaded_headers.get(), total);
740            METRICS_SYNC.headers_downloaded.set(downloaded as i64);
741            METRICS_SYNC.headers_total.set(total as i64);
742        }
743        CurrentStepValue::RequestingAccountRanges => {
744            let downloaded = METRICS.downloaded_account_tries.load(Relaxed);
745            METRICS_SYNC.accounts_downloaded.set(downloaded as i64);
746        }
747        CurrentStepValue::InsertingAccountRanges | CurrentStepValue::InsertingAccountRangesNoDb => {
748            let total = METRICS.downloaded_account_tries.load(Relaxed);
749            let inserted = METRICS.account_tries_inserted.load(Relaxed);
750            METRICS_SYNC.accounts_downloaded.set(total as i64);
751            METRICS_SYNC.accounts_inserted.set(inserted as i64);
752        }
753        CurrentStepValue::RequestingStorageRanges => {
754            let downloaded = METRICS.storage_leaves_downloaded.get();
755            METRICS_SYNC.storage_downloaded.set(downloaded as i64);
756        }
757        CurrentStepValue::InsertingStorageRanges => {
758            let inserted = METRICS.storage_leaves_inserted.get();
759            METRICS_SYNC.storage_inserted.set(inserted as i64);
760        }
761        CurrentStepValue::HealingState => {
762            let healed = METRICS.global_state_trie_leafs_healed.load(Relaxed);
763            METRICS_SYNC.state_leaves_healed.set(healed as i64);
764        }
765        CurrentStepValue::HealingStorage => {
766            let healed = METRICS.global_storage_tries_leafs_healed.load(Relaxed);
767            METRICS_SYNC.storage_leaves_healed.set(healed as i64);
768        }
769        CurrentStepValue::RequestingBytecodes => {
770            let total = METRICS.bytecodes_to_download.load(Relaxed);
771            let downloaded = METRICS.downloaded_bytecodes.load(Relaxed);
772            METRICS_SYNC.bytecodes_downloaded.set(downloaded as i64);
773            METRICS_SYNC.bytecodes_total.set(total as i64);
774        }
775        CurrentStepValue::None => {}
776    }
777}
778
779fn progress_bar(percentage: f64, width: usize) -> String {
780    let clamped_percentage = percentage.clamp(0.0, 100.0);
781    let filled = ((clamped_percentage / 100.0) * width as f64) as usize;
782    let filled = filled.min(width);
783    let empty = width.saturating_sub(filled);
784    format!("{}{}", "▓".repeat(filled), "░".repeat(empty))
785}
786
787fn format_thousands(n: u64) -> String {
788    let s = n.to_string();
789    let mut result = String::new();
790    for (i, c) in s.chars().rev().enumerate() {
791        if i > 0 && i % 3 == 0 {
792            result.push(',');
793        }
794        result.push(c);
795    }
796    result.chars().rev().collect()
797}
798
799/// Shows the amount of connected peers, active peers, and peers suitable for snap sync on a set interval
800pub async fn periodically_show_peer_stats_after_sync(peer_table: &PeerTable) {
801    const INTERVAL_DURATION: tokio::time::Duration = tokio::time::Duration::from_secs(60);
802    let mut interval = tokio::time::interval(INTERVAL_DURATION);
803    loop {
804        // clone peers to keep the lock short
805        let peers: Vec<PeerData> = peer_table.get_peers_data().await.unwrap_or(Vec::new());
806        let active_peers = peers
807            .iter()
808            .filter(|peer| -> bool { peer.connection.as_ref().is_some() })
809            .count();
810        let snap_active_peers = peers
811            .iter()
812            .filter(|peer| -> bool {
813                peer.connection.as_ref().is_some()
814                    && SUPPORTED_SNAP_CAPABILITIES
815                        .iter()
816                        .any(|cap| peer.supported_capabilities.contains(cap))
817            })
818            .count();
819        info!("Peers: {active_peers} (snap-capable: {snap_active_peers})");
820        interval.tick().await;
821    }
822}
823
824fn format_duration(duration: Duration) -> String {
825    let total_seconds = duration.as_secs();
826    let hours = total_seconds / 3600;
827    let minutes = (total_seconds % 3600) / 60;
828    let seconds = total_seconds % 60;
829    format!("{hours:02}:{minutes:02}:{seconds:02}")
830}