forest/cli/subcommands/
f3_cmd.rs

1// Copyright 2019-2025 ChainSafe Systems
2// SPDX-License-Identifier: Apache-2.0, MIT
3
4#[cfg(test)]
5mod tests;
6
7use std::{
8    borrow::Cow,
9    sync::{Arc, LazyLock},
10    time::{Duration, Instant},
11};
12
13use crate::{
14    blocks::{Tipset, TipsetKey},
15    lotus_json::HasLotusJson as _,
16    rpc::{
17        self,
18        f3::{
19            F3GetF3PowerTableByInstance, F3InstanceProgress, F3Manifest, F3PowerEntry,
20            FinalityCertificate,
21        },
22        prelude::*,
23    },
24    shim::fvm_shared_latest::ActorID,
25};
26use ahash::HashSet;
27use cid::Cid;
28use clap::{Subcommand, ValueEnum};
29use indicatif::{ProgressBar, ProgressStyle};
30use itertools::Itertools as _;
31use serde::{Deserialize, Serialize};
32use serde_with::{DisplayFromStr, serde_as};
33use tera::Tera;
34
35const MANIFEST_TEMPLATE_NAME: &str = "manifest.tpl";
36const CERTIFICATE_TEMPLATE_NAME: &str = "certificate.tpl";
37const PROGRESS_TEMPLATE_NAME: &str = "progress.tpl";
38
39static TEMPLATES: LazyLock<Tera> = LazyLock::new(|| {
40    let mut tera = Tera::default();
41    tera.add_raw_template(MANIFEST_TEMPLATE_NAME, include_str!("f3_cmd/manifest.tpl"))
42        .unwrap();
43    tera.add_raw_template(
44        CERTIFICATE_TEMPLATE_NAME,
45        include_str!("f3_cmd/certificate.tpl"),
46    )
47    .unwrap();
48    tera.add_raw_template(PROGRESS_TEMPLATE_NAME, include_str!("f3_cmd/progress.tpl"))
49        .unwrap();
50
51    #[allow(clippy::disallowed_types)]
52    fn format_duration(
53        value: &serde_json::Value,
54        _args: &std::collections::HashMap<String, serde_json::Value>,
55    ) -> tera::Result<serde_json::Value> {
56        if let Some(duration_nano_secs) = value.as_u64() {
57            let duration = Duration::from_lotus_json(duration_nano_secs);
58            return Ok(serde_json::Value::String(
59                humantime::format_duration(duration).to_string(),
60            ));
61        }
62
63        Ok(value.clone())
64    }
65    tera.register_filter("format_duration", format_duration);
66
67    tera
68});
69
70/// Output format
71#[derive(ValueEnum, Debug, Clone, Default, Serialize, Deserialize)]
72#[serde(rename_all = "kebab-case")]
73pub enum F3OutputFormat {
74    /// Text
75    #[default]
76    Text,
77    /// JSON
78    Json,
79}
80
81/// Manages Filecoin Fast Finality (F3) interactions
82#[derive(Debug, Subcommand)]
83pub enum F3Commands {
84    /// Gets the current manifest used by F3
85    Manifest {
86        /// The output format.
87        #[arg(long, value_enum, default_value_t = F3OutputFormat::Text)]
88        output: F3OutputFormat,
89    },
90    /// Checks the F3 status.
91    Status,
92    /// Manages interactions with F3 finality certificates.
93    #[command(subcommand, visible_alias = "c")]
94    Certs(F3CertsCommands),
95    /// Gets F3 power table at a specific instance ID or latest instance if none is specified.
96    #[command(subcommand, name = "powertable", visible_alias = "pt")]
97    PowerTable(F3PowerTableCommands),
98    /// Checks if F3 is in sync.
99    Ready {
100        /// Wait until F3 is in sync.
101        #[arg(long)]
102        wait: bool,
103        /// The threshold of the epoch gap between chain head and F3 head within which F3 is considered in sync.
104        #[arg(long, default_value_t = 20)]
105        threshold: usize,
106        /// Exit after F3 making no progress for this duration.
107        #[arg(long, default_value = "10m", requires = "wait")]
108        no_progress_timeout: humantime::Duration,
109    },
110}
111
112impl F3Commands {
113    pub async fn run(self, client: rpc::Client) -> anyhow::Result<()> {
114        match self {
115            Self::Manifest { output } => {
116                let manifest = client.call(F3GetManifest::request(())?).await?;
117                match output {
118                    F3OutputFormat::Text => {
119                        println!("{}", render_manifest_template(&manifest)?);
120                    }
121                    F3OutputFormat::Json => {
122                        println!("{}", serde_json::to_string_pretty(&manifest)?);
123                    }
124                }
125                Ok(())
126            }
127            Self::Status => {
128                let is_running = client.call(F3IsRunning::request(())?).await?;
129                println!("Running: {is_running}");
130                let progress = client.call(F3GetProgress::request(())?).await?;
131                println!("{}", render_progress_template(&progress)?);
132                let manifest = client.call(F3GetManifest::request(())?).await?;
133                println!("{}", render_manifest_template(&manifest)?);
134                Ok(())
135            }
136            Self::Certs(cmd) => cmd.run(client).await,
137            Self::PowerTable(cmd) => cmd.run(client).await,
138            Self::Ready {
139                wait,
140                threshold,
141                no_progress_timeout,
142            } => {
143                const EXIT_CODE_F3_NOT_IN_SYNC: i32 = 1;
144                const EXIT_CODE_F3_FAIL_TO_FETCH_HEAD: i32 = 2;
145                const EXIT_CODE_F3_NO_PROGRESS_TIMEOUT: i32 = 3;
146
147                let is_running = client.call(F3IsRunning::request(())?).await?;
148                if !is_running {
149                    anyhow::bail!("F3 is not running");
150                }
151
152                async fn get_heads(
153                    client: &rpc::Client,
154                ) -> anyhow::Result<(Arc<Tipset>, FinalityCertificate)> {
155                    let cert_head = client.call(F3GetLatestCertificate::request(())?).await?;
156                    let chain_head = client.call(ChainHead::request(())?).await?;
157                    Ok((chain_head, cert_head))
158                }
159
160                let pb = ProgressBar::new_spinner().with_style(
161                    ProgressStyle::with_template("{spinner} {msg}")
162                        .expect("indicatif template must be valid"),
163                );
164                pb.enable_steady_tick(std::time::Duration::from_millis(100));
165                let mut num_consecutive_fetch_failtures = 0;
166                let no_progress_timeout_duration: Duration = no_progress_timeout.into();
167                let mut interval = tokio::time::interval(Duration::from_secs(1));
168                let mut last_f3_head_epoch = 0;
169                let mut last_progress = Instant::now();
170                loop {
171                    interval.tick().await;
172                    match get_heads(&client).await {
173                        Ok((chain_head, cert_head)) => {
174                            num_consecutive_fetch_failtures = 0;
175                            let f3_head_epoch = cert_head.chain_head().epoch;
176                            if f3_head_epoch != last_f3_head_epoch {
177                                last_f3_head_epoch = f3_head_epoch;
178                                last_progress = Instant::now();
179                            }
180                            if f3_head_epoch.saturating_add(threshold.try_into()?)
181                                >= chain_head.epoch()
182                            {
183                                let text = format!(
184                                    "[+] F3 is in sync. Chain head epoch: {}, F3 head epoch: {}",
185                                    chain_head.epoch(),
186                                    cert_head.chain_head().epoch
187                                );
188                                pb.set_message(text);
189                                pb.finish();
190                                break;
191                            } else {
192                                let text = format!(
193                                    "[-] F3 is not in sync. Chain head epoch: {}, F3 head epoch: {}",
194                                    chain_head.epoch(),
195                                    cert_head.chain_head().epoch
196                                );
197                                pb.set_message(text);
198                                if !wait {
199                                    pb.finish();
200                                    std::process::exit(EXIT_CODE_F3_NOT_IN_SYNC);
201                                }
202                            }
203                        }
204                        Err(e) => {
205                            if !wait {
206                                anyhow::bail!("Failed to check F3 sync status: {e}");
207                            }
208
209                            num_consecutive_fetch_failtures += 1;
210                            if num_consecutive_fetch_failtures >= 3 {
211                                eprintln!("Warning: Failed to fetch heads: {e}. Exiting...");
212                                std::process::exit(EXIT_CODE_F3_FAIL_TO_FETCH_HEAD);
213                            } else {
214                                eprintln!("Warning: Failed to fetch heads: {e}. Retrying...");
215                            }
216                        }
217                    }
218
219                    if last_progress + no_progress_timeout_duration < Instant::now() {
220                        eprintln!(
221                            "Warning: F3 made no progress in the past {no_progress_timeout}. Exiting..."
222                        );
223                        std::process::exit(EXIT_CODE_F3_NO_PROGRESS_TIMEOUT);
224                    }
225                }
226                Ok(())
227            }
228        }
229    }
230}
231
232/// Manages interactions with F3 finality certificates.
233#[derive(Debug, Subcommand)]
234pub enum F3CertsCommands {
235    /// Gets an F3 finality certificate to a given instance ID, or the latest certificate if no instance is specified.
236    Get {
237        instance: Option<u64>,
238        /// The output format.
239        #[arg(long, value_enum, default_value_t = F3OutputFormat::Text)]
240        output: F3OutputFormat,
241    },
242    /// Lists a range of F3 finality certificates.
243    List {
244        /// Inclusive range of `from` and `to` instances in following notation:
245        /// `<from>..<to>`. Either `<from>` or `<to>` may be omitted, but not both.
246        range: Option<String>,
247        /// The output format.
248        #[arg(long, value_enum, default_value_t = F3OutputFormat::Text)]
249        output: F3OutputFormat,
250        /// The maximum number of instances. A value less than 0 indicates no limit.
251        #[arg(long, default_value_t = 10)]
252        limit: i64,
253        /// Reverses the default order of output.
254        #[arg(long, default_value_t = false)]
255        reverse: bool,
256    },
257}
258
259impl F3CertsCommands {
260    pub async fn run(self, client: rpc::Client) -> anyhow::Result<()> {
261        match self {
262            Self::Get { instance, output } => {
263                let cert = if let Some(instance) = instance {
264                    client.call(F3GetCertificate::request((instance,))?).await?
265                } else {
266                    client.call(F3GetLatestCertificate::request(())?).await?
267                };
268                match output {
269                    F3OutputFormat::Text => {
270                        println!("{}", render_certificate_template(&cert)?);
271                    }
272                    F3OutputFormat::Json => {
273                        println!("{}", serde_json::to_string_pretty(&cert)?);
274                    }
275                }
276            }
277            Self::List {
278                range,
279                output,
280                limit,
281                reverse,
282            } => {
283                let (from, to_opt) = if let Some(range) = range {
284                    let (from_opt, to_opt) = Self::parse_range_unvalidated(&range)?;
285                    (from_opt.unwrap_or_default(), to_opt)
286                } else {
287                    (0, None)
288                };
289                let to = if let Some(i) = to_opt {
290                    i
291                } else {
292                    F3GetLatestCertificate::call(&client, ()).await?.instance
293                };
294                anyhow::ensure!(
295                    to >= from,
296                    "ERROR: invalid range: 'from' cannot exceed 'to':  {from} > {to}"
297                );
298                let limit = if limit < 0 {
299                    usize::MAX
300                } else {
301                    limit as usize
302                };
303                let range: Box<dyn Iterator<Item = u64>> = if reverse {
304                    Box::new((from..=to).take(limit))
305                } else {
306                    Box::new((from..=to).rev().take(limit))
307                };
308                for i in range {
309                    let cert = F3GetCertificate::call(&client, (i,)).await?;
310                    match output {
311                        F3OutputFormat::Text => {
312                            println!("{}", render_certificate_template(&cert)?);
313                        }
314                        F3OutputFormat::Json => {
315                            println!("{}", serde_json::to_string_pretty(&cert)?);
316                        }
317                    }
318                    println!();
319                }
320            }
321        }
322
323        Ok(())
324    }
325
326    /// Parse range without validating `to >= from`
327    fn parse_range_unvalidated(range: &str) -> anyhow::Result<(Option<u64>, Option<u64>)> {
328        let pattern = lazy_regex::regex!(r#"^(?P<from>\d+)?\.\.(?P<to>\d+)?$"#);
329        if let Some(captures) = pattern.captures(range) {
330            let from = captures
331                .name("from")
332                .map(|i| i.as_str().parse().expect("Infallible"));
333            let to = captures
334                .name("to")
335                .map(|i| i.as_str().parse().expect("Infallible"));
336            anyhow::ensure!(from.is_some() || to.is_some(), "invalid range `{range}`");
337            Ok((from, to))
338        } else {
339            anyhow::bail!("invalid range `{range}`");
340        }
341    }
342}
343
344#[derive(Debug, Subcommand)]
345pub enum F3PowerTableCommands {
346    /// Gets F3 power table at a specific instance ID or latest instance if none is specified.
347    #[command(visible_alias = "g")]
348    Get {
349        /// instance ID. (default: latest)
350        instance: Option<u64>,
351        /// Whether to get the power table from EC. (default: false)
352        #[arg(long, default_value_t = false)]
353        ec: bool,
354    },
355    /// Gets the total proportion of power for a list of actors at a given instance.
356    #[command(visible_alias = "gp")]
357    GetProportion {
358        actor_ids: Vec<u64>,
359        /// instance ID. (default: latest)
360        #[arg(long, required = false)]
361        instance: Option<u64>,
362        /// Whether to get the power table from EC. (default: false)
363        #[arg(long, required = false, default_value_t = false)]
364        ec: bool,
365    },
366}
367
368impl F3PowerTableCommands {
369    pub async fn run(self, client: rpc::Client) -> anyhow::Result<()> {
370        match self {
371            Self::Get { instance, ec } => {
372                let (instance, power_table_cid, power_table) =
373                    Self::get_power_table(&client, instance, ec).await?;
374                let total = power_table
375                    .iter()
376                    .fold(num::BigInt::ZERO, |acc, entry| acc + &entry.power);
377                let mut scaled_total = 0;
378                for entry in power_table.iter() {
379                    scaled_total += scale_power(&entry.power, &total)?;
380                }
381                let result = F3PowerTableGetCommandResult {
382                    instance,
383                    from_ec: ec,
384                    power_table: F3PowerTableCliJson {
385                        cid: power_table_cid,
386                        entries: power_table,
387                        total,
388                        scaled_total,
389                    },
390                };
391                println!("{}", serde_json::to_string_pretty(&result)?);
392            }
393            Self::GetProportion {
394                actor_ids,
395                instance,
396                ec,
397            } => {
398                anyhow::ensure!(
399                    !actor_ids.is_empty(),
400                    "at least one actor ID must be specified"
401                );
402                let (instance, power_table_cid, power_table) =
403                    Self::get_power_table(&client, instance, ec).await?;
404                let total = power_table
405                    .iter()
406                    .fold(num::BigInt::ZERO, |acc, entry| acc + &entry.power);
407                let mut scaled_total = 0;
408                let mut scaled_sum = 0;
409                let mut actor_id_set = HashSet::from_iter(actor_ids);
410                for entry in power_table.iter() {
411                    let scaled_power = scale_power(&entry.power, &total)?;
412                    scaled_total += scaled_power;
413                    if actor_id_set.remove(&entry.id) {
414                        scaled_sum += scaled_power;
415                    }
416                }
417
418                let result = F3PowerTableGetProportionCommandResult {
419                    instance,
420                    from_ec: ec,
421                    power_table: F3PowerTableCliMinimalJson {
422                        cid: power_table_cid,
423                        scaled_total,
424                    },
425                    scaled_sum,
426                    proportion: (scaled_sum as f64) / (scaled_total as f64),
427                    not_found: actor_id_set.into_iter().collect(),
428                };
429                println!("{}", serde_json::to_string_pretty(&result)?);
430            }
431        };
432
433        Ok(())
434    }
435
436    async fn get_power_table(
437        client: &rpc::Client,
438        instance: Option<u64>,
439        ec: bool,
440    ) -> anyhow::Result<(u64, Cid, Vec<F3PowerEntry>)> {
441        let instance = if let Some(instance) = instance {
442            instance
443        } else {
444            let progress = F3GetProgress::call(client, ()).await?;
445            progress.id
446        };
447        let (tsk, power_table_cid) =
448            Self::get_power_table_tsk_by_instance(client, instance).await?;
449        let power_table = if ec {
450            F3GetECPowerTable::call(client, (tsk.into(),)).await?
451        } else {
452            F3GetF3PowerTableByInstance::call(client, (instance,)).await?
453        };
454        Ok((instance, power_table_cid, power_table))
455    }
456
457    async fn get_power_table_tsk_by_instance(
458        client: &rpc::Client,
459        instance: u64,
460    ) -> anyhow::Result<(TipsetKey, Cid)> {
461        let manifest = F3GetManifest::call(client, ()).await?;
462        if instance < manifest.initial_instance + manifest.committee_lookback {
463            let epoch = manifest.bootstrap_epoch - manifest.ec.finality;
464            let ts = ChainGetTipSetByHeight::call(client, (epoch, None.into())).await?;
465            return Ok((
466                ts.key().clone(),
467                manifest.initial_power_table.unwrap_or_default(),
468            ));
469        }
470
471        let previous = F3GetCertificate::call(client, (instance.saturating_sub(1),)).await?;
472        let lookback = F3GetCertificate::call(
473            client,
474            (instance.saturating_sub(manifest.committee_lookback),),
475        )
476        .await?;
477        let tsk = lookback.ec_chain.last().key.clone();
478        Ok((tsk, previous.supplemental_data.power_table))
479    }
480}
481
482fn render_manifest_template(template: &F3Manifest) -> anyhow::Result<String> {
483    let mut context = tera::Context::from_serialize(template)?;
484    context.insert(
485        "initial_power_table_cid",
486        &match template.initial_power_table {
487            Some(initial_power_table) if initial_power_table != Cid::default() => {
488                Cow::Owned(initial_power_table.to_string())
489            }
490            _ => Cow::Borrowed("unknown"),
491        },
492    );
493    Ok(TEMPLATES
494        .render(MANIFEST_TEMPLATE_NAME, &context)?
495        .trim_end()
496        .to_owned())
497}
498
499fn render_certificate_template(template: &FinalityCertificate) -> anyhow::Result<String> {
500    const MAX_TIPSETS: usize = 10;
501    const MAX_TIPSET_KEYS: usize = 2;
502    let mut context = tera::Context::from_serialize(template)?;
503    context.insert(
504        "power_table_cid",
505        &template.supplemental_data.power_table.to_string(),
506    );
507    context.insert(
508        "power_table_delta_string",
509        &template.power_table_delta_string(),
510    );
511    context.insert(
512        "epochs",
513        &format!(
514            "{}-{}",
515            template.chain_base().epoch,
516            template.chain_head().epoch
517        ),
518    );
519    let mut chain_lines = vec![];
520    for (i, ts) in template.ec_chain.iter().take(MAX_TIPSETS).enumerate() {
521        let table = if i + 1 == template.ec_chain.len() {
522            "    └──"
523        } else {
524            "    ├──"
525        };
526        let mut keys = ts
527            .key
528            .iter()
529            .take(MAX_TIPSET_KEYS)
530            .map(|i| i.to_string())
531            .join(", ");
532        if ts.key.len() > MAX_TIPSET_KEYS {
533            keys = format!("{keys}, ...");
534        }
535        chain_lines.push(format!(
536            "{table}{} (length: {}): [{keys}]",
537            ts.epoch,
538            ts.key.len()
539        ));
540    }
541    if template.ec_chain.len() > MAX_TIPSETS {
542        let n_remaining = template.ec_chain.len() - MAX_TIPSETS;
543        chain_lines.push(format!(
544            "    └──...omitted the remaining {n_remaining} tipsets."
545        ));
546    }
547    chain_lines.push(format!("Signed by {} miner(s).", template.signers.len()));
548    context.insert("chain_lines", &chain_lines);
549    Ok(TEMPLATES
550        .render(CERTIFICATE_TEMPLATE_NAME, &context)?
551        .trim_end()
552        .to_owned())
553}
554
555fn render_progress_template(template: &F3InstanceProgress) -> anyhow::Result<String> {
556    let mut context = tera::Context::from_serialize(template)?;
557    context.insert("phase_string", template.phase_string());
558    Ok(TEMPLATES
559        .render(PROGRESS_TEMPLATE_NAME, &context)?
560        .trim_end()
561        .to_owned())
562}
563
564#[derive(Debug, Clone, Deserialize, Serialize)]
565#[serde(rename_all = "PascalCase")]
566pub struct F3PowerTableGetCommandResult {
567    instance: u64,
568    #[serde(rename = "FromEC")]
569    from_ec: bool,
570    power_table: F3PowerTableCliJson,
571}
572
573#[serde_as]
574#[derive(Debug, Clone, Deserialize, Serialize)]
575#[serde(rename_all = "PascalCase")]
576pub struct F3PowerTableCliJson {
577    #[serde(rename = "CID")]
578    #[serde_as(as = "DisplayFromStr")]
579    cid: Cid,
580    #[serde(with = "crate::lotus_json")]
581    entries: Vec<F3PowerEntry>,
582    #[serde(with = "crate::lotus_json::stringify")]
583    total: num::BigInt,
584    scaled_total: i64,
585}
586
587#[derive(Debug, Clone, Deserialize, Serialize)]
588#[serde(rename_all = "PascalCase")]
589pub struct F3PowerTableGetProportionCommandResult {
590    instance: u64,
591    #[serde(rename = "FromEC")]
592    from_ec: bool,
593    power_table: F3PowerTableCliMinimalJson,
594    scaled_sum: i64,
595    proportion: f64,
596    not_found: Vec<ActorID>,
597}
598
599#[serde_as]
600#[derive(Debug, Clone, Deserialize, Serialize)]
601#[serde(rename_all = "PascalCase")]
602pub struct F3PowerTableCliMinimalJson {
603    #[serde(rename = "CID")]
604    #[serde_as(as = "DisplayFromStr")]
605    cid: Cid,
606    scaled_total: i64,
607}
608
609fn scale_power(power: &num::BigInt, total: &num::BigInt) -> anyhow::Result<i64> {
610    const MAX_POWER: i64 = 0xffff;
611    if total < power {
612        anyhow::bail!("total power {total} is less than the power of a single participant {power}");
613    }
614    let scacled = MAX_POWER * power / total;
615    Ok(scacled.try_into()?)
616}