forest/cli/subcommands/
info_cmd.rs

1// Copyright 2019-2025 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, sync::Arc, time::Duration};
192
193    use super::{NodeStatusInfo, SyncStatus};
194
195    fn mock_tipset_at(seconds_since_unix_epoch: u64) -> Arc<Tipset> {
196        let mock_header = CachingBlockHeader::new(RawBlockHeader {
197            miner_address: Address::from_str("f2kmbjvz7vagl2z6pfrbjoggrkjofxspp7cqtw2zy").unwrap(),
198            timestamp: seconds_since_unix_epoch,
199            ..Default::default()
200        });
201        let tipset = Tipset::from(&mock_header);
202
203        Arc::new(tipset)
204    }
205
206    fn mock_node_status() -> NodeStatusInfo {
207        NodeStatusInfo {
208            lag: 0,
209            health: 90.,
210            epoch: i64::MAX,
211            base_fee: TokenAmount::from_whole(1),
212            sync_status: SyncStatus::Ok,
213            start_time: DateTime::<chrono::Utc>::MIN_UTC,
214            network: "calibnet".to_string(),
215            default_wallet_address: None,
216            default_wallet_address_balance: None,
217        }
218    }
219
220    fn node_status(duration: Duration, tipset: &Tipset) -> NodeStatusInfo {
221        NodeStatusInfo::new(
222            duration,
223            20.,
224            tipset,
225            DateTime::<chrono::Utc>::MIN_UTC,
226            "calibnet".to_string(),
227            None,
228            None,
229        )
230    }
231
232    #[quickcheck]
233    fn test_sync_status_ok(duration: Duration) {
234        let tipset = mock_tipset_at(duration.as_secs() + (EPOCH_DURATION_SECONDS as u64 * 3 / 2));
235
236        let status = node_status(duration, tipset.as_ref());
237
238        assert_ne!(status.sync_status, SyncStatus::Slow);
239        assert_ne!(status.sync_status, SyncStatus::Behind);
240    }
241
242    #[quickcheck]
243    fn test_sync_status_behind(duration: Duration) {
244        let duration = duration + Duration::from_secs(300);
245        let tipset = mock_tipset_at(duration.as_secs().saturating_sub(200));
246        let status = node_status(duration, tipset.as_ref());
247
248        assert!(status.health.is_finite());
249        assert_ne!(status.sync_status, SyncStatus::Ok);
250        assert_ne!(status.sync_status, SyncStatus::Slow);
251    }
252
253    #[quickcheck]
254    fn test_sync_status_slow(duration: Duration) {
255        let duration = duration + Duration::from_secs(300);
256        let tipset = mock_tipset_at(
257            duration
258                .as_secs()
259                .saturating_sub(EPOCH_DURATION_SECONDS as u64 * 4),
260        );
261        let status = node_status(duration, tipset.as_ref());
262        assert!(status.health.is_finite());
263        assert_ne!(status.sync_status, SyncStatus::Behind);
264        assert_ne!(status.sync_status, SyncStatus::Ok);
265    }
266
267    #[test]
268    fn block_sync_timestamp() {
269        let duration = Duration::from_secs(60);
270        let tipset = mock_tipset_at(duration.as_secs() - 10);
271        let status = node_status(duration, tipset.as_ref());
272
273        assert!(
274            status
275                .format(DateTime::<chrono::Utc>::MIN_UTC)
276                .contains("10s behind")
277        );
278    }
279
280    #[test]
281    fn test_lag_uptime_ahead() {
282        let mut status = mock_node_status();
283        status.lag = -360;
284        assert!(
285            status
286                .format(DateTime::<chrono::Utc>::MIN_UTC)
287                .contains("6m ahead")
288        );
289    }
290
291    #[test]
292    fn chain_status_test() {
293        let duration = Duration::from_secs(100_000);
294        let tipset = mock_tipset_at(duration.as_secs() - 59);
295        let status = node_status(duration, tipset.as_ref());
296        let expected_status_fmt =
297            "[sync: Slow! (59s behind)] [basefee: 0 FIL] [epoch: 0]".to_string();
298        assert!(
299            status
300                .format(DateTime::<chrono::Utc>::MIN_UTC)
301                .contains(&expected_status_fmt)
302        );
303
304        let tipset = mock_tipset_at(duration.as_secs() - 30000);
305        let status = node_status(duration, tipset.as_ref());
306
307        let expected_status_fmt =
308            "[sync: Behind! (8h 20m behind)] [basefee: 0 FIL] [epoch: 0]".to_string();
309        assert!(
310            status
311                .format(DateTime::<chrono::Utc>::MIN_UTC)
312                .contains(&expected_status_fmt)
313        );
314    }
315}