1#[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#[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<(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#[derive(Debug, Subcommand)]
234pub enum F3CertsCommands {
235 Get {
237 instance: Option<u64>,
238 #[arg(long, value_enum, default_value_t = F3OutputFormat::Text)]
240 output: F3OutputFormat,
241 },
242 List {
244 range: Option<String>,
247 #[arg(long, value_enum, default_value_t = F3OutputFormat::Text)]
249 output: F3OutputFormat,
250 #[arg(long, default_value_t = 10)]
252 limit: i64,
253 #[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 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 #[command(visible_alias = "g")]
348 Get {
349 instance: Option<u64>,
351 #[arg(long, default_value_t = false)]
353 ec: bool,
354 },
355 #[command(visible_alias = "gp")]
357 GetProportion {
358 actor_ids: Vec<u64>,
359 #[arg(long, required = false)]
361 instance: Option<u64>,
362 #[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}