1#[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#[derive(ValueEnum, Debug, Clone, Default, Serialize, Deserialize)]
71#[serde(rename_all = "kebab-case")]
72pub enum F3OutputFormat {
73 #[default]
75 Text,
76 Json,
78}
79
80#[derive(Debug, Subcommand)]
82pub enum F3Commands {
83 Manifest {
85 #[arg(long, value_enum, default_value_t = F3OutputFormat::Text)]
87 output: F3OutputFormat,
88 },
89 Status,
91 #[command(subcommand, visible_alias = "c")]
93 Certs(F3CertsCommands),
94 #[command(subcommand, name = "powertable", visible_alias = "pt")]
96 PowerTable(F3PowerTableCommands),
97 Ready {
99 #[arg(long)]
101 wait: bool,
102 #[arg(long, default_value_t = 20)]
104 threshold: usize,
105 #[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#[derive(Debug, Subcommand)]
235pub enum F3CertsCommands {
236 Get {
238 instance: Option<u64>,
239 #[arg(long, value_enum, default_value_t = F3OutputFormat::Text)]
241 output: F3OutputFormat,
242 },
243 List {
245 range: Option<String>,
248 #[arg(long, value_enum, default_value_t = F3OutputFormat::Text)]
250 output: F3OutputFormat,
251 #[arg(long, default_value_t = 10)]
253 limit: i64,
254 #[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 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 #[command(visible_alias = "g")]
349 Get {
350 instance: Option<u64>,
352 #[arg(long, default_value_t = false)]
354 ec: bool,
355 },
356 #[command(visible_alias = "gp")]
358 GetProportion {
359 actor_ids: Vec<u64>,
360 #[arg(long, required = false)]
362 instance: Option<u64>,
363 #[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}