diff --git a/CHANGELOG.md b/CHANGELOG.md index 46fb7854dd71..f1a56a402ab0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ - [#7073](https://github.com/ChainSafe/forest/pull/7073) and [#7077](https://github.com/ChainSafe/forest/pull/7077): Replaced the underlying cache engine across the node. The eviction policy is no longer strict LRU — it is now CLOCK-PRO via [`quick_cache`](https://crates.io/crates/quick_cache), which is scan-resistant and typically gives higher hit rates on chain workloads. Refactored internal cache metrics to include `hits` and `misses` for all automatically. The old metrics `lru_cache_hit_total` and `lru_cache_miss_total` are deprecated in favor of `cache_{name}_hits` and `cache_{name}_misses`. Details can be found in https://forest-docs.pages.dev/reference/metrics +- [#7116](https://github.com/ChainSafe/forest/pull/7116): Disable state computation for Ethereum RPC methods by default. It could be explictly enabled by setting environment variable `FOREST_ETH_RPC_COMPUTE_STATE_ON_INDEX_MISS=1` + ### Added - [#6012](https://github.com/ChainSafe/forest/issues/6012): Stricter validation of address arguments in `forest-wallet` subcommands. diff --git a/docs/docs/users/reference/env_variables.md b/docs/docs/users/reference/env_variables.md index fa0fba485157..a6e327c20bd2 100644 --- a/docs/docs/users/reference/env_variables.md +++ b/docs/docs/users/reference/env_variables.md @@ -69,6 +69,7 @@ process. | `FOREST_MAX_CONCURRENT_INBOUND_CHAIN_EXCHANGE_REQUESTS` | positive integer | 32 | 32 | Maximum number of inbound chain exchange requests Forest will service concurrently. Excess requests are rejected with a `GoAway` response | | `FOREST_MAX_CONCURRENT_INBOUND_CHAIN_EXCHANGE_REQUESTS_PER_PEER` | positive integer | 4 | 4 | Per-peer cap on concurrent inbound chain exchange requests. Excess requests from a single peer are rejected with a `GoAway` response | | `FOREST_MAX_OUTBOUND_CHAIN_EXCHANGE_RESPONSE_BYTES` | positive integer (bytes) | 10485760 (10 MiB) | 10485760 | Cap on the encoded byte size of a chain exchange response Forest serves to peers. Building stops as soon as the running encoded size would exceed this cap and the response is returned with `PartialResponse` status | +| `FOREST_ETH_RPC_COMPUTE_STATE_ON_INDEX_MISS` | 1 or true | false | 1 | Allows Ethereum RPC methods to compute state trees on index miss | ### `FOREST_F3_SIDECAR_FFI_BUILD_OPT_OUT` diff --git a/scripts/tests/api_compare/docker-compose.yml b/scripts/tests/api_compare/docker-compose.yml index 38a8df93106c..f4cca6295a71 100644 --- a/scripts/tests/api_compare/docker-compose.yml +++ b/scripts/tests/api_compare/docker-compose.yml @@ -130,7 +130,7 @@ services: - | set -euxo pipefail forest-tool api serve $(ls /data/*.car.zst | tail -n 1) \ - --height=-50 --index-backfill-epochs 50 --port ${FOREST_OFFLINE_RPC_PORT} --save-token /data/forest-token-offline + --height=-50 --index-backfill-epochs 200 --port ${FOREST_OFFLINE_RPC_PORT} --save-token /data/forest-token-offline healthcheck: test: ["CMD", "forest-cli", "chain", "head"] interval: 10s diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 6b3d4580ecb5..ef3b91ecb5c3 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -389,6 +389,12 @@ fn maybe_prefill_rpc_caches( async fn prefill_rpc_caches_for_tipset(state_manager: StateManager, tsk: TipsetKey) { match state_manager.chain_index().load_required_tipset(&tsk) { Ok(ts) => { + { + // First, compute state for the ts as it's disallowed for RPC methods by default + if let Err(e) = state_manager.load_executed_tipset(&ts).await { + warn!("failed to load executed tipset for cache warmup: {e:#}"); + } + } for tx_info in [crate::rpc::eth::TxInfo::Full, crate::rpc::eth::TxInfo::Hash] { if let Err(e) = crate::rpc::eth::Block::from_filecoin_tipset( &state_manager, diff --git a/src/rpc/methods/eth.rs b/src/rpc/methods/eth.rs index 3aadb76ac724..4dce20921f96 100644 --- a/src/rpc/methods/eth.rs +++ b/src/rpc/methods/eth.rs @@ -521,7 +521,7 @@ impl Block { state_root, executed_messages, .. - } = state_manager.load_executed_tipset(&tipset).await?; + } = state_manager.load_executed_tipset_for_rpc(&tipset).await?; let has_transactions = !executed_messages.is_empty(); let state_tree = state_manager.get_state_tree(&state_root)?; @@ -1528,7 +1528,10 @@ async fn get_block_receipts( state_root, executed_messages, .. - } = ctx.state_manager.load_executed_tipset(&ts_ref).await?; + } = ctx + .state_manager + .load_executed_tipset_for_rpc(&ts_ref) + .await?; // Load the state tree let state_tree = ctx.state_manager.get_state_tree(&state_root)?; @@ -2032,7 +2035,7 @@ async fn eth_fee_history( let base_fee = &ts.block_headers().first().parent_base_fee; let ExecutedTipset { executed_messages, .. - } = ctx.state_manager.load_executed_tipset(&ts).await?; + } = ctx.state_manager.load_executed_tipset_for_rpc(&ts).await?; let mut tx_gas_rewards = Vec::with_capacity(executed_messages.len()); for ExecutedMessage { message, receipt, .. diff --git a/src/rpc/methods/eth/filter/mod.rs b/src/rpc/methods/eth/filter/mod.rs index 9555db7b1bc9..ce7fed6e4dbd 100644 --- a/src/rpc/methods/eth/filter/mod.rs +++ b/src/rpc/methods/eth/filter/mod.rs @@ -346,7 +346,7 @@ impl EthEventHandler { let tipset_key = tipset.key(); let ExecutedTipset { executed_messages, .. - } = state_manager.load_executed_tipset(tipset).await?; + } = state_manager.load_executed_tipset_for_rpc(tipset).await?; let mut resolved_id_addrs = HashMap::default(); let mut event_count = 0; for ( diff --git a/src/state_manager/state_computation.rs b/src/state_manager/state_computation.rs index 66a2a9241dfb..bc75428e8500 100644 --- a/src/state_manager/state_computation.rs +++ b/src/state_manager/state_computation.rs @@ -11,6 +11,11 @@ use anyhow::{bail, ensure}; use fil_actors_shared::fvm_ipld_amt::{Amt, Amtv0}; use tracing::{error, info, instrument}; +enum StateRecomputePolicy { + Allowed, + Disallowed, +} + impl StateManager { /// Load the state of a tipset, including state root, message receipts pub async fn load_tipset_state(&self, ts: &Tipset) -> anyhow::Result { @@ -27,8 +32,42 @@ impl StateManager { } } + /// Load an executed tipset for RPC methods, with state computation unless explicitly enabled. + pub async fn load_executed_tipset_for_rpc( + &self, + ts: &Tipset, + ) -> anyhow::Result { + crate::def_is_env_truthy!( + enable_state_computation, + "FOREST_ETH_RPC_COMPUTE_STATE_ON_INDEX_MISS" + ); + let policy = if enable_state_computation() { + StateRecomputePolicy::Allowed + } else { + StateRecomputePolicy::Disallowed + }; + + // https://github.com/ChainSafe/forest/issues/7118 + #[cfg(test)] + let policy = { + _ = policy; + StateRecomputePolicy::Allowed + }; + + self.load_executed_tipset_with_cache(ts, policy).await + } + /// Load an executed tipset, including state root, message receipts and events with caching. pub async fn load_executed_tipset(&self, ts: &Tipset) -> anyhow::Result { + self.load_executed_tipset_with_cache(ts, StateRecomputePolicy::Allowed) + .await + } + + async fn load_executed_tipset_with_cache( + &self, + ts: &Tipset, + policy: StateRecomputePolicy, + ) -> anyhow::Result { // validate the existence of state trees for post-chain-head-epoch tipsets in case chain head is reset(e.g. manually or via GC). if ts.epoch() >= self.heaviest_tipset().epoch() && let Some(cached) = self.cache.get(ts.key()) @@ -42,7 +81,7 @@ impl StateManager { self.cache .get_or_insert_async(ts.key(), async move { let receipt_ts = self.chain_store().load_child_tipset(ts)?; - self.load_executed_tipset_inner(ts, receipt_ts.as_ref()) + self.load_executed_tipset_inner(ts, receipt_ts.as_ref(), policy) .await }) .await @@ -53,13 +92,23 @@ impl StateManager { msg_ts: &Tipset, // when `msg_ts` is the current head, `receipt_ts` is `None` receipt_ts: Option<&Tipset>, + policy: StateRecomputePolicy, ) -> anyhow::Result { + let state_compute_disallow_error = || { + format!( + "failed to load tipset state output and recomputation is disallowed, epoch={}, key={}", + msg_ts.epoch(), + msg_ts.key() + ) + }; + if let Some(receipt_ts) = receipt_ts { anyhow::ensure!( msg_ts.key() == receipt_ts.parents(), "message tipset should be the parent of message receipt tipset" ); } + let allow_state_compute = matches!(policy, StateRecomputePolicy::Allowed); let mut recomputed = false; let (state_root, receipt_root, receipts) = match receipt_ts.and_then(|ts| { let receipt_root = *ts.parent_message_receipts(); @@ -69,6 +118,9 @@ impl StateManager { }) { Some((state_root, receipt_root, receipts)) => (state_root, receipt_root, receipts), None => { + if !allow_state_compute { + anyhow::bail!(state_compute_disallow_error()); + } let state_output = self .compute_tipset_state(msg_ts.shallow_clone(), NO_CALLBACK, VMTrace::NotTraced) .await?; @@ -95,6 +147,9 @@ impl StateManager { Ok(events) => events, Err(e) if recomputed => return Err(e), Err(_) => { + if !allow_state_compute { + anyhow::bail!(state_compute_disallow_error()); + } self.compute_tipset_state( msg_ts.shallow_clone(), NO_CALLBACK,