diff --git a/src/blocks/checkpoints.rs b/src/blocks/checkpoints.rs new file mode 100644 index 000000000000..64333ea44ecb --- /dev/null +++ b/src/blocks/checkpoints.rs @@ -0,0 +1,93 @@ +// Copyright 2019-2026 ChainSafe Systems +// SPDX-License-Identifier: Apache-2.0, MIT + +//! Provides utilities for efficiently locating the genesis block and known checkpoints +//! in the Filecoin blockchain by leveraging a list of precomputed, hash-chained block CIDs. +//! This avoids scanning millions of epochs, significantly speeding up chain traversal. + +use crate::{ + blocks::{CachingBlockHeader, Tipset}, + networks::NetworkChain, + shim::clock::ChainEpoch, +}; +use ahash::HashMap; +use anyhow::Context as _; +use cid::Cid; +use fvm_ipld_blockstore::Blockstore; +use itertools::Itertools; +use serde::{Deserialize, Serialize}; +use serde_with::{DisplayFromStr, serde_as}; +use std::sync::{LazyLock, OnceLock}; + +/// Holds mappings from chain epochs to block CIDs for each network. +#[serde_as] +#[derive(Serialize, Deserialize)] +pub struct KnownBlocks { + #[serde_as(as = "HashMap<_, DisplayFromStr>")] + pub calibnet: HashMap, + #[serde_as(as = "HashMap<_, DisplayFromStr>")] + pub mainnet: HashMap, +} + +/// Lazily loaded static instance of `KnownBlocks` from YAML. +/// Caches (`OnceLock`) are used to avoid recomputing known tipsets. +pub static KNOWN_BLOCKS: LazyLock = LazyLock::new(|| { + serde_yaml::from_str(include_str!("../../build/known_blocks.yaml")).expect("infallible") +}); + +/// Returns a cached, ascending-epoch list of known [`Tipset`]s for the given network. +pub fn known_tipsets( + bs: &impl Blockstore, + network: &NetworkChain, +) -> anyhow::Result<&'static Vec> { + static CACHE_CALIBNET: OnceLock> = OnceLock::new(); + static CACHE_MAINNET: OnceLock> = OnceLock::new(); + let (cache, known_blocks) = match network { + NetworkChain::Calibnet => (&CACHE_CALIBNET, &KNOWN_BLOCKS.calibnet), + NetworkChain::Mainnet => (&CACHE_MAINNET, &KNOWN_BLOCKS.mainnet), + _ => anyhow::bail!("unsupported network {network}"), + }; + if let Some(v) = cache.get() { + Ok(v) + } else { + let tipsets = known_blocks_to_known_tipsets(bs, known_blocks)?; + _ = cache.set(tipsets); + cache.get().context("infallible") + } +} + +fn known_blocks_to_known_tipsets( + bs: &impl Blockstore, + blocks: &HashMap, +) -> anyhow::Result> { + let mut tipsets: Vec = blocks + .values() + .map(|&b| block_cid_to_required_parent_tipset(bs, b)) + .try_collect()?; + tipsets.sort_by_key(|ts| ts.epoch()); + Ok(tipsets) +} + +fn block_cid_to_parent_tipset(bs: &impl Blockstore, block: Cid) -> anyhow::Result> { + if let Some(block) = CachingBlockHeader::load(bs, block)? { + Tipset::load(bs, &block.parents) + } else { + Ok(None) + } +} + +fn block_cid_to_required_parent_tipset(bs: &impl Blockstore, block: Cid) -> anyhow::Result { + block_cid_to_parent_tipset(bs, block)? + .with_context(|| format!("failed to load parent tipset of block {block}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_known_blocks() { + assert!(!KNOWN_BLOCKS.calibnet.is_empty()); + assert!(!KNOWN_BLOCKS.mainnet.is_empty()); + } +} diff --git a/src/blocks/mod.rs b/src/blocks/mod.rs index c3211be3bc02..9186beab9eec 100644 --- a/src/blocks/mod.rs +++ b/src/blocks/mod.rs @@ -6,6 +6,7 @@ use thiserror::Error; mod block; #[cfg(test)] mod chain4u; +pub mod checkpoints; mod election_proof; mod gossip_block; mod header; diff --git a/src/blocks/tipset.rs b/src/blocks/tipset.rs index ecda41502ddf..e7c2e910ee1d 100644 --- a/src/blocks/tipset.rs +++ b/src/blocks/tipset.rs @@ -19,7 +19,6 @@ use crate::{ multihash::MultihashCode, }, }; -use ahash::HashMap; use anyhow::Context as _; use cid::Cid; use fvm_ipld_blockstore::Blockstore; @@ -412,29 +411,20 @@ impl Tipset { /// Fetch the genesis block header for a given tipset. pub fn genesis(&self, store: &impl Blockstore) -> anyhow::Result { - // Scanning through millions of epochs to find the genesis is quite - // slow. Let's use a list of known blocks to short-circuit the search. - // The blocks are hash-chained together and known blocks are guaranteed - // to have a known genesis. - #[derive(Serialize, Deserialize)] - struct KnownHeaders { - calibnet: HashMap, - mainnet: HashMap, - } - - static KNOWN_HEADERS: OnceLock = OnceLock::new(); - let headers = KNOWN_HEADERS.get_or_init(|| { - serde_yaml::from_str(include_str!("../../build/known_blocks.yaml")).unwrap() - }); - for tipset in self.clone().chain(store) { // Search for known calibnet and mainnet blocks for (genesis_cid, known_blocks) in [ - (*calibnet::GENESIS_CID, &headers.calibnet), - (*mainnet::GENESIS_CID, &headers.mainnet), + ( + *calibnet::GENESIS_CID, + &super::checkpoints::KNOWN_BLOCKS.calibnet, + ), + ( + *mainnet::GENESIS_CID, + &super::checkpoints::KNOWN_BLOCKS.mainnet, + ), ] { if let Some(known_block_cid) = known_blocks.get(&tipset.epoch()) - && known_block_cid == &tipset.min_ticket_block().cid().to_string() + && known_block_cid == tipset.min_ticket_block().cid() { return store .get_cbor(&genesis_cid)? diff --git a/src/chain/store/chain_store.rs b/src/chain/store/chain_store.rs index 4869130b3627..40a3f253a4c6 100644 --- a/src/chain/store/chain_store.rs +++ b/src/chain/store/chain_store.rs @@ -6,7 +6,6 @@ use super::{ index::{ChainIndex, ResolveNullTipset}, tipset_tracker::TipsetTracker, }; -use crate::libp2p_bitswap::{BitswapStoreRead, BitswapStoreReadWrite}; use crate::message::{ChainMessage, Message as MessageTrait, SignedMessage}; use crate::networks::{ChainConfig, Height}; use crate::rpc::eth::{eth_tx_from_signed_eth_message, types::EthHash}; @@ -17,6 +16,10 @@ use crate::shim::{ }; use crate::state_manager::StateOutput; use crate::utils::db::{BlockstoreExt, CborStoreExt}; +use crate::{ + blocks::checkpoints::known_tipsets, + libp2p_bitswap::{BitswapStoreRead, BitswapStoreReadWrite}, +}; use crate::{ blocks::{CachingBlockHeader, Tipset, TipsetKey, TxMeta}, db::HeaviestTipsetKeyProvider, @@ -242,6 +245,41 @@ where Tipset::from(self.genesis_block_header()) } + /// Find tipset at epoch `to` in the chain + pub fn tipset_by_height( + &self, + to: ChainEpoch, + from: Option, + resolve: ResolveNullTipset, + ) -> Result { + let head = self.heaviest_tipset(); + // Fail fast when `to` is too large + if to > head.epoch() { + return Err(Error::Other(format!( + "looking for tipset with height greater than the current chain head, req: {to}, head: {}", + head.epoch() + ))); + } + + let best_known_from = if let Ok(known_tipsets) = + known_tipsets(self.blockstore(), &self.chain_config().network) + && let Some(ts) = known_tipsets.iter().find(|ts| ts.epoch() > to).cloned() + { + Some(ts) + } else { + None + }; + let from = match (from, best_known_from) { + // prefer `b` when `b` is closer to `to` + (Some(a), Some(b)) if b.epoch() < a.epoch() => b, + // prefer `a` when presents + (Some(a), _) => a, + (None, Some(b)) => b, + // fallback to chain head + (None, None) => head, + }; + self.chain_index().tipset_by_height(to, from, resolve) + } /// Subscribes head changes. pub fn subscribe_head_changes(&self) -> broadcast::Receiver { self.head_changes_tx.subscribe() @@ -278,22 +316,10 @@ where } pub fn load_child_tipset(&self, ts: &Tipset) -> Result { - let head = self.heaviest_tipset(); - if head.parents() == ts.key() { - Ok(head) - } else if head.epoch() > ts.epoch() { - let maybe_child = self.chain_index().tipset_by_height( - ts.epoch() + 1, - head, - ResolveNullTipset::TakeNewer, - )?; - if maybe_child.parents() == ts.key() { - Ok(maybe_child) - } else { - Err(Error::NotFound( - format!("child of tipset@{}", ts.epoch()).into(), - )) - } + let maybe_child = + self.tipset_by_height(ts.epoch() + 1, None, ResolveNullTipset::TakeNewer)?; + if maybe_child.parents() == ts.key() { + Ok(maybe_child) } else { Err(Error::NotFound( format!("child of tipset@{}", ts.epoch()).into(), diff --git a/src/dev/subcommands/state_cmd.rs b/src/dev/subcommands/state_cmd.rs index d5f97de1e70a..22b777833ba0 100644 --- a/src/dev/subcommands/state_cmd.rs +++ b/src/dev/subcommands/state_cmd.rs @@ -85,11 +85,7 @@ impl ComputeCommand { let (ts, ts_next) = { // We don't want to track all entries that are visited by `tipset_by_height` db.pause_tracking(); - let ts = chain_store.chain_index().tipset_by_height( - epoch, - chain_store.heaviest_tipset(), - ResolveNullTipset::TakeOlder, - )?; + let ts = chain_store.tipset_by_height(epoch, None, ResolveNullTipset::TakeOlder)?; let ts_next = chain_store.load_child_tipset(&ts)?; db.resume_tracking(); SettingsStoreExt::write_obj( @@ -210,11 +206,7 @@ impl ValidateCommand { let ts = { // We don't want to track all entries that are visited by `tipset_by_height` db.pause_tracking(); - let ts = chain_store.chain_index().tipset_by_height( - epoch, - chain_store.heaviest_tipset(), - ResolveNullTipset::TakeOlder, - )?; + let ts = chain_store.tipset_by_height(epoch, None, ResolveNullTipset::TakeOlder)?; db.resume_tracking(); SettingsStoreExt::write_obj(&db.tracker, crate::db::setting_keys::HEAD_KEY, ts.key())?; // Only track the desired tipset diff --git a/src/rpc/methods/chain.rs b/src/rpc/methods/chain.rs index 35b9dfa1eac9..f90e55e77cdf 100644 --- a/src/rpc/methods/chain.rs +++ b/src/rpc/methods/chain.rs @@ -178,9 +178,9 @@ impl RpcMethod<0> for ChainGetFinalizedTipset { Err(_) => { // fallback to ec finality tracing::warn!("F3 finalization unavailable, falling back to EC finality"); - let ec_tipset = ctx.chain_index().tipset_by_height( + let ec_tipset = ctx.chain_store().tipset_by_height( ec_finality_epoch, - head, + None, ResolveNullTipset::TakeOlder, )?; Ok(ec_tipset) @@ -438,8 +438,8 @@ impl RpcMethod<1> for ForestChainExport { let head = ctx.chain_store().load_required_tipset_or_heaviest(&tsk)?; let start_ts = - ctx.chain_index() - .tipset_by_height(epoch, head, ResolveNullTipset::TakeOlder)?; + ctx.chain_store() + .tipset_by_height(epoch, Some(head), ResolveNullTipset::TakeOlder)?; let options = Some(ExportOptions { skip_checksum, @@ -629,10 +629,9 @@ impl RpcMethod<1> for ForestChainExportDiff { ); } - let head = ctx.chain_store().heaviest_tipset(); let start_ts = - ctx.chain_index() - .tipset_by_height(from, head, ResolveNullTipset::TakeOlder)?; + ctx.chain_store() + .tipset_by_height(from, None, ResolveNullTipset::TakeOlder)?; crate::tool::subcommands::archive_cmd::do_export( &ctx.store_owned(), @@ -929,9 +928,9 @@ impl RpcMethod<2> for ChainGetTipSetByHeight { let ts = ctx .chain_store() .load_required_tipset_or_heaviest(&tipset_key)?; - let tss = ctx - .chain_index() - .tipset_by_height(height, ts, ResolveNullTipset::TakeOlder)?; + let tss = + ctx.chain_store() + .tipset_by_height(height, Some(ts), ResolveNullTipset::TakeOlder)?; Ok(tss) } } @@ -959,9 +958,9 @@ impl RpcMethod<2> for ChainGetTipSetAfterHeight { let ts = ctx .chain_store() .load_required_tipset_or_heaviest(&tipset_key)?; - let tss = ctx - .chain_index() - .tipset_by_height(height, ts, ResolveNullTipset::TakeNewer)?; + let tss = + ctx.chain_store() + .tipset_by_height(height, Some(ts), ResolveNullTipset::TakeNewer)?; Ok(tss) } } @@ -1102,9 +1101,9 @@ impl ChainGetTipSetV2 { if finalized.epoch() >= safe_height { Ok(finalized) } else { - Ok(ctx.chain_index().tipset_by_height( + Ok(ctx.chain_store().tipset_by_height( safe_height, - head, + None, ResolveNullTipset::TakeOlder, )?) } @@ -1140,9 +1139,9 @@ impl ChainGetTipSetV2 { pub fn get_ec_finalized_tipset(ctx: &Ctx) -> anyhow::Result { let head = ctx.chain_store().heaviest_tipset(); let ec_finality_epoch = (head.epoch() - ctx.chain_config().policy.chain_finality).max(0); - Ok(ctx.chain_index().tipset_by_height( + Ok(ctx.chain_store().tipset_by_height( ec_finality_epoch, - head, + None, ResolveNullTipset::TakeOlder, )?) } @@ -1160,9 +1159,9 @@ impl ChainGetTipSetV2 { // Get tipset by height. if let Some(height) = &selector.height { let anchor = Self::get_tipset_by_anchor(ctx, height.anchor.as_ref()).await?; - let ts = ctx.chain_index().tipset_by_height( + let ts = ctx.chain_store().tipset_by_height( height.at, - anchor, + Some(anchor), height.resolve_null_tipset_policy(), )?; return Ok(ts); diff --git a/src/rpc/methods/eth.rs b/src/rpc/methods/eth.rs index 801486b68ffd..b253ca394cef 100644 --- a/src/rpc/methods/eth.rs +++ b/src/rpc/methods/eth.rs @@ -944,9 +944,7 @@ fn resolve_block_number_tipset( if height > head.epoch() - 1 { bail!("requested a future epoch (beyond \"latest\")"); } - Ok(chain - .chain_index() - .tipset_by_height(height, head, resolve)?) + Ok(chain.tipset_by_height(height, None, resolve)?) } fn resolve_block_hash_tipset( @@ -959,10 +957,7 @@ fn resolve_block_hash_tipset( // verify that the tipset is in the canonical chain if require_canonical { // walk up the current chain (our head) until we reach ts.epoch() - let walk_ts = - chain - .chain_index() - .tipset_by_height(ts.epoch(), chain.heaviest_tipset(), resolve)?; + let walk_ts = chain.tipset_by_height(ts.epoch(), None, resolve)?; // verify that it equals the expected tipset if walk_ts != ts { bail!("tipset is not canonical"); diff --git a/src/rpc/methods/eth/filter/mod.rs b/src/rpc/methods/eth/filter/mod.rs index b724553f77ab..7b82cf3744e5 100644 --- a/src/rpc/methods/eth/filter/mod.rs +++ b/src/rpc/methods/eth/filter/mod.rs @@ -397,9 +397,9 @@ impl EthEventHandler { *range.end() }; - let max_tipset = ctx.chain_index().tipset_by_height( + let max_tipset = ctx.chain_store().tipset_by_height( max_height, - ctx.chain_store().heaviest_tipset(), + None, ResolveNullTipset::TakeOlder, )?; for tipset in max_tipset diff --git a/src/rpc/methods/eth/tipset_resolver.rs b/src/rpc/methods/eth/tipset_resolver.rs index bccea571b9df..5bef066000c8 100644 --- a/src/rpc/methods/eth/tipset_resolver.rs +++ b/src/rpc/methods/eth/tipset_resolver.rs @@ -157,9 +157,9 @@ where pub fn get_ec_safe_tipset(&self) -> anyhow::Result { let head = self.ctx.chain_store().heaviest_tipset(); let safe_height = (head.epoch() - SAFE_HEIGHT_DISTANCE).max(0); - Ok(self.ctx.chain_index().tipset_by_height( + Ok(self.ctx.chain_store().tipset_by_height( safe_height, - head, + None, ResolveNullTipset::TakeOlder, )?) } @@ -171,9 +171,9 @@ where let head = self.ctx.chain_store().heaviest_tipset(); let ec_finality_epoch = (head.epoch() - self.ctx.chain_config().policy.chain_finality).max(0); - Ok(self.ctx.chain_index().tipset_by_height( + Ok(self.ctx.chain_store().tipset_by_height( ec_finality_epoch, - head, + None, ResolveNullTipset::TakeOlder, )?) } diff --git a/src/rpc/methods/f3.rs b/src/rpc/methods/f3.rs index 8991299606e9..6028f6e42d4a 100644 --- a/src/rpc/methods/f3.rs +++ b/src/rpc/methods/f3.rs @@ -89,11 +89,9 @@ impl RpcMethod<1> for GetTipsetByEpoch { (epoch,): Self::Params, _: &http::Extensions, ) -> Result { - let ts = ctx.chain_index().tipset_by_height( - epoch, - ctx.chain_store().heaviest_tipset(), - ResolveNullTipset::TakeOlder, - )?; + let ts = ctx + .chain_store() + .tipset_by_height(epoch, None, ResolveNullTipset::TakeOlder)?; Ok(ts.into()) } } diff --git a/src/rpc/methods/state.rs b/src/rpc/methods/state.rs index 8f0f191d8b3b..96ee79a037ce 100644 --- a/src/rpc/methods/state.rs +++ b/src/rpc/methods/state.rs @@ -1536,19 +1536,17 @@ impl RpcMethod<2> for ForestStateCompute { ) -> Result { let n_epochs = n_epochs.map(|n| n.get()).unwrap_or(1) as ChainEpoch; let to_epoch = from_epoch + n_epochs - 1; - let to_ts = ctx.chain_index().tipset_by_height( - to_epoch, - ctx.chain_store().heaviest_tipset(), - ResolveNullTipset::TakeOlder, - )?; + let to_ts = + ctx.chain_store() + .tipset_by_height(to_epoch, None, ResolveNullTipset::TakeOlder)?; let from_ts = if from_epoch >= to_ts.epoch() { // When `from_epoch` is a null epoch or `n_epochs` is 1, // `to_ts.epoch()` could be less than or equal to `from_epoch` to_ts.clone() } else { - ctx.chain_index().tipset_by_height( + ctx.chain_store().tipset_by_height( from_epoch, - to_ts.clone(), + Some(to_ts.clone()), ResolveNullTipset::TakeOlder, )? }; diff --git a/src/state_manager/mod.rs b/src/state_manager/mod.rs index a60b9aaa23d4..5ca64200b4db 100644 --- a/src/state_manager/mod.rs +++ b/src/state_manager/mod.rs @@ -205,9 +205,9 @@ where let bundle_metadata = state.get_actor_bundle_metadata()?; if expected_bundle_metadata != bundle_metadata { let current_epoch = head.epoch(); - let target_head = self.chain_index().tipset_by_height( + let target_head = self.chain_store().tipset_by_height( (expected_height_info.epoch - 1).max(0), - head, + None, ResolveNullTipset::TakeOlder, )?; let target_epoch = target_head.epoch(); @@ -1602,8 +1602,8 @@ where let heaviest = self.heaviest_tipset(); let heaviest_epoch = heaviest.epoch(); let end = self - .chain_index() - .tipset_by_height(*epochs.end(), heaviest, ResolveNullTipset::TakeOlder) + .chain_store() + .tipset_by_height(*epochs.end(), None, ResolveNullTipset::TakeOlder) .with_context(|| { format!( "couldn't get a tipset at height {} behind heaviest tipset at height {heaviest_epoch}", @@ -1775,7 +1775,6 @@ where /// This is a performance optimization to avoid recomputing the state and receipt root by checking the blockstore. /// It only checks the immediate next epoch, as this is the most likely place to find a child. fn try_lookup_state_from_next_tipset(&self, ts: &Tipset) -> Option { - // Check if the next tipset has the same parent if let Ok(child_ts) = self.chain_store().load_child_tipset(ts) { let state_root = *child_ts.parent_state(); let receipt_root = *child_ts.parent_message_receipts(); diff --git a/src/tool/subcommands/index_cmd.rs b/src/tool/subcommands/index_cmd.rs index 910e79b61abe..0d431a42b37c 100644 --- a/src/tool/subcommands/index_cmd.rs +++ b/src/tool/subcommands/index_cmd.rs @@ -101,11 +101,7 @@ impl IndexCommands { // ensure from epoch is not greater than head epoch. This can happen if the // assumed head is actually a null tipset. let from = std::cmp::min(*from, head_ts.epoch()); - chain_store.chain_index().tipset_by_height( - from, - head_ts, - ResolveNullTipset::TakeOlder, - )? + chain_store.tipset_by_height(from, None, ResolveNullTipset::TakeOlder)? } else { head_ts };