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