From 42586fe3b74c92558304a706b544e9da4dedb596 Mon Sep 17 00:00:00 2001 From: hanabi1224 Date: Thu, 28 May 2026 16:18:45 +0800 Subject: [PATCH 1/4] fix: adjust state computation policy for Eth RPC methods --- docs/docs/users/reference/env_variables.md | 1 + scripts/tests/api_compare/docker-compose.yml | 2 +- src/rpc/methods/eth.rs | 9 ++-- src/rpc/methods/eth/filter/mod.rs | 2 +- src/state_manager/state_computation.rs | 49 +++++++++++++++++++- 5 files changed, 57 insertions(+), 6 deletions(-) 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/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..6bf2345ac2b7 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,34 @@ 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 + }; + 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 +73,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,6 +84,7 @@ impl StateManager { msg_ts: &Tipset, // when `msg_ts` is the current head, `receipt_ts` is `None` receipt_ts: Option<&Tipset>, + policy: StateRecomputePolicy, ) -> anyhow::Result { if let Some(receipt_ts) = receipt_ts { anyhow::ensure!( @@ -60,6 +92,7 @@ impl StateManager { "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 +102,13 @@ impl StateManager { }) { Some((state_root, receipt_root, receipts)) => (state_root, receipt_root, receipts), None => { + if !allow_state_compute { + anyhow::bail!( + "failed to load tipset state output, epoch={}, key={}", + msg_ts.epoch(), + msg_ts.key() + ); + } let state_output = self .compute_tipset_state(msg_ts.shallow_clone(), NO_CALLBACK, VMTrace::NotTraced) .await?; @@ -95,6 +135,13 @@ impl StateManager { Ok(events) => events, Err(e) if recomputed => return Err(e), Err(_) => { + if !allow_state_compute { + anyhow::bail!( + "failed to load tipset state output, epoch={}, key={}", + msg_ts.epoch(), + msg_ts.key() + ); + } self.compute_tipset_state( msg_ts.shallow_clone(), NO_CALLBACK, From 179d7b7f4035508fdef3214609abbae3a25caf46 Mon Sep 17 00:00:00 2001 From: hanabi1224 Date: Thu, 28 May 2026 18:33:44 +0800 Subject: [PATCH 2/4] resolve review comments --- CHANGELOG.md | 2 ++ src/state_manager/state_computation.rs | 20 +++++++++---------- src/tool/subcommands/api_cmd/test_snapshot.rs | 1 + 3 files changed, 13 insertions(+), 10 deletions(-) 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/src/state_manager/state_computation.rs b/src/state_manager/state_computation.rs index 6bf2345ac2b7..705c92cea58c 100644 --- a/src/state_manager/state_computation.rs +++ b/src/state_manager/state_computation.rs @@ -86,6 +86,14 @@ impl StateManager { 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(), @@ -103,11 +111,7 @@ impl StateManager { Some((state_root, receipt_root, receipts)) => (state_root, receipt_root, receipts), None => { if !allow_state_compute { - anyhow::bail!( - "failed to load tipset state output, epoch={}, key={}", - msg_ts.epoch(), - msg_ts.key() - ); + anyhow::bail!(state_compute_disallow_error()); } let state_output = self .compute_tipset_state(msg_ts.shallow_clone(), NO_CALLBACK, VMTrace::NotTraced) @@ -136,11 +140,7 @@ impl StateManager { Err(e) if recomputed => return Err(e), Err(_) => { if !allow_state_compute { - anyhow::bail!( - "failed to load tipset state output, epoch={}, key={}", - msg_ts.epoch(), - msg_ts.key() - ); + anyhow::bail!(state_compute_disallow_error()); } self.compute_tipset_state( msg_ts.shallow_clone(), diff --git a/src/tool/subcommands/api_cmd/test_snapshot.rs b/src/tool/subcommands/api_cmd/test_snapshot.rs index 4963fce3f8f1..49b1f58cca10 100644 --- a/src/tool/subcommands/api_cmd/test_snapshot.rs +++ b/src/tool/subcommands/api_cmd/test_snapshot.rs @@ -65,6 +65,7 @@ fn backfill_eth_mappings(db: &MemoryDB, index: Option) -> anyhow::Result< } pub async fn run_test_from_snapshot(path: &Path) -> anyhow::Result<()> { + unsafe { std::env::set_var("FOREST_ETH_RPC_COMPUTE_STATE_ON_INDEX_MISS", "1") }; let mut run = false; let snapshot_bytes = std::fs::read(path)?; let snapshot_bytes = if let Ok(bytes) = zstd::decode_all(snapshot_bytes.as_slice()) { From c993b00a8fe927d269f4c7232aefc64207b63881 Mon Sep 17 00:00:00 2001 From: hanabi1224 Date: Thu, 28 May 2026 19:01:29 +0800 Subject: [PATCH 3/4] fix cache prefilling --- src/daemon/mod.rs | 6 ++++++ 1 file changed, 6 insertions(+) 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, From db7dbe5756f44f84fe71509f09a1f31aa6318055 Mon Sep 17 00:00:00 2001 From: hanabi1224 Date: Thu, 28 May 2026 20:37:26 +0800 Subject: [PATCH 4/4] unset env var in tests --- src/state_manager/state_computation.rs | 8 ++++++++ src/tool/subcommands/api_cmd/test_snapshot.rs | 1 - 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/state_manager/state_computation.rs b/src/state_manager/state_computation.rs index 705c92cea58c..bc75428e8500 100644 --- a/src/state_manager/state_computation.rs +++ b/src/state_manager/state_computation.rs @@ -46,6 +46,14 @@ impl StateManager { } 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 } diff --git a/src/tool/subcommands/api_cmd/test_snapshot.rs b/src/tool/subcommands/api_cmd/test_snapshot.rs index 49b1f58cca10..4963fce3f8f1 100644 --- a/src/tool/subcommands/api_cmd/test_snapshot.rs +++ b/src/tool/subcommands/api_cmd/test_snapshot.rs @@ -65,7 +65,6 @@ fn backfill_eth_mappings(db: &MemoryDB, index: Option) -> anyhow::Result< } pub async fn run_test_from_snapshot(path: &Path) -> anyhow::Result<()> { - unsafe { std::env::set_var("FOREST_ETH_RPC_COMPUTE_STATE_ON_INDEX_MISS", "1") }; let mut run = false; let snapshot_bytes = std::fs::read(path)?; let snapshot_bytes = if let Ok(bytes) = zstd::decode_all(snapshot_bytes.as_slice()) {