Skip to main content

forest/cli/subcommands/
info_cmd.rs

1// Copyright 2019-2026 ChainSafe Systems
2// SPDX-License-Identifier: Apache-2.0, MIT
3
4use std::time::{Duration, SystemTime, UNIX_EPOCH};
5
6use crate::blocks::Tipset;
7use crate::cli::humantoken::TokenAmountPretty;
8use crate::rpc::{self, prelude::*};
9use crate::shim::address::Address;
10use crate::shim::clock::{BLOCKS_PER_EPOCH, ChainEpoch, EPOCH_DURATION_SECONDS};
11use crate::shim::econ::TokenAmount;
12use chrono::{DateTime, Utc};
13use clap::Subcommand;
14use humantime::format_duration;
15
16#[derive(Debug, Subcommand)]
17pub enum InfoCommand {
18    Show,
19}
20
21#[derive(Debug)]
22pub struct NodeStatusInfo {
23    /// How far behind the node is with respect to syncing to head in seconds
24    pub lag: i64,
25    /// Chain health is the percentage denoting how close we are to having
26    /// an average of 5 blocks per tipset in the last couple of
27    /// hours. The number of blocks per tipset is non-deterministic
28    /// but averaging at 5 is considered healthy.
29    pub health: f64,
30    /// epoch the node is currently at
31    pub epoch: ChainEpoch,
32    /// Base fee is the set price per unit of gas (measured in attoFIL/gas unit) to be burned (sent to an unrecoverable address) for every message execution
33    pub base_fee: TokenAmount,
34    pub sync_status: SyncStatus,
35    /// Start time of the node
36    pub start_time: DateTime<Utc>,
37    pub network: String,
38    pub default_wallet_address: Option<Address>,
39    pub default_wallet_address_balance: Option<TokenAmount>,
40}
41
42#[derive(Debug, strum::Display, PartialEq)]
43pub enum SyncStatus {
44    Ok,
45    Slow,
46    Behind,
47    Fast,
48}
49
50impl NodeStatusInfo {
51    pub fn new(
52        cur_duration: Duration,
53        blocks_per_tipset_last_finality: f64,
54        head: &Tipset,
55        start_time: DateTime<Utc>,
56        network: String,
57        default_wallet_address: Option<Address>,
58        default_wallet_address_balance: Option<TokenAmount>,
59    ) -> NodeStatusInfo {
60        let ts = head.min_timestamp() as i64;
61        let cur_duration_secs = cur_duration.as_secs() as i64;
62        let lag = cur_duration_secs - ts;
63
64        let sync_status = if lag < 0 {
65            SyncStatus::Fast
66        } else if lag < EPOCH_DURATION_SECONDS * 3 / 2 {
67            // within 1.5 epochs
68            SyncStatus::Ok
69        } else if lag < EPOCH_DURATION_SECONDS * 5 {
70            // within 5 epochs
71            SyncStatus::Slow
72        } else {
73            SyncStatus::Behind
74        };
75
76        let base_fee = head.min_ticket_block().parent_base_fee.clone();
77
78        // blocks_per_tipset_last_finality = no of blocks till head / chain finality
79        let health = 100. * blocks_per_tipset_last_finality / BLOCKS_PER_EPOCH as f64;
80
81        Self {
82            lag,
83            health,
84            epoch: head.epoch(),
85            base_fee,
86            sync_status,
87            start_time,
88            network,
89            default_wallet_address,
90            default_wallet_address_balance,
91        }
92    }
93
94    fn format(&self, now: DateTime<Utc>) -> String {
95        let network = format!("Network: {}", self.network);
96
97        let uptime = {
98            let uptime = (now - self.start_time)
99                .to_std()
100                .expect("failed converting to std duration");
101            let uptime = Duration::from_secs(uptime.as_secs());
102            let fmt_uptime = format_duration(uptime);
103            format!(
104                "Uptime: {fmt_uptime} (Started at: {})",
105                self.start_time.with_timezone(&chrono::offset::Local)
106            )
107        };
108
109        let chain = {
110            let base_fee_fmt = self.base_fee.pretty();
111            let lag_time = humantime::format_duration(Duration::from_secs(self.lag.unsigned_abs()));
112            let behind = if self.lag < 0 {
113                format!("{lag_time} ahead")
114            } else {
115                format!("{lag_time} behind")
116            };
117
118            format!(
119                "Chain: [sync: {}! ({})] [basefee: {base_fee_fmt}] [epoch: {}]",
120                self.sync_status, behind, self.epoch
121            )
122        };
123
124        let chain_health = format!("Chain health: {:.2}%\n\n", self.health);
125
126        let wallet_info = {
127            let wallet_address = self
128                .default_wallet_address
129                .as_ref()
130                .map(|it| it.to_string())
131                .unwrap_or("address not set".to_string());
132
133            let wallet_balance = self
134                .default_wallet_address_balance
135                .as_ref()
136                .map(|balance| format!("{:.4}", balance.pretty()))
137                .unwrap_or("could not find balance".to_string());
138
139            format!("Default wallet address: {wallet_address} [{wallet_balance}]")
140        };
141
142        [network, uptime, chain, chain_health, wallet_info].join("\n")
143    }
144}
145
146impl InfoCommand {
147    pub async fn run(self, client: rpc::Client) -> anyhow::Result<()> {
148        let (node_status, head, network, start_time, default_wallet_address) = tokio::try_join!(
149            NodeStatus::call(&client, ()),
150            ChainHead::call(&client, ()),
151            StateNetworkName::call(&client, ()),
152            StartTime::call(&client, ()),
153            WalletDefaultAddress::call(&client, ()),
154        )?;
155
156        let cur_duration: Duration = SystemTime::now().duration_since(UNIX_EPOCH)?;
157        let blocks_per_tipset_last_finality =
158            node_status.chain_status.blocks_per_tipset_last_finality;
159
160        let default_wallet_address_balance = if let Some(def_addr) = default_wallet_address {
161            let balance = WalletBalance::call(&client, (def_addr,)).await?;
162            Some(balance)
163        } else {
164            None
165        };
166
167        let node_status_info = NodeStatusInfo::new(
168            cur_duration,
169            blocks_per_tipset_last_finality,
170            &head,
171            start_time,
172            network,
173            default_wallet_address,
174            default_wallet_address_balance,
175        );
176
177        println!("{}", node_status_info.format(Utc::now()));
178
179        Ok(())
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use crate::blocks::RawBlockHeader;
186    use crate::blocks::{CachingBlockHeader, Tipset};
187    use crate::shim::clock::EPOCH_DURATION_SECONDS;
188    use crate::shim::{address::Address, econ::TokenAmount};
189    use chrono::DateTime;
190    use quickcheck_macros::quickcheck;
191    use std::{str::FromStr, time::Duration};
192
193    use super::{NodeStatusInfo, SyncStatus};
194
195    fn mock_tipset_at(seconds_since_unix_epoch: u64) -> Tipset {
196        CachingBlockHeader::new(RawBlockHeader {
197            miner_address: Address::from_str("f2kmbjvz7vagl2z6pfrbjoggrkjofxspp7cqtw2zy").unwrap(),
198            timestamp: seconds_since_unix_epoch,
199            ..Default::default()
200        })
201        .into()
202    }
203
204    fn mock_node_status() -> NodeStatusInfo {
205        NodeStatusInfo {
206            lag: 0,
207            health: 90.,
208            epoch: i64::MAX,
209            base_fee: TokenAmount::from_whole(1),
210            sync_status: SyncStatus::Ok,
211            start_time: DateTime::<chrono::Utc>::MIN_UTC,
212            network: "calibnet".to_string(),
213            default_wallet_address: None,
214            default_wallet_address_balance: None,
215        }
216    }
217
218    fn node_status(duration: Duration, tipset: &Tipset) -> NodeStatusInfo {
219        NodeStatusInfo::new(
220            duration,
221            20.,
222            tipset,
223            DateTime::<chrono::Utc>::MIN_UTC,
224            "calibnet".to_string(),
225            None,
226            None,
227        )
228    }
229
230    #[quickcheck]
231    fn test_sync_status_ok(duration: Duration) {
232        let tipset = mock_tipset_at(duration.as_secs() + (EPOCH_DURATION_SECONDS as u64 * 3 / 2));
233
234        let status = node_status(duration, &tipset);
235
236        assert_ne!(status.sync_status, SyncStatus::Slow);
237        assert_ne!(status.sync_status, SyncStatus::Behind);
238    }
239
240    #[quickcheck]
241    fn test_sync_status_behind(duration: Duration) {
242        let duration = duration + Duration::from_secs(300);
243        let tipset = mock_tipset_at(duration.as_secs().saturating_sub(200));
244        let status = node_status(duration, &tipset);
245
246        assert!(status.health.is_finite());
247        assert_ne!(status.sync_status, SyncStatus::Ok);
248        assert_ne!(status.sync_status, SyncStatus::Slow);
249    }
250
251    #[quickcheck]
252    fn test_sync_status_slow(duration: Duration) {
253        let duration = duration + Duration::from_secs(300);
254        let tipset = mock_tipset_at(
255            duration
256                .as_secs()
257                .saturating_sub(EPOCH_DURATION_SECONDS as u64 * 4),
258        );
259        let status = node_status(duration, &tipset);
260        assert!(status.health.is_finite());
261        assert_ne!(status.sync_status, SyncStatus::Behind);
262        assert_ne!(status.sync_status, SyncStatus::Ok);
263    }
264
265    #[test]
266    fn block_sync_timestamp() {
267        let duration = Duration::from_secs(60);
268        let tipset = mock_tipset_at(duration.as_secs() - 10);
269        let status = node_status(duration, &tipset);
270
271        assert!(
272            status
273                .format(DateTime::<chrono::Utc>::MIN_UTC)
274                .contains("10s behind")
275        );
276    }
277
278    #[test]
279    fn test_lag_uptime_ahead() {
280        let mut status = mock_node_status();
281        status.lag = -360;
282        assert!(
283            status
284                .format(DateTime::<chrono::Utc>::MIN_UTC)
285                .contains("6m ahead")
286        );
287    }
288
289    #[test]
290    fn chain_status_test() {
291        let duration = Duration::from_secs(100_000);
292        let tipset = mock_tipset_at(duration.as_secs() - 59);
293        let status = node_status(duration, &tipset);
294        let expected_status_fmt =
295            "[sync: Slow! (59s behind)] [basefee: 0 FIL] [epoch: 0]".to_string();
296        assert!(
297            status
298                .format(DateTime::<chrono::Utc>::MIN_UTC)
299                .contains(&expected_status_fmt)
300        );
301
302        let tipset = mock_tipset_at(duration.as_secs() - 30000);
303        let status = node_status(duration, &tipset);
304
305        let expected_status_fmt =
306            "[sync: Behind! (8h 20m behind)] [basefee: 0 FIL] [epoch: 0]".to_string();
307        assert!(
308            status
309                .format(DateTime::<chrono::Utc>::MIN_UTC)
310                .contains(&expected_status_fmt)
311        );
312    }
313}