diff --git a/src/networks/mod.rs b/src/networks/mod.rs index b564f0d28655..f2177b5395ff 100644 --- a/src/networks/mod.rs +++ b/src/networks/mod.rs @@ -222,11 +222,38 @@ impl From for NetworkVersion { } } +/// Checks if the given height is an expensive migration. +/// See +pub const fn is_expensive_migration(height: Height) -> bool { + matches!( + height, + Height::Assembly + | Height::Trust + | Height::Turbo + | Height::Hyperdrive + | Height::Chocolate + | Height::OhSnap + | Height::Skyr + | Height::Shark + | Height::Hygge + | Height::Lightning + | Height::Watermelon + | Height::Dragon + | Height::Waffle + | Height::TukTuk + | Height::Teep + | Height::GoldenWeek + | Height::FireHorse + ) +} + #[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] #[cfg_attr(test, derive(derive_quickcheck_arbitrary::Arbitrary))] pub struct HeightInfo { pub epoch: ChainEpoch, pub bundle: Option, + #[serde(default)] + pub expensive: bool, } pub struct HeightInfoWithActorManifest<'a> { @@ -512,6 +539,17 @@ impl ChainConfig { .unwrap_or(0) } + /// Returns true if executing between `parent` and `height` (exclusive of `height`) would + /// cross an expensive state migration. + pub fn has_expensive_fork_between(&self, parent: ChainEpoch, height: ChainEpoch) -> bool { + if parent >= height { + return false; + } + self.height_infos + .values() + .any(|info| info.expensive && info.epoch >= parent && info.epoch < height) + } + pub async fn genesis_bytes( &self, db: &DB, @@ -599,6 +637,7 @@ macro_rules! make_height { HeightInfo { epoch: $epoch, bundle: None, + expensive: $crate::networks::is_expensive_migration(Height::$id), }, ) }; @@ -608,6 +647,7 @@ macro_rules! make_height { HeightInfo { epoch: $epoch, bundle: Some(Cid::try_from($bundle).unwrap()), + expensive: $crate::networks::is_expensive_migration(Height::$id), }, ) }; @@ -678,6 +718,24 @@ mod tests { heights_are_present(&mainnet::HEIGHT_INFOS); } + #[test] + fn height_info_expensive_flag_matches_is_expensive_migration() { + for height in Height::iter() { + let Some(info) = mainnet::HEIGHT_INFOS.get(&height) else { + continue; + }; + assert_eq!(info.expensive, is_expensive_migration(height), "{height:?}"); + } + } + + #[test] + fn has_expensive_fork_between_matches_upgrade_epochs() { + let cfg = ChainConfig::mainnet(); + let shark = cfg.epoch(Height::Shark); + assert!(cfg.has_expensive_fork_between(shark - 1, shark + 1)); + assert!(!cfg.has_expensive_fork_between(shark - 1, shark)); + } + #[test] fn test_calibnet_heights() { heights_are_present(&calibnet::HEIGHT_INFOS); diff --git a/src/rpc/methods/eth.rs b/src/rpc/methods/eth.rs index aef3812ac298..b43f9cb12176 100644 --- a/src/rpc/methods/eth.rs +++ b/src/rpc/methods/eth.rs @@ -1800,7 +1800,7 @@ async fn eth_estimate_gas( err = e.into(); } - Err(anyhow::anyhow!("failed to estimate gas: {err}").into()) + Err(anyhow::anyhow!("failed to estimate gas: {}", err.message()).into()) } Ok(gassed_msg) => { let expected_gas = eth_gas_search(ctx, gassed_msg, &tipset.key().into()).await?; @@ -1814,6 +1814,18 @@ async fn apply_message( tipset: Option, msg: Message, ) -> Result { + if let Some(ts) = &tipset + && ts.epoch() > 0 + { + let parent = ctx.chain_index().load_required_tipset(ts.parents())?; + if ctx + .chain_config() + .has_expensive_fork_between(parent.epoch(), ts.epoch() + 1) + { + return Err(crate::state_manager::Error::ExpensiveFork.into()); + } + } + let (invoc_res, _) = ctx .state_manager .apply_on_state_with_gas(tipset, msg, VMFlush::Skip) diff --git a/src/rpc/methods/state.rs b/src/rpc/methods/state.rs index e16367ba31f9..9cb49456fc01 100644 --- a/src/rpc/methods/state.rs +++ b/src/rpc/methods/state.rs @@ -86,10 +86,28 @@ impl StateCall { message: &Message, tsk: Option, ) -> anyhow::Result { - let tipset = state_manager + let mut tipset = state_manager .chain_store() .load_required_tipset_or_heaviest(&tsk)?; - Ok(state_manager.call(message, Some(tipset))?) + + // Match Lotus' `StateCall` behavior: if the call refuses due to an expensive + // state fork between the parent and the target tipset, walk back to the parent + // tipset and retry. This loop terminates when the call returns a non-`ExpensiveFork` + // result (success or different error), or when we fail to load the parent tipset + // (e.g. we walked back past genesis). + // + // See: + loop { + match state_manager.call(message, Some(tipset.shallow_clone())) { + Err(crate::state_manager::Error::ExpensiveFork) => { + tipset = state_manager + .chain_index() + .load_required_tipset(tipset.parents()) + .map_err(|e| anyhow::anyhow!("getting parent tipset: {e}"))?; + } + result => return Ok(result?), + } + } } } diff --git a/src/state_manager/errors.rs b/src/state_manager/errors.rs index d10d8ae9b4ee..57cdc1435395 100644 --- a/src/state_manager/errors.rs +++ b/src/state_manager/errors.rs @@ -12,6 +12,9 @@ pub enum Error { /// Error originating from state #[error("{0}")] State(String), + /// Refusing explicit call due to an expensive state migration at the requested epoch. + #[error("refusing explicit call due to state fork at epoch")] + ExpensiveFork, /// Other state manager error #[error("{0}")] Other(String), diff --git a/src/state_manager/message_simulation.rs b/src/state_manager/message_simulation.rs index 637c73169793..f77a32ea4799 100644 --- a/src/state_manager/message_simulation.rs +++ b/src/state_manager/message_simulation.rs @@ -11,33 +11,63 @@ use crate::shim::address::Protocol; use crate::shim::crypto::{Signature, SignatureType}; use crate::shim::executor::ApplyRet; use crate::shim::message::Message; +use crate::state_migration::run_state_migrations; use fvm_shared4::crypto::signature::SECP_SIG_LEN; use std::time::Duration; use tracing::instrument; impl StateManager { - #[instrument(skip(self, rand))] + #[instrument(skip(self))] fn call_raw( &self, state_cid: Option, msg: &Message, - rand: ChainRand, - tipset: &Tipset, + tipset: Option, ) -> Result { let mut msg = msg.clone(); + let chain_config = self.chain_config(); - let state_cid = state_cid.unwrap_or(*tipset.parent_state()); - - let tipset_messages = self - .chain_store() - .messages_for_tipset(tipset) - .map_err(|err| Error::Other(err.to_string()))?; + let tipset = if let Some(ts) = tipset { + if ts.epoch() > 0 { + let parent = self + .chain_index() + .load_required_tipset(ts.parents()) + .map_err(Error::other)?; + if chain_config.has_expensive_fork_between(parent.epoch(), ts.epoch() + 1) { + return Err(Error::ExpensiveFork); + } + } + ts + } else { + // Search back till we find a height with no fork, or we reach the beginning. + let mut heaviest_ts = self.heaviest_tipset(); + while heaviest_ts.epoch() > 0 { + let parent = self + .chain_index() + .load_required_tipset(heaviest_ts.parents()) + .map_err(Error::other)?; + if !chain_config.has_expensive_fork_between(parent.epoch(), heaviest_ts.epoch() + 1) + { + break; + } + heaviest_ts = parent; + } + heaviest_ts + }; - let prior_messsages = tipset_messages - .iter() - .filter(|ts_msg| ts_msg.message().from() == msg.from()); + let state_cid = state_cid.unwrap_or(*tipset.parent_state()); // Handle state forks + let state_cid = match run_state_migrations( + tipset.epoch(), + self.chain_config(), + self.db(), + &state_cid, + ) { + Ok(Some(new_state)) => new_state, + Ok(None) => state_cid, + Err(e) => return Err(Error::other(e)), + }; let height = tipset.epoch(); let genesis_info = GenesisInfo::from_chain_config(self.chain_config().clone()); @@ -46,7 +76,7 @@ impl StateManager { heaviest_tipset: tipset.shallow_clone(), state_tree_root: state_cid, epoch: height, - rand: Box::new(rand), + rand: Box::new(self.chain_rand(tipset.shallow_clone())), base_fee: tipset.block_headers().first().parent_base_fee.clone(), circ_supply: genesis_info.get_vm_circulating_supply( height, @@ -61,6 +91,15 @@ impl StateManager { VMTrace::Traced, )?; + let tipset_messages = self + .chain_store() + .messages_for_tipset(&tipset) + .map_err(|err| Error::Other(err.to_string()))?; + + let prior_messsages = tipset_messages + .iter() + .filter(|ts_msg| ts_msg.message().from() == msg.from()); + for m in prior_messsages { vm.apply_message(m)?; } @@ -96,9 +135,7 @@ impl StateManager { /// runs the given message and returns its result without any persisted /// changes. pub fn call(&self, message: &Message, tipset: Option) -> Result { - let ts = tipset.unwrap_or_else(|| self.heaviest_tipset()); - let chain_rand = self.chain_rand(ts.shallow_clone()); - self.call_raw(None, message, chain_rand, &ts) + self.call_raw(None, message, tipset) } /// Same as [`StateManager::call`] but runs the message on the given state and not @@ -109,9 +146,7 @@ impl StateManager { message: &Message, tipset: Option, ) -> Result { - let ts = tipset.unwrap_or_else(|| self.cs.heaviest_tipset()); - let chain_rand = self.chain_rand(ts.shallow_clone()); - self.call_raw(Some(state_cid), message, chain_rand, &ts) + self.call_raw(Some(state_cid), message, tipset) } pub async fn apply_on_state_with_gas( diff --git a/src/tool/subcommands/api_cmd/api_compare_tests.rs b/src/tool/subcommands/api_cmd/api_compare_tests.rs index 76ff27b83c63..783f580f9cc8 100644 --- a/src/tool/subcommands/api_cmd/api_compare_tests.rs +++ b/src/tool/subcommands/api_cmd/api_compare_tests.rs @@ -2444,6 +2444,34 @@ fn f3_tests_with_tipset(tipset: &Tipset) -> anyhow::Result> { ]) } +fn eth_expensive_fork_error_tests(store: Arc) -> anyhow::Result> { + let heaviest_tipset = store.heaviest_tipset()?; + let chain_config = handle_chain_config(&NetworkChain::Calibnet)?; + let expensive_fork_epoch = chain_config + .height_infos + .values() + .filter(|info| info.expensive && info.epoch <= heaviest_tipset.epoch()) + .map(|info| info.epoch) + .max() + .ok_or_else(|| anyhow::anyhow!("calibnet must define at least one expensive fork"))?; + + Ok(vec![ + RpcTest::identity(EthCall::request(( + EthCallMessage::default(), + BlockNumberOrHash::from_block_number(expensive_fork_epoch), + ))?) + .policy_on_rejected(PolicyOnRejected::PassWithQuasiIdenticalError), + RpcTest::identity(EthEstimateGas::request(( + EthCallMessage { + from: Some(generate_eth_random_address()?), + ..Default::default() + }, + Some(BlockNumberOrHash::from_block_number(expensive_fork_epoch)), + ))?) + .policy_on_rejected(PolicyOnRejected::PassWithQuasiIdenticalError), + ]) +} + // Extract tests that use chain-specific data such as block CIDs or message // CIDs. Right now, only the last `n_tipsets` tipsets are used. fn snapshot_tests( @@ -2463,6 +2491,8 @@ fn snapshot_tests( .last() .expect("Infallible"); + tests.extend(eth_expensive_fork_error_tests(store.clone())?); + for tipset in shared_tipset.chain(&store).take(num_tipsets) { tests.extend(chain_tests_with_tipset(&store, offline, &tipset)?); tests.extend(miner_tests_with_tipset(&store, &tipset, miner_address)?); @@ -2555,7 +2585,7 @@ pub(super) async fn create_tests( let store = Arc::new(ManyCar::try_from(snapshot_files.clone())?); revalidate_chain(store.clone(), n_tipsets).await?; tests.extend(snapshot_tests( - store, + store.clone(), offline, n_tipsets, miner_address,