diff --git a/CHANGELOG.md b/CHANGELOG.md index fae3bbb8d1f3..2abaeb867c3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,8 @@ This is a non-mandatory release for all node operators. It enables F3 finality r - [#6631](https://github.com/ChainSafe/forest/issues/6631): Backported F3 finality resolution to ETH v1 RPC methods. +- [#6686](https://github.com/ChainSafe/forest/issues/6686): [FIP-0115](https://github.com/filecoin-project/FIPs/pull/1233) implementation. This will go live on the next network upgrade. + ### Removed - [#6681](https://github.com/ChainSafe/forest/pull/6681): Removed `tracing-chrome` feature and all related code as it was deemed unused. If you didn't set `CHROME_TRACE_FILE` manually before, you shouldn't be affected by this change. If you were using this feature, reach out. diff --git a/src/chain/store/base_fee.rs b/src/chain/store/base_fee.rs index f408a9630359..96baa448a207 100644 --- a/src/chain/store/base_fee.rs +++ b/src/chain/store/base_fee.rs @@ -8,6 +8,10 @@ use crate::shim::econ::{BLOCK_GAS_LIMIT, TokenAmount}; use ahash::{HashSet, HashSetExt}; use fvm_ipld_blockstore::Blockstore; +use super::weighted_quick_select::weighted_quick_select; + +pub const BLOCK_GAS_TARGET_INDEX: u64 = BLOCK_GAS_LIMIT * 80 / 100 - 1; + /// Used in calculating the base fee change. pub const BLOCK_GAS_TARGET: u64 = BLOCK_GAS_LIMIT / 2; @@ -37,51 +41,92 @@ fn compute_next_base_fee( }; // Limit absolute change at the block gas target. - if delta.abs() > BLOCK_GAS_TARGET as i64 { - delta = if delta.is_positive() { - BLOCK_GAS_TARGET as i64 - } else { - -(BLOCK_GAS_TARGET as i64) - }; - } + delta = delta.clamp(-(BLOCK_GAS_TARGET as i64), BLOCK_GAS_TARGET as i64); // cap change at 12.5% (BaseFeeMaxChangeDenom) by capping delta let change: TokenAmount = (base_fee * delta) .div_floor(BLOCK_GAS_TARGET) .div_floor(BASE_FEE_MAX_CHANGE_DENOM); - let mut next_base_fee = base_fee + change; - if next_base_fee.atto() < &MINIMUM_BASE_FEE.into() { - next_base_fee = TokenAmount::from_atto(MINIMUM_BASE_FEE); - } - next_base_fee + (base_fee + change).max(TokenAmount::from_atto(MINIMUM_BASE_FEE)) } pub fn compute_base_fee( db: &DB, ts: &Tipset, smoke_height: ChainEpoch, + xxx_height: ChainEpoch, ) -> Result where DB: Blockstore, { - let mut total_limit = 0; + // FIP-0115: https://github.com/filecoin-project/FIPs/pull/1233 + if ts.epoch() >= xxx_height { + return compute_next_base_fee_from_premiums(db, ts); + } + + compute_next_base_fee_from_utlilization(db, ts, smoke_height) +} + +fn compute_next_base_fee_from_premiums( + db: &DB, + ts: &Tipset, +) -> Result +where + DB: Blockstore, +{ + let mut limits = Vec::new(); + let mut premiums = Vec::new(); let mut seen = HashSet::new(); + let parent_base_fee = &ts.block_headers().first().parent_base_fee; - // Add all unique messages' gas limit to get the total for the Tipset. for b in ts.block_headers() { - let (msg1, msg2) = crate::chain::block_messages(db, b)?; - for m in msg1 { - let m_cid = m.cid(); - if !seen.contains(&m_cid) { - total_limit += m.gas_limit(); - seen.insert(m_cid); + let (bls_msgs, secp_msgs) = crate::chain::block_messages(db, b)?; + for m in bls_msgs + .iter() + .map(|m| m as &dyn Message) + .chain(secp_msgs.iter().map(|m| m as &dyn Message)) + { + if seen.insert((m.from(), m.sequence())) { + limits.push(m.gas_limit()); + premiums.push(m.effective_gas_premium(parent_base_fee)); } } - for m in msg2 { - let m_cid = m.cid(); - if !seen.contains(&m_cid) { - total_limit += m.gas_limit(); - seen.insert(m_cid); + } + + let percentile_premium = weighted_quick_select(premiums, limits, BLOCK_GAS_TARGET_INDEX); + Ok(compute_next_base_fee_from_premium( + parent_base_fee, + percentile_premium, + )) +} + +pub(crate) fn compute_next_base_fee_from_premium( + base_fee: &TokenAmount, + percentile_premium: TokenAmount, +) -> TokenAmount { + let denom = TokenAmount::from_atto(BASE_FEE_MAX_CHANGE_DENOM); + let max_adj = (base_fee + (&denom - &TokenAmount::from_atto(1))) / denom; + TokenAmount::from_atto(MINIMUM_BASE_FEE) + .max(base_fee + (&max_adj).min(&(&percentile_premium - &max_adj))) +} + +fn compute_next_base_fee_from_utlilization( + db: &DB, + ts: &Tipset, + smoke_height: ChainEpoch, +) -> Result +where + DB: Blockstore, +{ + let mut total_limit = 0; + let mut seen = HashSet::new(); + + // Add all unique messages' gas limit to get the total for the Tipset. + for b in ts.block_headers() { + let (bls_msgs, secp_msgs) = crate::chain::block_messages(db, b)?; + for m in bls_msgs.iter().chain(secp_msgs.iter().map(|m| &m.message)) { + if seen.insert(m.cid()) { + total_limit += m.gas_limit; } } } @@ -174,6 +219,108 @@ mod tests { }); let ts = Tipset::from(h0); let smoke_height = ChainConfig::default().epoch(Height::Smoke); - assert!(compute_base_fee(&blockstore, &ts, smoke_height).is_err()); + let xxx_height = ChainConfig::default().epoch(Height::Xxx); + assert!(compute_base_fee(&blockstore, &ts, smoke_height, xxx_height).is_err()); + } + + #[test] + fn test_next_base_fee_from_premium() { + // Test cases from the FIP-0115 + // + let test_cases = vec![ + (100, 0, 100), + (100, 13, 100), + (100, 14, 101), + (100, 26, 113), + (801, 0, 700), + (801, 20, 720), + (801, 40, 740), + (801, 60, 760), + (801, 80, 780), + (801, 100, 800), + (801, 120, 820), + (801, 140, 840), + (801, 160, 860), + (801, 180, 880), + (801, 200, 900), + (801, 201, 901), + (808, 0, 707), + (808, 1, 708), + (808, 201, 908), + (808, 202, 909), + (808, 203, 909), + ]; + + for (base_fee, premium_p, expected) in test_cases { + let base_fee = TokenAmount::from_atto(base_fee); + let premium = TokenAmount::from_atto(premium_p); + + let result = compute_next_base_fee_from_premium(&base_fee, premium); + + assert_eq!( + result, + TokenAmount::from_atto(expected), + "Failed for base_fee={}, premium_p={}", + base_fee.atto(), + premium_p + ); + } + } + + mod quickcheck_tests { + use super::*; + use num::Zero; + use quickcheck_macros::quickcheck; + + #[quickcheck] + fn prop_next_base_fee_never_below_minimum(base_fee: TokenAmount, premium: TokenAmount) { + let result = compute_next_base_fee_from_premium(&base_fee, premium); + assert!(result >= TokenAmount::from_atto(MINIMUM_BASE_FEE)); + } + + #[quickcheck] + fn prop_next_base_fee_change_bounded(base_fee: TokenAmount, premium: TokenAmount) -> bool { + if base_fee.is_zero() { + return true; + } + let min_fee = TokenAmount::from_atto(MINIMUM_BASE_FEE); + let result = compute_next_base_fee_from_premium(&base_fee, premium); + + // maxAdj = ceil(base_fee / 8) + let denom = TokenAmount::from_atto(BASE_FEE_MAX_CHANGE_DENOM); + let max_adj = (&base_fee + &denom - TokenAmount::from_atto(1)) / &denom; + + // Result should be within [max(MIN, base - max_adj), max(MIN, base + max_adj)] + let lower = min_fee.clone().max(&base_fee - &max_adj); + let upper = min_fee.max(&base_fee + &max_adj); + + result >= lower && result <= upper + } + + #[quickcheck] + fn prop_next_base_fee_monotonic_in_premium( + base_fee: TokenAmount, + premium1: TokenAmount, + premium2: TokenAmount, + ) { + let result1 = compute_next_base_fee_from_premium(&base_fee, premium1.clone()); + let result2 = compute_next_base_fee_from_premium(&base_fee, premium2.clone()); + + // Higher premium should give higher or equal result + if premium1 <= premium2 { + assert!(result1 <= result2); + } else { + assert!(result1 >= result2); + } + } + + #[quickcheck] + fn prop_zero_premium_decreases_or_maintains_base_fee(base_fee: TokenAmount) { + let min_fee = TokenAmount::from_atto(MINIMUM_BASE_FEE); + let result = compute_next_base_fee_from_premium(&base_fee, TokenAmount::zero()); + + // With zero premium, base fee should decrease (or stay at minimum) + assert!(result <= base_fee || result == min_fee); + } } } diff --git a/src/chain/store/mod.rs b/src/chain/store/mod.rs index 58ca468ac366..0aba76672816 100644 --- a/src/chain/store/mod.rs +++ b/src/chain/store/mod.rs @@ -6,5 +6,6 @@ mod chain_store; mod errors; pub mod index; mod tipset_tracker; +mod weighted_quick_select; pub use self::{base_fee::*, chain_store::*, errors::*}; diff --git a/src/chain/store/weighted_quick_select.rs b/src/chain/store/weighted_quick_select.rs new file mode 100644 index 000000000000..41db151205ed --- /dev/null +++ b/src/chain/store/weighted_quick_select.rs @@ -0,0 +1,379 @@ +// Copyright 2019-2026 ChainSafe Systems +// SPDX-License-Identifier: Apache-2.0, MIT + +use crate::shim::econ::TokenAmount; +use crate::utils::rand::forest_rng; +use num::Zero as _; +use rand::Rng; + +/// Performs weighted quick select to find the gas-weighted percentile premium. +/// +/// This algorithm selects a value from the premiums array such that the cumulative +/// weight (gas limits) up to that value reaches the target index. +/// +/// # Arguments +/// * `premiums` - Array of effective gas premiums +/// * `limits` - Array of gas limits (weights) corresponding to each premium +/// * `target_index` - The target cumulative weight to reach +/// +/// # Returns +/// The premium at the target percentile, or zero if not found +pub fn weighted_quick_select( + mut premiums: Vec, + mut limits: Vec, + mut target_index: u64, +) -> TokenAmount { + loop { + match (premiums.as_slice(), limits.as_slice()) { + ([], _) => return TokenAmount::zero(), + ([premium], [limit]) => { + return if *limit > target_index { + premium.clone() + } else { + TokenAmount::zero() + }; + } + _ => {} + } + + // Choose random pivot + let pivot_idx = forest_rng().gen_range(0..premiums.len()); + let pivot = premiums + .get(pivot_idx) + .expect("pivot_idx is in range") + .clone(); + + // Partition into three groups by premium value relative to pivot + let (mut more_premiums, mut more_weights, mut more_total_weight) = + (Vec::new(), Vec::new(), 0u64); + let mut equal_total_weight = 0u64; + let (mut less_premiums, mut less_weights, mut less_total_weight) = + (Vec::new(), Vec::new(), 0u64); + + for (premium, limit) in premiums.into_iter().zip(limits) { + match premium.cmp(&pivot) { + std::cmp::Ordering::Greater => { + more_total_weight = more_total_weight.saturating_add(limit); + more_premiums.push(premium); + more_weights.push(limit); + } + std::cmp::Ordering::Equal => { + equal_total_weight = equal_total_weight.saturating_add(limit); + } + std::cmp::Ordering::Less => { + less_total_weight = less_total_weight.saturating_add(limit); + less_premiums.push(premium); + less_weights.push(limit); + } + } + } + + // Determine which partition contains our target + if target_index < more_total_weight { + // Target is in the high-premium partition + premiums = more_premiums; + limits = more_weights; + } else if target_index < more_total_weight.saturating_add(equal_total_weight) { + // Target is in the equal-premium partition + return pivot; + } else { + // Adjust target index by subtracting weights we've passed + target_index -= more_total_weight.saturating_add(equal_total_weight); + + // Check if target is within the low-premium partition + if target_index < less_total_weight { + premiums = less_premiums; + limits = less_weights; + } else { + // Target index exceeds all weights + return TokenAmount::zero(); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_weighted_quick_select_basic() { + assert_eq!( + weighted_quick_select(vec![], vec![], 0), + TokenAmount::zero() + ); + + assert_eq!( + weighted_quick_select( + vec![TokenAmount::from_atto(123)], + vec![5_999_999_999], + 8_000_000_000 + ), + TokenAmount::zero() + ); + + assert_eq!( + weighted_quick_select( + vec![TokenAmount::from_atto(123)], + vec![8_000_000_001], + 8_000_000_000 + ), + TokenAmount::from_atto(123) + ); + } + + #[test] + fn test_weighted_quick_select_deterministic() { + // Run multiple times to verify correctness despite randomness + const TARGET_INDEX: u64 = 7_999_999_999; + + for _ in 0..10 { + assert_eq!( + weighted_quick_select( + vec![TokenAmount::from_atto(123), TokenAmount::from_atto(100)], + vec![5_999_999_999, 2_000_000_000], + TARGET_INDEX + ), + TokenAmount::zero() + ); + + // Premium value 0 case - returns the premium value 0, not "not found" + assert_eq!( + weighted_quick_select( + vec![TokenAmount::from_atto(123), TokenAmount::from_atto(0)], + vec![5_999_999_999, 2_000_000_001], + TARGET_INDEX + ), + TokenAmount::from_atto(0) + ); + + assert_eq!( + weighted_quick_select( + vec![TokenAmount::from_atto(123), TokenAmount::from_atto(100)], + vec![5_999_999_999, 2_000_000_001], + TARGET_INDEX + ), + TokenAmount::from_atto(100) + ); + + assert_eq!( + weighted_quick_select( + vec![TokenAmount::from_atto(123), TokenAmount::from_atto(100)], + vec![7_999_999_999, 2_000_000_001], + TARGET_INDEX + ), + TokenAmount::from_atto(100) + ); + + assert_eq!( + weighted_quick_select( + vec![TokenAmount::from_atto(123), TokenAmount::from_atto(100)], + vec![8_000_000_000, 2_000_000_000], + TARGET_INDEX + ), + TokenAmount::from_atto(123) + ); + + assert_eq!( + weighted_quick_select( + vec![TokenAmount::from_atto(123), TokenAmount::from_atto(100)], + vec![8_000_000_000, 9_000_000_000], + TARGET_INDEX + ), + TokenAmount::from_atto(123) + ); + + assert_eq!( + weighted_quick_select( + vec![ + TokenAmount::from_atto(100), + TokenAmount::from_atto(200), + TokenAmount::from_atto(300), + TokenAmount::from_atto(400), + TokenAmount::from_atto(500), + TokenAmount::from_atto(600), + TokenAmount::from_atto(700), + ], + vec![ + 4_000_000_000, + 1_000_000_000, + 2_000_000_000, + 1_000_000_000, + 2_000_000_000, + 2_000_000_000, + 3_000_000_000, + ], + TARGET_INDEX + ), + TokenAmount::from_atto(400) + ); + } + } + + mod quickcheck_tests { + use super::*; + use crate::blocks::BLOCK_MESSAGE_LIMIT; + use crate::shim::econ::BLOCK_GAS_LIMIT; + use quickcheck::{Arbitrary, Gen}; + use quickcheck_macros::quickcheck; + + #[derive(Clone, Debug)] + struct RealisticGasLimits(Vec); + + impl Arbitrary for RealisticGasLimits { + fn arbitrary(g: &mut Gen) -> Self { + let size = usize::arbitrary(g) % BLOCK_MESSAGE_LIMIT; + let limits: Vec = (0..size) + .map(|_| u64::arbitrary(g) % BLOCK_GAS_LIMIT) // this goes above but that's + // fine + .collect(); + RealisticGasLimits(limits) + } + } + + /// Wrapper for generating matching premiums and limits + #[derive(Clone, Debug)] + struct MatchedPremiumsAndLimits { + premiums: Vec, + limits: Vec, + } + + impl Arbitrary for MatchedPremiumsAndLimits { + fn arbitrary(g: &mut Gen) -> Self { + let limits = RealisticGasLimits::arbitrary(g).0; + let premiums: Vec = (0..limits.len()).map(|_| u64::arbitrary(g)).collect(); + MatchedPremiumsAndLimits { premiums, limits } + } + } + + #[quickcheck] + fn prop_result_is_from_input_or_zero(input: MatchedPremiumsAndLimits, target: u64) -> bool { + let premium_amounts: Vec = input + .premiums + .iter() + .map(|&p| TokenAmount::from_atto(p)) + .collect(); + let result = + weighted_quick_select(premium_amounts.clone(), input.limits.clone(), target); + + // Result must either be zero or one of the input premiums + result.is_zero() || premium_amounts.iter().any(|p| p == &result) + } + + #[quickcheck] + fn prop_empty_input_returns_zero(target: u64) -> bool { + let result = weighted_quick_select(vec![], vec![], target); + result.is_zero() + } + + #[quickcheck] + fn prop_single_element_behavior(premium: u64, limit: u64, target: u64) -> bool { + let result = + weighted_quick_select(vec![TokenAmount::from_atto(premium)], vec![limit], target); + + // If limit > target, should return the premium; otherwise zero + if limit > target { + result == TokenAmount::from_atto(premium) + } else { + result.is_zero() + } + } + + #[quickcheck] + fn prop_target_beyond_total_weight_returns_zero(input: MatchedPremiumsAndLimits) -> bool { + if input.limits.is_empty() { + return true; + } + + let premium_amounts: Vec = input + .premiums + .iter() + .map(|&p| TokenAmount::from_atto(p)) + .collect(); + + // Target at or beyond total weight should return zero + let total_weight: u64 = input.limits.iter().sum(); + let result = weighted_quick_select(premium_amounts, input.limits, total_weight); + result.is_zero() + } + + #[quickcheck] + fn prop_deterministic_result(input: MatchedPremiumsAndLimits, target: u64) -> bool { + let premium_amounts: Vec = input + .premiums + .iter() + .map(|&p| TokenAmount::from_atto(p)) + .collect(); + + // Run twice and check results are the same (despite randomness in pivot selection) + let result1 = + weighted_quick_select(premium_amounts.clone(), input.limits.clone(), target); + let result2 = weighted_quick_select(premium_amounts, input.limits, target); + + result1 == result2 + } + + /// Wrapper for two distinct premiums with weights + #[derive(Clone, Debug)] + struct OrderedPremiumPair { + low_premium: u64, + high_premium: u64, + weight_low: u64, + weight_high: u64, + } + + impl Arbitrary for OrderedPremiumPair { + fn arbitrary(g: &mut Gen) -> Self { + const MAX_PREMIUM: u64 = u64::MAX / 2; // Leave room for increment + let low = u64::arbitrary(g) % MAX_PREMIUM; + let high = low + (u64::arbitrary(g) % 1000).max(1); + OrderedPremiumPair { + low_premium: low, + high_premium: high, + weight_low: (u64::arbitrary(g) % BLOCK_GAS_LIMIT).max(1), + weight_high: (u64::arbitrary(g) % BLOCK_GAS_LIMIT).max(1), + } + } + } + + #[quickcheck] + fn prop_result_respects_weight_ordering(pair: OrderedPremiumPair) -> bool { + // Use target strictly less than weight_high to actually test the behavior. + // weight_high >= 1 is guaranteed by the Arbitrary impl, so this is safe. + let target = pair.weight_high - 1; + let result = weighted_quick_select( + vec![ + TokenAmount::from_atto(pair.low_premium), + TokenAmount::from_atto(pair.high_premium), + ], + vec![pair.weight_low, pair.weight_high], + target, + ); + + // When target < weight_high, should select the high premium + // (the high premium's weight alone is sufficient to cover the target) + result == TokenAmount::from_atto(pair.high_premium) + } + + #[quickcheck] + fn prop_equal_premiums_handled_correctly( + premium: u64, + limits: RealisticGasLimits, + target: u64, + ) -> bool { + if limits.0.is_empty() { + return true; + } + + let premiums = vec![TokenAmount::from_atto(premium); limits.0.len()]; + let total_weight: u64 = limits.0.iter().sum(); + let result = weighted_quick_select(premiums, limits.0, target); + + if target < total_weight { + result == TokenAmount::from_atto(premium) + } else { + result.is_zero() + } + } + } +} diff --git a/src/chain_sync/tipset_syncer.rs b/src/chain_sync/tipset_syncer.rs index d925badce854..7cad8b543ba4 100644 --- a/src/chain_sync/tipset_syncer.rs +++ b/src/chain_sync/tipset_syncer.rs @@ -232,14 +232,20 @@ async fn validate_block( // Base fee check validations.spawn_blocking({ let smoke_height = state_manager.chain_config().epoch(Height::Smoke); + let xxx_height = state_manager.chain_config().epoch(Height::Xxx); let base_tipset = base_tipset.clone(); let block_store = state_manager.blockstore_owned(); let block = Arc::clone(&block); move || { - let base_fee = crate::chain::compute_base_fee(&block_store, &base_tipset, smoke_height) - .map_err(|e| { - TipsetSyncerError::Validation(format!("Could not compute base fee: {e}")) - })?; + let base_fee = crate::chain::compute_base_fee( + &block_store, + &base_tipset, + smoke_height, + xxx_height, + ) + .map_err(|e| { + TipsetSyncerError::Validation(format!("Could not compute base fee: {e}")) + })?; let parent_base_fee = &block.header.parent_base_fee; if &base_fee != parent_base_fee { return Err(TipsetSyncerError::Validation(format!( diff --git a/src/message/mod.rs b/src/message/mod.rs index 95527b4e5db9..bb9ad579b163 100644 --- a/src/message/mod.rs +++ b/src/message/mod.rs @@ -153,4 +153,47 @@ pub fn valid_for_block_inclusion( #[cfg(test)] mod tests { mod builder_test; + + use itertools::Itertools; + + use super::*; + + #[test] + fn test_effective_gas_premium() { + // Test cases from the FIP-0115 + // > + let test_cases = vec![ + // (base_fee, gas_fee_cap, gas_premium, expected) + (8, 8, 8, 0), + (8, 16, 7, 7), + (8, 19, 10, 10), + (123456, 123455, 123455, 0), + (123456, 1234567, 1111112, 1111111), + ] + .into_iter() + .map(|(base_fee, gas_fee_cap, gas_premium, expected)| { + ( + TokenAmount::from_atto(base_fee), + TokenAmount::from_atto(gas_fee_cap), + TokenAmount::from_atto(gas_premium), + TokenAmount::from_atto(expected), + ) + }) + .collect_vec(); + + for (base_fee, gas_fee_cap, gas_premium, expected) in test_cases.into_iter() { + let msg = ShimMessage { + gas_fee_cap: gas_fee_cap.clone(), + gas_premium: gas_premium.clone(), + ..Default::default() + }; + + let result = msg.effective_gas_premium(&base_fee); + assert_eq!( + result, expected, + "base_fee={} gas_fee_cap={} gas_premium={} expected={} got={}", + base_fee, gas_fee_cap, gas_premium, expected, result + ); + } + } } diff --git a/src/message_pool/msgpool/provider.rs b/src/message_pool/msgpool/provider.rs index a685810344dc..10452b38862c 100644 --- a/src/message_pool/msgpool/provider.rs +++ b/src/message_pool/msgpool/provider.rs @@ -113,7 +113,8 @@ where fn chain_compute_base_fee(&self, ts: &Tipset) -> Result { let smoke_height = self.sm.chain_config().epoch(Height::Smoke); - crate::chain::compute_base_fee(self.sm.blockstore(), ts, smoke_height) + let xxx_height = self.sm.chain_config().epoch(Height::Xxx); + crate::chain::compute_base_fee(self.sm.blockstore(), ts, smoke_height, xxx_height) .map_err(|err| err.into()) } } diff --git a/src/rpc/methods/miner.rs b/src/rpc/methods/miner.rs index ecdc75e1a459..0dfd40251510 100644 --- a/src/rpc/methods/miner.rs +++ b/src/rpc/methods/miner.rs @@ -141,6 +141,11 @@ impl RpcMethod<1> for MinerCreateBlock { .get(&Height::Smoke) .context("Missing Smoke height")? .epoch, + ctx.chain_config() + .height_infos + .get(&Height::Xxx) + .context("Missing Xxx height")? + .epoch, )?; let (state, receipts) = ctx .state_manager