diff --git a/CHANGELOG.md b/CHANGELOG.md index c1110f75fda9..0e67df2131d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ ### Added +- [#6811](https://github.com/ChainSafe/forest/pull/6811): Added v2 RPC method `Filecoin.ChainGetTipSetFinalityStatus`. + - [#6710](https://github.com/ChainSafe/forest/pull/6710): Added support for f4 addresses in forest-wallet. ### Changed diff --git a/scripts/tests/api_compare/filter-list-gateway b/scripts/tests/api_compare/filter-list-gateway index df0e07e6b3bc..2fcc7b51517f 100644 --- a/scripts/tests/api_compare/filter-list-gateway +++ b/scripts/tests/api_compare/filter-list-gateway @@ -4,6 +4,7 @@ !Filecoin.AuthNew !Filecoin.BeaconGetEntry +!Filecoin.ChainGetTipSetFinalityStatus !Filecoin.ChainSetHead !Filecoin.ChainStatObj !Filecoin.ChainTipSetWeight diff --git a/src/chain/ec_finality/calculator/mod.rs b/src/chain/ec_finality/calculator/mod.rs index 2f21a6e2f60a..0f0c743c51e7 100644 --- a/src/chain/ec_finality/calculator/mod.rs +++ b/src/chain/ec_finality/calculator/mod.rs @@ -19,27 +19,26 @@ mod skellam; mod tests; use anyhow::Context as _; +use std::sync::LazyLock; // `BISECT_LOW` and `BISECT_HIGH` define the search range for the bisect algorithm // that finds the epoch depth at which the finality guarantee is met. A low // bound of 3 avoids evaluating trivially shallow depths; a high bound of -// 200 accommodates degraded chains that take longer to finalize. -#[allow(dead_code)] +// 450 accommodates degraded chains that take longer to finalize. pub const BISECT_LOW: i64 = 3; -#[allow(dead_code)] -pub const BISECT_HIGH: i64 = 200; +pub const BISECT_HIGH: i64 = 450; // the Filecoin mainnet expected block production rate. -#[allow(dead_code)] pub const DEFAULT_BLOCKS_PER_EPOCH: f64 = 5.0; // the standard Filecoin security assumption for adversarial mining power. -#[allow(dead_code)] pub const DEFAULT_BYZANTINE_FRACTION: f64 = 0.3; // the target reorg probability as a power of 2. 2^-30 (~one-in-a-billion) is the standard Filecoin finality guarantee. -#[allow(dead_code)] -pub const DEFAULT_SAFETY_EXPONENT: i64 = -30; +pub const DEFAULT_SAFETY_EXPONENT: i32 = -30; + +pub static DEFAULT_GUARANTEE: LazyLock = + LazyLock::new(|| f64::powi(2., DEFAULT_SAFETY_EXPONENT)); /// Computes the upper-bound probability that a confirmed /// tipset could be reorganized out of the canonical chain. This is a port diff --git a/src/chain/ec_finality/calculator/tests.rs b/src/chain/ec_finality/calculator/tests.rs index 2d394699e19e..711acf31ba13 100644 --- a/src/chain/ec_finality/calculator/tests.rs +++ b/src/chain/ec_finality/calculator/tests.rs @@ -93,7 +93,6 @@ fn test_calc_validator_prob_healthy_chain() { let chain = vec![5; 905]; let current_epoch = chain.len() as i64 - 1; - let guarantee = 2_f64.powi(-30); let prob30 = calc_validator_prob( &chain, @@ -106,7 +105,7 @@ fn test_calc_validator_prob_healthy_chain() { .unwrap(); assert_lt!( prob30, - guarantee, + *DEFAULT_GUARANTEE, "healthy chain at depth 30 should be below 2^-30" ); @@ -132,7 +131,6 @@ fn test_calc_validator_prob_degraded_chain() { // finality than a healthy chain at the same depth let chain = vec![2; 905]; let current_epoch = chain.len() as i64 - 1; - let guarantee = 2_f64.powi(-30); let prob30 = calc_validator_prob( &chain, @@ -145,7 +143,7 @@ fn test_calc_validator_prob_degraded_chain() { .unwrap(); assert_ge!( prob30, - guarantee, + *DEFAULT_GUARANTEE, "degraded chain at depth 30 should NOT achieve 2^-30" ); } @@ -153,14 +151,13 @@ fn test_calc_validator_prob_degraded_chain() { #[test] fn test_find_threshold_depth_healthy_chain() { let chain = vec![5; 905]; - let guarantee = 2_f64.powi(-30); let depth = find_threshold_depth( &chain, TEST_FINALITY, DEFAULT_BLOCKS_PER_EPOCH, DEFAULT_BYZANTINE_FRACTION, - guarantee, + *DEFAULT_GUARANTEE, ) .unwrap(); assert_gt!(depth, 0, "healthy chain should find a threshold"); @@ -173,17 +170,16 @@ fn test_find_threshold_depth_healthy_chain() { #[test] fn test_find_threshold_depth_degraded_chain() { - // All-2s chain is too degraded to achieve 2^-30 within the bisect - // search range (BisectHigh=200), so threshold is not found - let chain = vec![2; 905]; - let guarantee = 2_f64.powi(-30); + // All-1s chain is too degraded to achieve 2^-30 within the bisect + // search range (BisectHigh=450), so threshold is not found + let chain = vec![1; 905]; let depth = find_threshold_depth( &chain, TEST_FINALITY, DEFAULT_BLOCKS_PER_EPOCH, DEFAULT_BYZANTINE_FRACTION, - guarantee, + *DEFAULT_GUARANTEE, ) .unwrap(); assert_eq!( @@ -197,14 +193,13 @@ fn test_find_threshold_depth_mildly_degraded_chain() { // All-3s chain is degraded but should still find a threshold, // just deeper than a healthy chain let chain = vec![3; 905]; - let guarantee = 2_f64.powi(-30); let depth = find_threshold_depth( &chain, TEST_FINALITY, DEFAULT_BLOCKS_PER_EPOCH, DEFAULT_BYZANTINE_FRACTION, - guarantee, + *DEFAULT_GUARANTEE, ) .unwrap(); assert_gt!( @@ -226,13 +221,12 @@ fn test_find_threshold_depth_mildly_degraded_chain() { #[case(1)] fn test_find_threshold_depth_too_short_chain(#[case] chain_len: usize) { let chain = vec![3; chain_len]; - let guarantee = 2_f64.powi(-30); let depth = find_threshold_depth( &chain, TEST_FINALITY, DEFAULT_BLOCKS_PER_EPOCH, DEFAULT_BYZANTINE_FRACTION, - guarantee, + *DEFAULT_GUARANTEE, ) .unwrap(); assert_eq!(depth, -1, "input chain is too short"); diff --git a/src/chain/ec_finality/mod.rs b/src/chain/ec_finality/mod.rs index d4b92acfefdd..607d69471df2 100644 --- a/src/chain/ec_finality/mod.rs +++ b/src/chain/ec_finality/mod.rs @@ -1,4 +1,4 @@ // Copyright 2019-2026 ChainSafe Systems // SPDX-License-Identifier: Apache-2.0, MIT -mod calculator; +pub mod calculator; diff --git a/src/chain/mod.rs b/src/chain/mod.rs index a2e3d56cdda4..f53751fffd3f 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -1,7 +1,7 @@ // Copyright 2019-2026 ChainSafe Systems // SPDX-License-Identifier: Apache-2.0, MIT -mod ec_finality; +pub mod ec_finality; mod snapshot_format; pub mod store; #[cfg(test)] diff --git a/src/rpc/methods/chain.rs b/src/rpc/methods/chain.rs index 620ab00869a0..0e619d06c6d9 100644 --- a/src/rpc/methods/chain.rs +++ b/src/rpc/methods/chain.rs @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0, MIT pub mod types; -use enumflags2::{BitFlags, make_bitflags}; use types::*; #[cfg(test)] @@ -34,10 +33,12 @@ use crate::utils::io::VoidAsyncWriter; use crate::utils::misc::env::is_env_truthy; use anyhow::{Context as _, Result}; use cid::Cid; +use enumflags2::{BitFlags, make_bitflags}; use fvm_ipld_blockstore::Blockstore; use fvm_ipld_encoding::{CborStore, RawBytes}; use hex::ToHex; use ipld_core::ipld::Ipld; +use itertools::Itertools as _; use jsonrpsee::types::Params; use jsonrpsee::types::error::ErrorObjectOwned; use num::BigInt; @@ -298,7 +299,7 @@ impl RpcMethod<1> for ChainGetParentReceipts { gas_used: r.gas_used(), events_root: r.events_root(), }) - .collect(); + .collect_vec(); Ok(receipts) } @@ -1138,6 +1139,136 @@ impl RpcMethod<1> for ChainGetTipSetV2 { } } +pub enum ChainGetTipSetFinalityStatus {} + +impl ChainGetTipSetFinalityStatus { + pub fn get_finality_status(ctx: &Ctx) -> ChainFinalityStatus { + let head = ctx.chain_store().heaviest_tipset(); + let (ec_finality_threshold_depth, ec_finalized_tip_set) = + Self::get_ec_finality_threshold_depth_and_tipset_with_cache(ctx, head.clone()); + let f3_finalized_tip_set = ctx.chain_store().f3_finalized_tipset(); + let finalized_tip_set = match (&ec_finalized_tip_set, &f3_finalized_tip_set) { + (Some(ec), Some(f3)) => { + if ec.epoch() >= f3.epoch() { + Some(ec.clone()) + } else { + Some(f3.clone()) + } + } + (Some(ec), None) => Some(ec.clone()), + (None, Some(f3)) => Some(f3.clone()), + (None, None) => None, + }; + ChainFinalityStatus { + ec_finality_threshold_depth, + ec_finalized_tip_set, + f3_finalized_tip_set, + finalized_tip_set, + head, + } + } + + fn get_ec_finality_threshold_depth_and_tipset_with_cache( + ctx: &Ctx, + head: Tipset, + ) -> (i64, Option) { + static CACHE: parking_lot::Mutex)>> = + parking_lot::Mutex::new(None); + let mut cache = CACHE.lock(); + if let Some((cached_head, cached_threshold, cached_tipset)) = &*cache + && cached_head == &head + { + (*cached_threshold, cached_tipset.clone()) + } else { + let (threshold, tipset) = + Self::get_ec_finality_threshold_depth_and_tipset(ctx, head.clone()); + *cache = Some((head, threshold, tipset.clone())); + (threshold, tipset) + } + } + + fn get_ec_finality_threshold_depth_and_tipset( + ctx: &Ctx, + head: Tipset, + ) -> (i64, Option) { + use crate::chain::ec_finality::calculator::{ + DEFAULT_BLOCKS_PER_EPOCH, DEFAULT_BYZANTINE_FRACTION, DEFAULT_GUARANTEE, + find_threshold_depth, + }; + + const FINALITY_CHAIN_EXTRA_EPOCHS: usize = 5; + + let finality = ctx.chain_config().policy.chain_finality; + let chain_len = finality as usize + FINALITY_CHAIN_EXTRA_EPOCHS; + let mut chain = Vec::with_capacity(chain_len); + let mut ts = head.clone(); + while chain.len() < chain_len { + chain.push(ts.len() as i64); + if let Ok(parent) = ctx.chain_index().load_required_tipset(ts.parents()) { + // insert 0 for null rounds + if let Ok(n_null_tipsets_to_pad) = usize::try_from(ts.epoch() - parent.epoch() - 1) + && n_null_tipsets_to_pad > 0 + { + let target_len = + (chain.len().saturating_add(n_null_tipsets_to_pad)).min(chain_len); + chain.resize(target_len, 0); + } + ts = parent; + } else { + break; + } + } + // Reverse to chronological order (oldest first). + chain.reverse(); + let depth = match find_threshold_depth( + &chain, + finality, + DEFAULT_BLOCKS_PER_EPOCH, + DEFAULT_BYZANTINE_FRACTION, + *DEFAULT_GUARANTEE, + ) { + Ok(threshold) => threshold, + Err(e) => { + tracing::error!( + "Failed to calculate EC finality threshold depth: {e:#}, chain: {chain:?}" + ); + -1 + } + }; + let finalized = if depth >= 0 + && let Ok(ts) = ctx.chain_index().tipset_by_height( + (head.epoch() - depth).max(0), + head, + ResolveNullTipset::TakeOlder, + ) { + Some(ts.clone()) + } else { + None + }; + (depth, finalized) + } +} + +impl RpcMethod<0> for ChainGetTipSetFinalityStatus { + const NAME: &'static str = "Filecoin.ChainGetTipSetFinalityStatus"; + const PARAM_NAMES: [&'static str; 0] = []; + const API_PATHS: BitFlags = make_bitflags!(ApiPaths::{ V2 }); + const PERMISSION: Permission = Permission::Read; + const DESCRIPTION: Option<&'static str> = + Some("Returns a breakdown of how the node is currently determining finality."); + + type Params = (); + type Ok = ChainFinalityStatus; + + async fn handle( + ctx: Ctx, + (): Self::Params, + _: &http::Extensions, + ) -> Result { + Ok(Self::get_finality_status(&ctx)) + } +} + pub enum ChainSetHead {} impl RpcMethod<1> for ChainSetHead { const NAME: &'static str = "Filecoin.ChainSetHead"; @@ -1541,7 +1672,7 @@ impl PathChanges { .into_iter() .map(PathChange::Revert) .chain(applies.into_iter().map(PathChange::Apply)) - .collect() + .collect_vec() } } @@ -1582,7 +1713,6 @@ mod tests { networks::{self, ChainConfig}, }; use PathChange::{Apply, Revert}; - use itertools::Itertools as _; use std::sync::Arc; #[test] diff --git a/src/rpc/methods/chain/types.rs b/src/rpc/methods/chain/types.rs index 54b728981ea0..eb0f2b038de0 100644 --- a/src/rpc/methods/chain/types.rs +++ b/src/rpc/methods/chain/types.rs @@ -10,3 +10,41 @@ pub struct ObjStat { pub links: usize, } lotus_json_with_self!(ObjStat); + +/// Describes how the node is currently determining finality, +/// combining probabilistic EC finality (based on observed chain health) with +/// F3 fast finality when available. +#[derive(Serialize, Deserialize, JsonSchema, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ChainFinalityStatus { + /// The shallowest epoch depth at which the + /// probability of a chain reorganization drops below 2^-30 (~one in a + /// billion). A value of -1 indicates the threshold was not met within the + /// search range, which suggests degraded chain health. + pub ec_finality_threshold_depth: i64, + + /// The most recent tipset where the reorg probability + /// is below 2^-30, based on observed block production. [`None`] if the + /// threshold is not met. + #[serde(with = "crate::lotus_json")] + #[schemars(with = "LotusJson>")] + pub ec_finalized_tip_set: Option, + + /// The tipset finalized by F3 (Fast Finality), if F3 + /// is running and has issued a certificate. [`None`] if F3 is not available. + #[serde(with = "crate::lotus_json")] + #[schemars(with = "LotusJson>")] + pub f3_finalized_tip_set: Option, + + /// The overall finalized tipset used by the node, + /// taking the most recent of F3 and EC calculator results. + #[serde(with = "crate::lotus_json")] + #[schemars(with = "LotusJson>")] + pub finalized_tip_set: Option, + + /// The current chain head used for the computation. + #[serde(with = "crate::lotus_json")] + #[schemars(with = "LotusJson")] + pub head: Tipset, +} +lotus_json_with_self!(ChainFinalityStatus); diff --git a/src/rpc/mod.rs b/src/rpc/mod.rs index aa3ea6351279..749deb27b814 100644 --- a/src/rpc/mod.rs +++ b/src/rpc/mod.rs @@ -79,6 +79,7 @@ macro_rules! for_each_rpc_method { $callback!($crate::rpc::chain::ChainGetPath); $callback!($crate::rpc::chain::ChainGetTipSet); $callback!($crate::rpc::chain::ChainGetTipSetV2); + $callback!($crate::rpc::chain::ChainGetTipSetFinalityStatus); $callback!($crate::rpc::chain::ChainGetTipSetAfterHeight); $callback!($crate::rpc::chain::ChainGetTipSetByHeight); $callback!($crate::rpc::chain::ChainHasObj); diff --git a/src/rpc/snapshots/forest__rpc__tests__rpc__v2.snap b/src/rpc/snapshots/forest__rpc__tests__rpc__v2.snap index bc81f131eccf..6b6c55ffcaa0 100644 --- a/src/rpc/snapshots/forest__rpc__tests__rpc__v2.snap +++ b/src/rpc/snapshots/forest__rpc__tests__rpc__v2.snap @@ -20,6 +20,15 @@ methods: schema: $ref: "#/components/schemas/Tipset" paramStructure: by-position + - name: Filecoin.ChainGetTipSetFinalityStatus + description: Returns a breakdown of how the node is currently determining finality. + params: [] + result: + name: Filecoin.ChainGetTipSetFinalityStatus.Result + required: true + schema: + $ref: "#/components/schemas/ChainFinalityStatus" + paramStructure: by-position - name: Forest.ChainExport params: - name: params @@ -1848,6 +1857,32 @@ components: - $ref: "#/components/schemas/EthInt64" Bloom: type: string + ChainFinalityStatus: + description: "Describes how the node is currently determining finality,\ncombining probabilistic EC finality (based on observed chain health) with\nF3 fast finality when available." + type: object + properties: + ecFinalityThresholdDepth: + description: "The shallowest epoch depth at which the\nprobability of a chain reorganization drops below 2^-30 (~one in a\nbillion). A value of -1 indicates the threshold was not met within the\nsearch range, which suggests degraded chain health." + type: integer + format: int64 + ecFinalizedTipSet: + description: "The most recent tipset where the reorg probability\nis below 2^-30, based on observed block production. [`None`] if the\nthreshold is not met." + $ref: "#/components/schemas/Nullable_Tipset" + f3FinalizedTipSet: + description: "The tipset finalized by F3 (Fast Finality), if F3\nis running and has issued a certificate. [`None`] if F3 is not available." + $ref: "#/components/schemas/Nullable_Tipset" + finalizedTipSet: + description: "The overall finalized tipset used by the node,\ntaking the most recent of F3 and EC calculator results." + $ref: "#/components/schemas/Nullable_Tipset" + head: + description: The current chain head used for the computation. + $ref: "#/components/schemas/Tipset" + required: + - ecFinalityThresholdDepth + - ecFinalizedTipSet + - f3FinalizedTipSet + - finalizedTipSet + - head ChangedType: description: Represents a changed value with before and after states. type: object @@ -2725,6 +2760,10 @@ components: anyOf: - $ref: "#/components/schemas/Ticket" - type: "null" + Nullable_Tipset: + anyOf: + - $ref: "#/components/schemas/Tipset" + - type: "null" PoStProof: type: object properties: diff --git a/src/tool/subcommands/api_cmd/api_compare_tests.rs b/src/tool/subcommands/api_cmd/api_compare_tests.rs index a8ea39111a88..da5a70f63fee 100644 --- a/src/tool/subcommands/api_cmd/api_compare_tests.rs +++ b/src/tool/subcommands/api_cmd/api_compare_tests.rs @@ -477,10 +477,19 @@ fn common_tests() -> Vec { ] } -fn chain_tests() -> Vec { +fn chain_tests(offline: bool) -> Vec { vec![ - RpcTest::basic(ChainHead::request(()).unwrap()), RpcTest::identity(ChainGetGenesis::request(()).unwrap()), + if offline { + RpcTest::basic(ChainHead::request(()).unwrap()) + } else { + RpcTest::identity(ChainHead::request(()).unwrap()) + }, + if offline { + RpcTest::basic(ChainGetTipSetFinalityStatus::request(()).unwrap()) + } else { + RpcTest::identity(ChainGetTipSetFinalityStatus::request(()).unwrap()) + }, ] } @@ -2512,7 +2521,7 @@ pub(super) async fn create_tests( let mut tests = vec![]; tests.extend(auth_tests()?); tests.extend(common_tests()); - tests.extend(chain_tests()); + tests.extend(chain_tests(offline)); tests.extend(mpool_tests()); tests.extend(net_tests()); tests.extend(node_tests()); diff --git a/src/tool/subcommands/api_cmd/test_snapshots.txt b/src/tool/subcommands/api_cmd/test_snapshots.txt index ef9ffed5986b..a1ae73b00cef 100644 --- a/src/tool/subcommands/api_cmd/test_snapshots.txt +++ b/src/tool/subcommands/api_cmd/test_snapshots.txt @@ -18,6 +18,7 @@ filecoin_chaingettipset_key_1762420743419725.rpcsnap.json.zst filecoin_chaingettipset_tag_1762420743420262.rpcsnap.json.zst filecoin_chaingettipsetafterheight_1736937942817771.rpcsnap.json.zst filecoin_chaingettipsetbyheight_1741271398549509.rpcsnap.json.zst +filecoin_chaingettipsetfinalitystatus_1774798719525307.rpcsnap.json.zst filecoin_chainhasobj_1736937942818251.rpcsnap.json.zst filecoin_chainhead_1741269640489512.rpcsnap.json.zst filecoin_chainreadobj_1736937942818676.rpcsnap.json.zst