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 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#[derive(ValueEnum, Debug, Clone, Default, Serialize, Deserialize)]
72#[serde(rename_all = "kebab-case")]
73pub enum F3OutputFormat {
74 #[default]
76 Text,
77 Json,
79}
80
81#[derive(Debug, Subcommand)]
83pub enum F3Commands {
84 Manifest {
86 #[arg(long, value_enum, default_value_t = F3OutputFormat::Text)]
88 output: F3OutputFormat,
89 },
90 Status,
92 #[command(subcommand, visible_alias = "c")]
94 Certs(F3CertsCommands),
95 #[command(subcommand, name = "powertable", visible_alias = "pt")]
97 PowerTable(F3PowerTableCommands),
98 Ready {
100 #[arg(long)]
102 wait: bool,
103 #[arg(long, default_value_t = 20)]
105 threshold: usize,
106 #[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#[derive(Debug, Subcommand)]
236pub enum F3CertsCommands {
237 Get {
239 instance: Option<u64>,
240 #[arg(long, value_enum, default_value_t = F3OutputFormat::Text)]
242 output: F3OutputFormat,
243 },
244 List {
246 range: Option<String>,
249 #[arg(long, value_enum, default_value_t = F3OutputFormat::Text)]
251 output: F3OutputFormat,
252 #[arg(long, default_value_t = 10)]
254 limit: i64,
255 #[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 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 #[command(visible_alias = "g")]
350 Get {
351 instance: Option<u64>,
353 #[arg(long, default_value_t = false)]
355 ec: bool,
356 },
357 #[command(visible_alias = "gp")]
359 GetProportion {
360 actor_ids: Vec<u64>,
361 #[arg(long, required = false)]
363 instance: Option<u64>,
364 #[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}