Skip to main content

forest/cli/subcommands/
f3_cmd.rs

1// Copyright 2019-2026 ChainSafe Systems
2// SPDX-License-Identifier: Apache-2.0, MIT
3
4#[cfg(test)]
5mod tests;
6
7use std::{
8    borrow::Cow,
9    sync::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<(Tipset, FinalityCertificate)> {
155                    let (cert_head, chain_head) = tokio::try_join!(
156                        client.call(F3GetLatestCertificate::request(())?),
157                        client.call(ChainHead::request(())?),
158                    )?;
159                    Ok((chain_head, cert_head))
160                }
161
162                let pb = ProgressBar::new_spinner().with_style(
163                    ProgressStyle::with_template("{spinner} {msg}")
164                        .expect("indicatif template must be valid"),
165                );
166                pb.enable_steady_tick(std::time::Duration::from_millis(100));
167                let mut num_consecutive_fetch_failtures = 0;
168                let no_progress_timeout_duration: Duration = no_progress_timeout.into();
169                let mut interval = tokio::time::interval(Duration::from_secs(1));
170                let mut last_f3_head_epoch = 0;
171                let mut last_progress = Instant::now();
172                loop {
173                    interval.tick().await;
174                    match get_heads(&client).await {
175                        Ok((chain_head, cert_head)) => {
176                            num_consecutive_fetch_failtures = 0;
177                            let f3_head_epoch = cert_head.chain_head().epoch;
178                            if f3_head_epoch != last_f3_head_epoch {
179                                last_f3_head_epoch = f3_head_epoch;
180                                last_progress = Instant::now();
181                            }
182                            if f3_head_epoch.saturating_add(threshold.try_into()?)
183                                >= chain_head.epoch()
184                            {
185                                let text = format!(
186                                    "[+] F3 is in sync. Chain head epoch: {}, F3 head epoch: {}",
187                                    chain_head.epoch(),
188                                    cert_head.chain_head().epoch
189                                );
190                                pb.set_message(text);
191                                pb.finish();
192                                break;
193                            } else {
194                                let text = format!(
195                                    "[-] F3 is not in sync. Chain head epoch: {}, F3 head epoch: {}",
196                                    chain_head.epoch(),
197                                    cert_head.chain_head().epoch
198                                );
199                                pb.set_message(text);
200                                if !wait {
201                                    pb.finish();
202                                    std::process::exit(EXIT_CODE_F3_NOT_IN_SYNC);
203                                }
204                            }
205                        }
206                        Err(e) => {
207                            if !wait {
208                                return Err(e.context("Failed to check F3 sync status"));
209                            }
210
211                            num_consecutive_fetch_failtures += 1;
212                            if num_consecutive_fetch_failtures >= 3 {
213                                eprintln!("Warning: Failed to fetch heads: {e:#}. Exiting...");
214                                std::process::exit(EXIT_CODE_F3_FAIL_TO_FETCH_HEAD);
215                            } else {
216                                eprintln!("Warning: Failed to fetch heads: {e:#}. Retrying...");
217                            }
218                        }
219                    }
220
221                    if last_progress + no_progress_timeout_duration < Instant::now() {
222                        eprintln!(
223                            "Warning: F3 made no progress in the past {no_progress_timeout}. Exiting..."
224                        );
225                        std::process::exit(EXIT_CODE_F3_NO_PROGRESS_TIMEOUT);
226                    }
227                }
228                Ok(())
229            }
230        }
231    }
232}
233
234/// Manages interactions with F3 finality certificates.
235#[derive(Debug, Subcommand)]
236pub enum F3CertsCommands {
237    /// Gets an F3 finality certificate to a given instance ID, or the latest certificate if no instance is specified.
238    Get {
239        instance: Option<u64>,
240        /// The output format.
241        #[arg(long, value_enum, default_value_t = F3OutputFormat::Text)]
242        output: F3OutputFormat,
243    },
244    /// Lists a range of F3 finality certificates.
245    List {
246        /// Inclusive range of `from` and `to` instances in following notation:
247        /// `<from>..<to>`. Either `<from>` or `<to>` may be omitted, but not both.
248        range: Option<String>,
249        /// The output format.
250        #[arg(long, value_enum, default_value_t = F3OutputFormat::Text)]
251        output: F3OutputFormat,
252        /// The maximum number of instances. A value less than 0 indicates no limit.
253        #[arg(long, default_value_t = 10)]
254        limit: i64,
255        /// Reverses the default order of output.
256        #[arg(long, default_value_t = false)]
257        reverse: bool,
258    },
259}
260
261impl F3CertsCommands {
262    pub async fn run(self, client: rpc::Client) -> anyhow::Result<()> {
263        match self {
264            Self::Get { instance, output } => {
265                let cert = if let Some(instance) = instance {
266                    client.call(F3GetCertificate::request((instance,))?).await?
267                } else {
268                    client.call(F3GetLatestCertificate::request(())?).await?
269                };
270                match output {
271                    F3OutputFormat::Text => {
272                        println!("{}", render_certificate_template(&cert)?);
273                    }
274                    F3OutputFormat::Json => {
275                        println!("{}", serde_json::to_string_pretty(&cert)?);
276                    }
277                }
278            }
279            Self::List {
280                range,
281                output,
282                limit,
283                reverse,
284            } => {
285                let (from, to_opt) = if let Some(range) = range {
286                    let (from_opt, to_opt) = Self::parse_range_unvalidated(&range)?;
287                    (from_opt.unwrap_or_default(), to_opt)
288                } else {
289                    (0, None)
290                };
291                let to = if let Some(i) = to_opt {
292                    i
293                } else {
294                    F3GetLatestCertificate::call(&client, ()).await?.instance
295                };
296                anyhow::ensure!(
297                    to >= from,
298                    "ERROR: invalid range: 'from' cannot exceed 'to':  {from} > {to}"
299                );
300                let limit = if limit < 0 {
301                    usize::MAX
302                } else {
303                    limit as usize
304                };
305                let range: Box<dyn Iterator<Item = u64>> = if reverse {
306                    Box::new((from..=to).take(limit))
307                } else {
308                    Box::new((from..=to).rev().take(limit))
309                };
310                for i in range {
311                    let cert = F3GetCertificate::call(&client, (i,)).await?;
312                    match output {
313                        F3OutputFormat::Text => {
314                            println!("{}", render_certificate_template(&cert)?);
315                        }
316                        F3OutputFormat::Json => {
317                            println!("{}", serde_json::to_string_pretty(&cert)?);
318                        }
319                    }
320                    println!();
321                }
322            }
323        }
324
325        Ok(())
326    }
327
328    /// Parse range without validating `to >= from`
329    fn parse_range_unvalidated(range: &str) -> anyhow::Result<(Option<u64>, Option<u64>)> {
330        let pattern = lazy_regex::regex!(r#"^(?P<from>\d+)?\.\.(?P<to>\d+)?$"#);
331        if let Some(captures) = pattern.captures(range) {
332            let from = captures
333                .name("from")
334                .map(|i| i.as_str().parse().expect("Infallible"));
335            let to = captures
336                .name("to")
337                .map(|i| i.as_str().parse().expect("Infallible"));
338            anyhow::ensure!(from.is_some() || to.is_some(), "invalid range `{range}`");
339            Ok((from, to))
340        } else {
341            anyhow::bail!("invalid range `{range}`");
342        }
343    }
344}
345
346#[derive(Debug, Subcommand)]
347pub enum F3PowerTableCommands {
348    /// Gets F3 power table at a specific instance ID or latest instance if none is specified.
349    #[command(visible_alias = "g")]
350    Get {
351        /// instance ID. (default: latest)
352        instance: Option<u64>,
353        /// Whether to get the power table from EC. (default: false)
354        #[arg(long, default_value_t = false)]
355        ec: bool,
356    },
357    /// Gets the total proportion of power for a list of actors at a given instance.
358    #[command(visible_alias = "gp")]
359    GetProportion {
360        actor_ids: Vec<u64>,
361        /// instance ID. (default: latest)
362        #[arg(long, required = false)]
363        instance: Option<u64>,
364        /// Whether to get the power table from EC. (default: false)
365        #[arg(long, required = false, default_value_t = false)]
366        ec: bool,
367    },
368}
369
370impl F3PowerTableCommands {
371    pub async fn run(self, client: rpc::Client) -> anyhow::Result<()> {
372        match self {
373            Self::Get { instance, ec } => {
374                let (instance, power_table_cid, power_table) =
375                    Self::get_power_table(&client, instance, ec).await?;
376                let total = power_table
377                    .iter()
378                    .fold(num::BigInt::ZERO, |acc, entry| acc + &entry.power);
379                let mut scaled_total = 0;
380                for entry in power_table.iter() {
381                    scaled_total += scale_power(&entry.power, &total)?;
382                }
383                let result = F3PowerTableGetCommandResult {
384                    instance,
385                    from_ec: ec,
386                    power_table: F3PowerTableCliJson {
387                        cid: power_table_cid,
388                        entries: power_table,
389                        total,
390                        scaled_total,
391                    },
392                };
393                println!("{}", serde_json::to_string_pretty(&result)?);
394            }
395            Self::GetProportion {
396                actor_ids,
397                instance,
398                ec,
399            } => {
400                anyhow::ensure!(
401                    !actor_ids.is_empty(),
402                    "at least one actor ID must be specified"
403                );
404                let (instance, power_table_cid, power_table) =
405                    Self::get_power_table(&client, instance, ec).await?;
406                let total = power_table
407                    .iter()
408                    .fold(num::BigInt::ZERO, |acc, entry| acc + &entry.power);
409                let mut scaled_total = 0;
410                let mut scaled_sum = 0;
411                let mut actor_id_set = HashSet::from_iter(actor_ids);
412                for entry in power_table.iter() {
413                    let scaled_power = scale_power(&entry.power, &total)?;
414                    scaled_total += scaled_power;
415                    if actor_id_set.remove(&entry.id) {
416                        scaled_sum += scaled_power;
417                    }
418                }
419
420                let result = F3PowerTableGetProportionCommandResult {
421                    instance,
422                    from_ec: ec,
423                    power_table: F3PowerTableCliMinimalJson {
424                        cid: power_table_cid,
425                        scaled_total,
426                    },
427                    scaled_sum,
428                    proportion: (scaled_sum as f64) / (scaled_total as f64),
429                    not_found: actor_id_set.into_iter().collect(),
430                };
431                println!("{}", serde_json::to_string_pretty(&result)?);
432            }
433        };
434
435        Ok(())
436    }
437
438    async fn get_power_table(
439        client: &rpc::Client,
440        instance: Option<u64>,
441        ec: bool,
442    ) -> anyhow::Result<(u64, Cid, Vec<F3PowerEntry>)> {
443        let instance = if let Some(instance) = instance {
444            instance
445        } else {
446            let progress = F3GetProgress::call(client, ()).await?;
447            progress.id
448        };
449        let (tsk, power_table_cid) =
450            Self::get_power_table_tsk_by_instance(client, instance).await?;
451        let power_table = if ec {
452            F3GetECPowerTable::call(client, (tsk.into(),)).await?
453        } else {
454            F3GetF3PowerTableByInstance::call(client, (instance,)).await?
455        };
456        Ok((instance, power_table_cid, power_table))
457    }
458
459    async fn get_power_table_tsk_by_instance(
460        client: &rpc::Client,
461        instance: u64,
462    ) -> anyhow::Result<(TipsetKey, Cid)> {
463        let manifest = F3GetManifest::call(client, ()).await?;
464        if instance < manifest.initial_instance + manifest.committee_lookback {
465            let epoch = manifest.bootstrap_epoch - manifest.ec.finality;
466            let ts = ChainGetTipSetByHeight::call(client, (epoch, None.into())).await?;
467            return Ok((
468                ts.key().clone(),
469                manifest.initial_power_table.unwrap_or_default(),
470            ));
471        }
472
473        let (previous, lookback) = tokio::try_join!(
474            F3GetCertificate::call(client, (instance.saturating_sub(1),)),
475            F3GetCertificate::call(
476                client,
477                (instance.saturating_sub(manifest.committee_lookback),)
478            ),
479        )?;
480        let tsk = lookback.ec_chain.last().key.clone();
481        Ok((tsk, previous.supplemental_data.power_table))
482    }
483}
484
485fn render_manifest_template(template: &F3Manifest) -> anyhow::Result<String> {
486    let mut context = tera::Context::from_serialize(template)?;
487    context.insert(
488        "initial_power_table_cid",
489        &match template.initial_power_table {
490            Some(initial_power_table) if initial_power_table != Cid::default() => {
491                Cow::Owned(initial_power_table.to_string())
492            }
493            _ => Cow::Borrowed("unknown"),
494        },
495    );
496    Ok(TEMPLATES
497        .render(MANIFEST_TEMPLATE_NAME, &context)?
498        .trim_end()
499        .to_owned())
500}
501
502fn render_certificate_template(template: &FinalityCertificate) -> anyhow::Result<String> {
503    const MAX_TIPSETS: usize = 10;
504    const MAX_TIPSET_KEYS: usize = 2;
505    let mut context = tera::Context::from_serialize(template)?;
506    context.insert(
507        "power_table_cid",
508        &template.supplemental_data.power_table.to_string(),
509    );
510    context.insert(
511        "power_table_delta_string",
512        &template.power_table_delta_string(),
513    );
514    context.insert(
515        "epochs",
516        &format!(
517            "{}-{}",
518            template.chain_base().epoch,
519            template.chain_head().epoch
520        ),
521    );
522    let mut chain_lines = vec![];
523    for (i, ts) in template.ec_chain.iter().take(MAX_TIPSETS).enumerate() {
524        let table = if i + 1 == template.ec_chain.len() {
525            "    └──"
526        } else {
527            "    ├──"
528        };
529        let mut keys = ts
530            .key
531            .iter()
532            .take(MAX_TIPSET_KEYS)
533            .map(|i| i.to_string())
534            .join(", ");
535        if ts.key.len() > MAX_TIPSET_KEYS {
536            keys = format!("{keys}, ...");
537        }
538        chain_lines.push(format!(
539            "{table}{} (length: {}): [{keys}]",
540            ts.epoch,
541            ts.key.len()
542        ));
543    }
544    if template.ec_chain.len() > MAX_TIPSETS {
545        let n_remaining = template.ec_chain.len() - MAX_TIPSETS;
546        chain_lines.push(format!(
547            "    └──...omitted the remaining {n_remaining} tipsets."
548        ));
549    }
550    chain_lines.push(format!("Signed by {} miner(s).", template.signers.len()));
551    context.insert("chain_lines", &chain_lines);
552    Ok(TEMPLATES
553        .render(CERTIFICATE_TEMPLATE_NAME, &context)?
554        .trim_end()
555        .to_owned())
556}
557
558fn render_progress_template(template: &F3InstanceProgress) -> anyhow::Result<String> {
559    let mut context = tera::Context::from_serialize(template)?;
560    context.insert("phase_string", template.phase_string());
561    Ok(TEMPLATES
562        .render(PROGRESS_TEMPLATE_NAME, &context)?
563        .trim_end()
564        .to_owned())
565}
566
567#[derive(Debug, Clone, Deserialize, Serialize)]
568#[serde(rename_all = "PascalCase")]
569pub struct F3PowerTableGetCommandResult {
570    instance: u64,
571    #[serde(rename = "FromEC")]
572    from_ec: bool,
573    power_table: F3PowerTableCliJson,
574}
575
576#[serde_as]
577#[derive(Debug, Clone, Deserialize, Serialize)]
578#[serde(rename_all = "PascalCase")]
579pub struct F3PowerTableCliJson {
580    #[serde(rename = "CID")]
581    #[serde_as(as = "DisplayFromStr")]
582    cid: Cid,
583    #[serde(with = "crate::lotus_json")]
584    entries: Vec<F3PowerEntry>,
585    #[serde(with = "crate::lotus_json::stringify")]
586    total: num::BigInt,
587    scaled_total: i64,
588}
589
590#[derive(Debug, Clone, Deserialize, Serialize)]
591#[serde(rename_all = "PascalCase")]
592pub struct F3PowerTableGetProportionCommandResult {
593    instance: u64,
594    #[serde(rename = "FromEC")]
595    from_ec: bool,
596    power_table: F3PowerTableCliMinimalJson,
597    scaled_sum: i64,
598    proportion: f64,
599    not_found: Vec<ActorID>,
600}
601
602#[serde_as]
603#[derive(Debug, Clone, Deserialize, Serialize)]
604#[serde(rename_all = "PascalCase")]
605pub struct F3PowerTableCliMinimalJson {
606    #[serde(rename = "CID")]
607    #[serde_as(as = "DisplayFromStr")]
608    cid: Cid,
609    scaled_total: i64,
610}
611
612fn scale_power(power: &num::BigInt, total: &num::BigInt) -> anyhow::Result<i64> {
613    const MAX_POWER: i64 = 0xffff;
614    if total < power {
615        anyhow::bail!("total power {total} is less than the power of a single participant {power}");
616    }
617    let scacled = MAX_POWER * power / total;
618    Ok(scacled.try_into()?)
619}