diff --git a/CHANGELOG.md b/CHANGELOG.md index e9fbd8d26823..59ead3f8cbf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,9 @@ - [#4949](https://github.com/ChainSafe/forest/pull/4949) Added `forest-cli f3 status` CLI command. +- [#4949](https://github.com/ChainSafe/forest/pull/4949) Added + `forest-cli f3 certs get` CLI command. + - [#4706](https://github.com/ChainSafe/forest/issues/4706) Add support for the `Filecoin.EthSendRawTransaction` RPC method. diff --git a/src/blocks/tipset.rs b/src/blocks/tipset.rs index 79fe404bc4a8..342b8f562eca 100644 --- a/src/blocks/tipset.rs +++ b/src/blocks/tipset.rs @@ -63,6 +63,11 @@ impl TipsetKey { pub fn iter(&self) -> impl Iterator + '_ { self.0.iter() } + + /// Returns the number of `CID`s + pub fn len(&self) -> usize { + self.0.len() + } } impl From> for TipsetKey { diff --git a/src/cid_collections/small_cid_vec.rs b/src/cid_collections/small_cid_vec.rs index 274fa7604556..44525c5d386e 100644 --- a/src/cid_collections/small_cid_vec.rs +++ b/src/cid_collections/small_cid_vec.rs @@ -38,6 +38,11 @@ impl SmallCidNonEmptyVec { pub fn iter(&self) -> impl Iterator + '_ { self.0.iter().map(|cid| Cid::from(cid.clone())) } + + /// Returns the number of `CID`s + pub fn len(&self) -> usize { + self.0.len() + } } impl<'a> IntoIterator for &'a SmallCidNonEmptyVec { diff --git a/src/cli/subcommands/f3_cmd.rs b/src/cli/subcommands/f3_cmd.rs index 8335261959af..422025037542 100644 --- a/src/cli/subcommands/f3_cmd.rs +++ b/src/cli/subcommands/f3_cmd.rs @@ -3,7 +3,7 @@ use crate::rpc::{ self, - f3::{F3Instant, F3Manifest}, + f3::{F3Instant, F3Manifest, FinalityCertificate}, prelude::*, }; use cid::Cid; @@ -33,6 +33,9 @@ pub enum F3Commands { }, /// Checks the F3 status. Status, + /// Manages interactions with F3 finality certificates. + #[command(subcommand, visible_alias = "c")] + Certs(F3CertsCommands), } impl F3Commands { @@ -62,6 +65,43 @@ impl F3Commands { println!("{}", manifest_template.render_once()?); Ok(()) } + Self::Certs(cmd) => cmd.run(client).await, + } + } +} + +/// Manages interactions with F3 finality certificates. +#[derive(Debug, Subcommand)] +pub enum F3CertsCommands { + /// Gets an F3 finality certificate to a given instance ID, or the latest certificate if no instance is specified. + Get { + instance: Option, + /// The output format. + #[arg(long, value_enum, default_value_t = F3OutputFormat::Text)] + output: F3OutputFormat, + }, +} + +impl F3CertsCommands { + pub async fn run(self, client: rpc::Client) -> anyhow::Result<()> { + match self { + Self::Get { instance, output } => { + let cert = if let Some(instance) = instance { + client.call(F3GetCertificate::request((instance,))?).await? + } else { + client.call(F3GetLatestCertificate::request(())?).await? + }; + match output { + F3OutputFormat::Text => { + let template = FinalityCertificateTemplate::new(cert); + println!("{}", template.render_once()?); + } + F3OutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(&cert)?); + } + } + Ok(()) + } } } } @@ -95,6 +135,18 @@ impl ProgressTemplate { } } +#[derive(TemplateSimple, Debug, Clone, Serialize, Deserialize)] +#[template(path = "cli/f3/certificate.stpl")] +struct FinalityCertificateTemplate { + cert: FinalityCertificate, +} + +impl FinalityCertificateTemplate { + fn new(cert: FinalityCertificate) -> Self { + Self { cert } + } +} + #[cfg(test)] mod tests { use super::*; @@ -148,8 +200,166 @@ mod tests { "MaximumPollInterval": 120000000000_u64 } }); - let manifest: F3Manifest = serde_json::from_value(lotus_json.clone()).unwrap(); + let manifest: F3Manifest = serde_json::from_value(lotus_json).unwrap(); let template = ManifestTemplate::new(manifest); println!("{}", template.render_once().unwrap()); } + + #[test] + fn test_progress_template() { + let lotus_json = serde_json::json!({ + "ID": 1000, + "Round": 0, + "Phase": 0 + }); + let progress: F3Instant = serde_json::from_value(lotus_json).unwrap(); + let template = ProgressTemplate::new(progress); + println!("{}", template.render_once().unwrap()); + } + + #[test] + fn test_finality_certificate_template() { + // lotus f3 c get --output json 6204 + let lotus_json = serde_json::json!({ + "GPBFTInstance": 6204, + "ECChain": [ + { + "Epoch": 2088927, + "Key": "AXGg5AIg1NBjOnFimwUueRXQQzvPbHZO6vXbvqNA1gcomlVrq5MBcaDkAiCaOt71j85kjjq3SZF0NQq03tauEW3iwscIr4Qw0wna+g==", + "PowerTable": { + "/": "bafy2bzaceazjn2promafvtkaquebfgc3xvhoavdbxwns4i54ilgnzch7pkgua" + }, + "Commitments": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "Epoch": 2088928, + "Key": "AXGg5AIgFn9g3q/ATrgWiWzUYZLrtN/POrkNWFPmUShj/MDqZ5IBcaDkAiACwpEW4PvUCOIsZRaYhF6W+L1bgGd2TUFLOkATNxvuGgFxoOQCILlKPpFgMxXYFcq2HslyxzBN9ZZ6iPrPSBI2uwT4tUAvAXGg5AIgwYDZ217HUZ6nGnm6fnNd5lhep2C02mSYkkjJPf5pOig=", + "PowerTable": { + "/": "bafy2bzaceazjn2promafvtkaquebfgc3xvhoavdbxwns4i54ilgnzch7pkgua" + }, + "Commitments": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + } + ], + "SupplementalData": { + "Commitments": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "PowerTable": { + "/": "bafy2bzaceazjn2promafvtkaquebfgc3xvhoavdbxwns4i54ilgnzch7pkgua" + } + }, + "Signers": [ + 0, + 3 + ], + "Signature": "uYtvw/NWm2jKQj+d99UAG4aiPnpAMSrwAWIusv0XkjsOYYR0fyU4nUM++cAQGO47E2/J8WSDjstLgL+yMVAFC+Tgao4o9ILXIlhqhxObnNZ/Ehanajthif9SaRe1AO69", + "PowerTableDelta": [ + { + "ParticipantID": 3782, + "PowerDelta": "76347338653696", + "SigningKey": "lXSMTNEVmIdVxJV4clmW35jrlsBEfytNUGTWVih2dFlQ1k/7QQttsUGzpD5JoNaQ" + } + ] + }); + let cert: FinalityCertificate = serde_json::from_value(lotus_json).unwrap(); + let template = FinalityCertificateTemplate::new(cert); + println!("{}", template.render_once().unwrap()); + } } diff --git a/src/rpc/methods/f3.rs b/src/rpc/methods/f3.rs index 556d7a2086c0..71a4d51167fa 100644 --- a/src/rpc/methods/f3.rs +++ b/src/rpc/methods/f3.rs @@ -10,7 +10,7 @@ mod types; mod util; -pub use self::types::{F3Instant, F3LeaseManager, F3Manifest}; +pub use self::types::{F3Instant, F3LeaseManager, F3Manifest, FinalityCertificate}; use self::{types::*, util::*}; use super::wallet::WalletSign; use crate::{ @@ -566,7 +566,7 @@ impl RpcMethod<1> for F3GetCertificate { const PERMISSION: Permission = Permission::Read; type Params = (u64,); - type Ok = serde_json::Value; + type Ok = FinalityCertificate; async fn handle( _: Ctx, @@ -589,7 +589,7 @@ impl RpcMethod<0> for F3GetLatestCertificate { const PERMISSION: Permission = Permission::Read; type Params = (); - type Ok = serde_json::Value; + type Ok = FinalityCertificate; async fn handle(_: Ctx, _: Self::Params) -> Result { let client = get_rpc_http_client()?; diff --git a/src/rpc/methods/f3/types.rs b/src/rpc/methods/f3/types.rs index 7d985ee24db5..ff9628085dc6 100644 --- a/src/rpc/methods/f3/types.rs +++ b/src/rpc/methods/f3/types.rs @@ -8,10 +8,12 @@ use crate::{ networks::NetworkChain, }; use cid::{multihash::MultihashDigest as _, Cid}; +use fil_actors_shared::fvm_ipld_bitfield::BitField; use fvm_ipld_encoding::tuple::{Deserialize_tuple, Serialize_tuple}; use fvm_shared4::ActorID; use itertools::Itertools as _; use libp2p::PeerId; +use num::Zero as _; use once_cell::sync::Lazy; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -21,7 +23,7 @@ use std::{cmp::Ordering, time::Duration}; const MAX_LEASE_INSTANCES: u64 = 5; /// TipSetKey is the canonically ordered concatenation of the block CIDs in a tipset. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct F3TipSetKey( #[schemars(with = "String")] #[serde(with = "base64_standard")] @@ -67,7 +69,7 @@ impl TryFrom for TipsetKey { } } -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct F3TipSet { pub key: F3TipSetKey, /// The verifiable oracle randomness used to elect this block's author leader @@ -110,16 +112,37 @@ impl From> for F3TipSet { } } +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "PascalCase")] +pub struct ECTipSet { + #[schemars(with = "String")] + #[serde(with = "crate::lotus_json")] + pub key: F3TipSetKey, + pub epoch: ChainEpoch, + #[schemars(with = "String")] + #[serde(with = "crate::lotus_json")] + pub power_table: Cid, + pub commitments: [u8; 32], +} +lotus_json_with_self!(ECTipSet); + +impl ECTipSet { + pub fn ec_tipset_key(&self) -> TipsetKey { + TipsetKey::try_from(self.key.clone()).expect("failed to convert F3TipSetKey to TipsetKey") + } +} + /// PowerEntry represents a single entry in the PowerTable, including ActorID and its StoragePower and PubKey. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Eq, PartialEq)] +#[serde(rename_all = "PascalCase")] pub struct F3PowerEntry { #[serde(rename = "ID")] pub id: ActorID, #[schemars(with = "String")] - #[serde(rename = "Power", with = "crate::lotus_json::stringify")] + #[serde(with = "crate::lotus_json::stringify")] pub power: num::BigInt, #[schemars(with = "String")] - #[serde(rename = "PubKey", with = "base64_standard")] + #[serde(with = "base64_standard")] pub pub_key: Vec, } lotus_json_with_self!(F3PowerEntry); @@ -245,6 +268,81 @@ pub struct F3Manifest { } lotus_json_with_self!(F3Manifest); +#[derive(PartialEq, Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "PascalCase")] +pub struct SupplementalData { + pub commitments: [u8; 32], + #[schemars(with = "String")] + #[serde(with = "crate::lotus_json")] + pub power_table: Cid, +} +lotus_json_with_self!(SupplementalData); + +#[derive(PartialEq, Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "PascalCase")] +pub struct PowerTableDelta { + #[serde(rename = "ParticipantID")] + pub participant_id: ActorID, + #[schemars(with = "String")] + #[serde(with = "crate::lotus_json::stringify")] + pub power_delta: num::BigInt, + #[schemars(with = "String")] + #[serde(with = "crate::lotus_json")] + pub signing_key: Vec, +} +lotus_json_with_self!(PowerTableDelta); + +#[derive(PartialEq, Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "PascalCase")] +pub struct FinalityCertificate { + #[serde(rename = "GPBFTInstance")] + pub instance: u64, + #[schemars(with = "LotusJson>")] + #[serde(rename = "ECChain", with = "crate::lotus_json")] + pub ec_chain: Vec, + #[schemars(with = "LotusJson")] + #[serde(with = "crate::lotus_json")] + pub supplemental_data: SupplementalData, + #[schemars(with = "Vec")] + #[serde(with = "crate::lotus_json")] + pub signers: BitField, + #[schemars(with = "String")] + #[serde(with = "crate::lotus_json")] + pub signature: Vec, + #[schemars(with = "LotusJson>")] + #[serde(with = "crate::lotus_json")] + pub power_table_delta: Vec, +} +lotus_json_with_self!(FinalityCertificate); + +impl FinalityCertificate { + pub fn power_table_delta_string(&self) -> String { + let total_diff = self + .power_table_delta + .iter() + .map(|i| i.power_delta.clone()) + .fold(num::BigInt::zero(), |acc, x| acc + x); + if total_diff.is_zero() { + "None".into() + } else { + format!( + "Total of {total_diff} storage power across {} miner(s).", + self.power_table_delta.len() + ) + } + } + + pub fn chain_base(&self) -> &ECTipSet { + // Switch to NonEmpty and drop `.expect` + self.ec_chain.first().expect("ec_chain is empty") + } + + pub fn chain_head(&self) -> &ECTipSet { + // Switch to NonEmpty and drop `.expect` + self.ec_chain.last().expect("ec_chain is empty") + } +} + #[derive(PartialEq, Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "PascalCase")] pub struct F3Participant { @@ -624,4 +722,150 @@ mod tests { let serialized = serde_json::to_value(manifest.clone()).unwrap(); assert_eq!(lotus_json, serialized); } + + #[test] + fn f3_certificate_serde_roundtrip() { + // lotus f3 c get --output json 6204 + let lotus_json = serde_json::json!({ + "GPBFTInstance": 6204, + "ECChain": [ + { + "Epoch": 2088927, + "Key": "AXGg5AIg1NBjOnFimwUueRXQQzvPbHZO6vXbvqNA1gcomlVrq5MBcaDkAiCaOt71j85kjjq3SZF0NQq03tauEW3iwscIr4Qw0wna+g==", + "PowerTable": { + "/": "bafy2bzaceazjn2promafvtkaquebfgc3xvhoavdbxwns4i54ilgnzch7pkgua" + }, + "Commitments": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "Epoch": 2088928, + "Key": "AXGg5AIgFn9g3q/ATrgWiWzUYZLrtN/POrkNWFPmUShj/MDqZ5IBcaDkAiACwpEW4PvUCOIsZRaYhF6W+L1bgGd2TUFLOkATNxvuGgFxoOQCILlKPpFgMxXYFcq2HslyxzBN9ZZ6iPrPSBI2uwT4tUAvAXGg5AIgwYDZ217HUZ6nGnm6fnNd5lhep2C02mSYkkjJPf5pOig=", + "PowerTable": { + "/": "bafy2bzaceazjn2promafvtkaquebfgc3xvhoavdbxwns4i54ilgnzch7pkgua" + }, + "Commitments": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + } + ], + "SupplementalData": { + "Commitments": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "PowerTable": { + "/": "bafy2bzaceazjn2promafvtkaquebfgc3xvhoavdbxwns4i54ilgnzch7pkgua" + } + }, + "Signers": [ + 0, + 3 + ], + "Signature": "uYtvw/NWm2jKQj+d99UAG4aiPnpAMSrwAWIusv0XkjsOYYR0fyU4nUM++cAQGO47E2/J8WSDjstLgL+yMVAFC+Tgao4o9ILXIlhqhxObnNZ/Ehanajthif9SaRe1AO69", + "PowerTableDelta": [ + { + "ParticipantID": 3782, + "PowerDelta": "76347338653696", + "SigningKey": "lXSMTNEVmIdVxJV4clmW35jrlsBEfytNUGTWVih2dFlQ1k/7QQttsUGzpD5JoNaQ" + } + ] + }); + let cert: FinalityCertificate = serde_json::from_value(lotus_json.clone()).unwrap(); + let serialized = serde_json::to_value(cert.clone()).unwrap(); + assert_eq!(lotus_json, serialized); + } } diff --git a/templates/cli/f3/certificate.stpl b/templates/cli/f3/certificate.stpl new file mode 100644 index 000000000000..51defc733629 --- /dev/null +++ b/templates/cli/f3/certificate.stpl @@ -0,0 +1,14 @@ +<% use itertools::Itertools as _; +const MAX_TIPSETS: usize = 10; +const MAX_TIPSET_KEYS: usize = 2; +%>Instance: <%= cert.instance %> +Power Table: + Next: <%= cert.supplemental_data.power_table.to_string() %> + Delta: <%= cert.power_table_delta_string() %> +Finalized Chain: + Length: <%= cert.ec_chain.len() %> + Epochs: <%= cert.chain_base().epoch %>-<%= cert.chain_head().epoch %> + Chain: +<% for (i, ts) in cert.ec_chain.iter().take(MAX_TIPSETS).enumerate() { let tsk = ts.ec_tipset_key(); %> <% if i + 1 == cert.ec_chain.len() { %>└──<% } else { %>├──<% } %><%= ts.epoch %> (length: <%= tsk.len() %>): [<%= tsk.iter().take(MAX_TIPSET_KEYS).map(|i| i.to_string()).join(", ") %><% if tsk.len() > MAX_TIPSET_KEYS { %>, ...<% } %>] +<% } %><% if cert.ec_chain.len() > MAX_TIPSETS { let n_remaining = cert.ec_chain.len() - MAX_TIPSETS; %> └──...omitted the remaining <%= n_remaining %> tipsets. +<% } %>Signed by <%= cert.signers.len() %> miner(s).