From 268ed8df0e7a5c23a16ddbf318afcc5d6cc83bea Mon Sep 17 00:00:00 2001 From: Shashank Date: Mon, 3 Aug 2026 13:14:35 +0530 Subject: [PATCH 01/11] handle sender validation --- src/rpc/methods/eth.rs | 132 ++++++++++++++++-- src/rpc/methods/gas.rs | 35 +++-- src/state_manager/errors.rs | 13 ++ src/state_manager/message_simulation.rs | 65 +++++++-- .../subcommands/api_cmd/api_compare_tests.rs | 40 ++++++ 5 files changed, 257 insertions(+), 28 deletions(-) diff --git a/src/rpc/methods/eth.rs b/src/rpc/methods/eth.rs index 3c33ee6cb1f8..d49ea0f931d4 100644 --- a/src/rpc/methods/eth.rs +++ b/src/rpc/methods/eth.rs @@ -64,7 +64,9 @@ use crate::shim::gas::GasOutputs; use crate::shim::message::Message; use crate::shim::trace::{CallReturn, ExecutionEvent}; use crate::shim::{clock::ChainEpoch, state_tree::StateTree}; -use crate::state_manager::{ExecutedMessage, ExecutedTipset, StateManager, TipsetState, VMFlush}; +use crate::state_manager::{ + ExecutedMessage, ExecutedTipset, SenderValidation, StateManager, TipsetState, VMFlush, +}; use crate::utils::cache::SizeTrackingCache; use crate::utils::db::BlockstoreExt as _; use crate::utils::encoding::from_slice_with_fallback; @@ -1875,6 +1877,10 @@ async fn eth_estimate_gas( // gas estimation actually run. msg.gas_limit = 0; + if resolve_sender_validation(ctx, &msg.from, &tipset).await == SenderValidation::Skip { + return eth_estimate_gas_skip_sender(ctx, msg, tipset).await; + } + match gas::estimate_message_gas(ctx, msg.clone(), None, tipset.key().clone().into()).await { Err(server_err) => { // On failure, GasEstimateMessageGas doesn't actually return the invocation result, @@ -1899,12 +1905,72 @@ async fn eth_estimate_gas( Err(err.context("failed to estimate gas").into()) } Ok(gassed_msg) => { - let expected_gas = eth_gas_search(ctx, gassed_msg, &tipset.key().into()).await?; + let expected_gas = eth_gas_search( + ctx, + gassed_msg, + &tipset.key().into(), + SenderValidation::Enforce, + ) + .await?; Ok(expected_gas.into()) } } } +/// Returns [`SenderValidation::Skip`] for an EVM-contract or non-existent sender, which the FVM +/// can't validate. Defaults to [`SenderValidation::Enforce`]. +async fn resolve_sender_validation( + ctx: &Ctx, + from: &FilecoinAddress, + tipset: &Tipset, +) -> SenderValidation { + let Ok(state) = ctx.state_manager.load_tipset_state(tipset).await else { + return SenderValidation::Enforce; + }; + match ctx.state_manager.get_actor(from, state.state_root) { + Ok(None) => SenderValidation::Skip, + Ok(Some(actor)) if is_evm_actor(&actor.code) => SenderValidation::Skip, + _ => SenderValidation::Enforce, + } +} + +/// Estimates gas for a contract or non-existent sender, skipping sender validation. +async fn eth_estimate_gas_skip_sender( + ctx: &Ctx, + mut msg: Message, + tipset: Tipset, +) -> Result { + let tsk: ApiTipsetKey = tipset.key().clone().into(); + + let gas_limit = match gas::GasEstimateGasLimit::estimate_gas_limit( + ctx, + msg.clone(), + &tsk, + SenderValidation::Skip, + ) + .await + { + Ok(gas_limit) => gas_limit, + Err(estimate_err) => { + msg.set_gas_limit(BLOCK_GAS_LIMIT); + if let Err(e) = apply_message(ctx, Some(tipset), msg).await + && e.downcast_ref::() + .is_some_and(|eth_err| matches!(eth_err, EthErrors::ExecutionReverted { .. })) + { + return Err(e.into()); + } + return Err(estimate_err.context("failed to estimate gas").into()); + } + }; + + let gas_limit = + ((gas_limit as f64 * ctx.mpool.gas_limit_overestimation()) as u64).min(BLOCK_GAS_LIMIT); + msg.set_gas_limit(gas_limit); + + let expected_gas = eth_gas_search(ctx, msg, &tsk, SenderValidation::Skip).await?; + Ok(expected_gas.into()) +} + async fn apply_message( ctx: &Ctx, tipset: Option, @@ -1919,11 +1985,34 @@ async fn apply_message( return Err(crate::state_manager::Error::ExpensiveFork { epoch: ts.epoch() }.into()); } - let (invoc_res, _) = ctx + let result = ctx .state_manager - .apply_on_state_with_gas(tipset, msg, VMFlush::Skip) - .await - .context("failed to apply on state with gas")?; + .apply_on_state_with_gas( + tipset.clone(), + msg.clone(), + VMFlush::Skip, + SenderValidation::Enforce, + ) + .await; + + let needs_skip = match &result { + Err(e) => e + .downcast_ref::() + .is_some_and(|e| matches!(e, crate::state_manager::Error::SenderValidationFailed)), + Ok((invoc_res, _)) => invoc_res + .msg_rct + .as_ref() + .is_some_and(|rct| rct.exit_code() == fvm_shared4::error::ExitCode::SYS_SENDER_INVALID), + }; + + let (invoc_res, _) = if needs_skip { + ctx.state_manager + .apply_on_state_with_gas(tipset, msg, VMFlush::Skip, SenderValidation::Skip) + .await + .context("failed to apply on state with gas (skip sender validation)")? + } else { + result.context("failed to apply on state with gas")? + }; // Extract receipt or return early if none match &invoc_res.msg_rct { @@ -1946,9 +2035,15 @@ async fn apply_message( Ok(invoc_res) } -pub async fn eth_gas_search(data: &Ctx, msg: Message, tsk: &ApiTipsetKey) -> anyhow::Result { +pub async fn eth_gas_search( + data: &Ctx, + msg: Message, + tsk: &ApiTipsetKey, + sender_validation: SenderValidation, +) -> anyhow::Result { let (_invoc_res, apply_ret, prior_messages, ts) = - gas::GasEstimateGasLimit::estimate_call_with_gas(data, msg.clone(), tsk).await?; + gas::GasEstimateGasLimit::estimate_call_with_gas(data, msg.clone(), tsk, sender_validation) + .await?; if apply_ret.msg_receipt().exit_code().is_success() { return Ok(msg.gas_limit()); } @@ -1964,7 +2059,7 @@ pub async fn eth_gas_search(data: &Ctx, msg: Message, tsk: &ApiTipsetKey) -> any }) ) }) { - let ret = gas_search(data, &msg, prior_messages, ts).await?; + let ret = gas_search(data, &msg, prior_messages, ts, sender_validation).await?; Ok(((ret as f64) * data.mpool.gas_limit_overestimation()) as u64) } else { anyhow::bail!( @@ -1984,6 +2079,7 @@ async fn gas_search( msg: &Message, prior_messages: Arc>, ts: Tipset, + sender_validation: SenderValidation, ) -> anyhow::Result { let mut high = msg.gas_limit; let mut low = msg.gas_limit; @@ -1994,11 +2090,18 @@ async fn gas_search( prior_messages: Arc>, ts: Tipset, limit: u64, + sender_validation: SenderValidation, ) -> anyhow::Result { msg.gas_limit = limit; let (_invoc_res, apply_ret, _, _) = data .state_manager - .call_with_gas(msg.into(), prior_messages, Some(ts), VMFlush::Skip) + .call_with_gas( + msg.into(), + prior_messages, + Some(ts), + VMFlush::Skip, + sender_validation, + ) .await?; Ok(apply_ret.msg_receipt().exit_code().is_success()) } @@ -2010,6 +2113,7 @@ async fn gas_search( prior_messages.shallow_clone(), ts.shallow_clone(), high, + sender_validation, ) .await? { @@ -2028,6 +2132,7 @@ async fn gas_search( prior_messages.shallow_clone(), ts.shallow_clone(), median, + sender_validation, ) .await? { @@ -3845,7 +3950,12 @@ impl RpcMethod<3> for EthTraceCall { let (invoke_result, post_state_root) = ctx .state_manager - .apply_on_state_with_gas(Some(ts.shallow_clone()), msg.clone(), VMFlush::Flush) + .apply_on_state_with_gas( + Some(ts.shallow_clone()), + msg.clone(), + VMFlush::Flush, + SenderValidation::Enforce, + ) .await .context("failed to apply message")?; let post_state_root = diff --git a/src/rpc/methods/gas.rs b/src/rpc/methods/gas.rs index 407991db4898..095a7ceb1581 100644 --- a/src/rpc/methods/gas.rs +++ b/src/rpc/methods/gas.rs @@ -14,7 +14,7 @@ use crate::shim::{ econ::{BLOCK_GAS_LIMIT, TokenAmount}, message::Message, }; -use crate::state_manager::VMFlush; +use crate::state_manager::{SenderValidation, VMFlush}; use anyhow::Result; use enumflags2::BitFlags; use num::BigInt; @@ -201,7 +201,7 @@ impl RpcMethod<2> for GasEstimateGasLimit { (msg, tsk): Self::Params, _: &http::Extensions, ) -> Result { - Ok(Self::estimate_gas_limit(&ctx, msg, &tsk).await?) + Ok(Self::estimate_gas_limit(&ctx, msg, &tsk, SenderValidation::Enforce).await?) } } @@ -210,16 +210,21 @@ impl GasEstimateGasLimit { data: &Ctx, mut msg: Message, ApiTipsetKey(tsk): &ApiTipsetKey, + sender_validation: SenderValidation, ) -> anyhow::Result<(InvocResult, ApplyRet, Arc>, Tipset)> { msg.set_gas_limit(BLOCK_GAS_LIMIT); msg.set_gas_fee_cap(TokenAmount::from_atto(0)); msg.set_gas_premium(TokenAmount::from_atto(0)); let curr_ts = data.chain_store().load_required_tipset_or_heaviest(tsk)?; - let from_a = data - .state_manager - .resolve_to_deterministic_address(msg.from, &curr_ts) - .await?; + let from_a = match sender_validation { + SenderValidation::Skip => msg.from, + SenderValidation::Enforce => { + data.state_manager + .resolve_to_deterministic_address(msg.from, &curr_ts) + .await? + } + }; let pending = data.mpool.pending_for(&from_a).await; let prior_messages: Arc> = pending @@ -253,13 +258,19 @@ impl GasEstimateGasLimit { prior_messages.shallow_clone(), Some(ts.shallow_clone()), VMFlush::Skip, + sender_validation, ) .await?; Ok((invoc_res, apply_ret, prior_messages, ts)) } - pub async fn estimate_gas_limit(data: &Ctx, msg: Message, tsk: &ApiTipsetKey) -> Result { - let (res, ..) = Self::estimate_call_with_gas(data, msg, tsk) + pub async fn estimate_gas_limit( + data: &Ctx, + msg: Message, + tsk: &ApiTipsetKey, + sender_validation: SenderValidation, + ) -> Result { + let (res, ..) = Self::estimate_call_with_gas(data, msg, tsk, sender_validation) .await .context("gas estimation failed")?; match res.msg_rct { @@ -306,7 +317,13 @@ pub async fn estimate_message_gas( tsk: ApiTipsetKey, ) -> Result { if msg.gas_limit == 0 { - let gl = GasEstimateGasLimit::estimate_gas_limit(data, msg.clone(), &tsk).await?; + let gl = GasEstimateGasLimit::estimate_gas_limit( + data, + msg.clone(), + &tsk, + SenderValidation::Enforce, + ) + .await?; let gl = gl as f64 * data.mpool.gas_limit_overestimation(); msg.set_gas_limit((gl as u64).min(BLOCK_GAS_LIMIT)); } diff --git a/src/state_manager/errors.rs b/src/state_manager/errors.rs index 2651ffb3b3c6..06ec51dca637 100644 --- a/src/state_manager/errors.rs +++ b/src/state_manager/errors.rs @@ -18,11 +18,24 @@ pub enum Error { "required historical state unavailable: refusing explicit call due to state fork at epoch {epoch}" )] ExpensiveFork { epoch: ChainEpoch }, + /// Sender doesn't exist or isn't a valid sender type. + #[error("sender validation failed")] + SenderValidationFailed, /// Other state manager error #[error("{0}")] Other(String), } +/// Whether to enforce the FVM sender checks. +#[derive(Debug, Copy, Clone, Default, PartialEq, Eq)] +pub enum SenderValidation { + /// Enforce the FVM explicit-path sender checks. + #[default] + Enforce, + /// Skip sender validation for EVM contracts and non-existent senders. + Skip, +} + impl Error { pub fn state(e: impl Display) -> Self { Self::State(e.to_string()) diff --git a/src/state_manager/message_simulation.rs b/src/state_manager/message_simulation.rs index f2bc23dfe560..9335c2d50b81 100644 --- a/src/state_manager/message_simulation.rs +++ b/src/state_manager/message_simulation.rs @@ -8,7 +8,7 @@ use crate::interpreter::{ExecutionContext, IMPLICIT_MESSAGE_GAS_LIMIT, VM, VMTra use crate::message::{MessageRead as _, MessageReadWrite as _}; use crate::rpc::state::{ApiInvocResult, InvocResult, MessageGasCost}; use crate::shim::executor::ApplyRet; -use crate::shim::message::Message; +use crate::shim::message::{METHOD_SEND, Message}; use crate::state_migration::run_state_migrations; use std::time::Duration; use tracing::instrument; @@ -187,14 +187,27 @@ impl StateManager { tipset: Option, msg: Message, vm_flush: VMFlush, + sender_validation: SenderValidation, ) -> anyhow::Result<(ApiInvocResult, Option)> { let ts = tipset.unwrap_or_else(|| self.heaviest_tipset()); - let from_a = self.resolve_to_deterministic_address(msg.from, &ts).await?; - let chain_msg = ChainMessage::for_gas_estimation(msg.clone(), from_a.protocol()); + let from_protocol = match sender_validation { + SenderValidation::Skip => msg.from.protocol(), + SenderValidation::Enforce => self + .resolve_to_deterministic_address(msg.from, &ts) + .await? + .protocol(), + }; + let chain_msg = ChainMessage::for_gas_estimation(msg.clone(), from_protocol); let (_invoc_res, apply_ret, duration, state_root) = self - .call_with_gas(chain_msg, Default::default(), Some(ts), vm_flush) + .call_with_gas( + chain_msg, + Default::default(), + Some(ts), + vm_flush, + sender_validation, + ) .await?; Ok(( @@ -220,6 +233,7 @@ impl StateManager { prior_messages: Arc>, tipset: Option, vm_flush: VMFlush, + sender_validation: SenderValidation, ) -> Result<(InvocResult, ApplyRet, Duration, Option), Error> { let ts = tipset.unwrap_or_else(|| self.heaviest_tipset()); let TipsetState { state_root, .. } = self @@ -236,7 +250,7 @@ impl StateManager { tokio::task::spawn_blocking(move || { // FVM requires a stack size of 64MiB. The alternative is to use `ThreadedExecutor` from // FVM, but that introduces some constraints, and possible deadlocks. - let (ret, duration, state_cid) = stacker::grow(64 << 20, || -> anyhow::Result<_> { + let (ret, duration, state_cid) = stacker::grow(64 << 20, || -> Result<_, Error> { let mut vm = VM::new( ExecutionContext { heaviest_tipset: ts.clone(), @@ -261,13 +275,48 @@ impl StateManager { vm.apply_message(msg)?; } - let from_actor = vm + let mut sender_created = false; + let from_actor = match vm .get_actor(&message.from()) .map_err(|e| Error::Other(format!("Could not get actor from state: {e:#}")))? - .ok_or_else(|| Error::Other("cant find actor in state tree".to_string()))?; + { + Some(actor) => actor, + None => match sender_validation { + SenderValidation::Enforce => { + return Err(Error::SenderValidationFailed); + } + SenderValidation::Skip => { + let placeholder_send = Message { + from: Address::SYSTEM_ACTOR, + to: message.from(), + method_num: METHOD_SEND, + gas_limit: IMPLICIT_MESSAGE_GAS_LIMIT as u64, + ..Default::default() + }; + let (create_ret, _) = vm.apply_implicit_message(&placeholder_send)?; + if !create_ret.msg_receipt().exit_code().is_success() { + return Err(Error::Other(format!( + "failed to create ephemeral sender placeholder: exit code {}", + create_ret.msg_receipt().exit_code() + ))); + } + sender_created = true; + vm.get_actor(&message.from()) + .map_err(|e| { + Error::Other(format!("Could not get placeholder actor: {e:#}")) + })? + .ok_or(Error::SenderValidationFailed)? + } + }, + }; message.set_sequence(from_actor.sequence); - let (ret, duration) = vm.apply_message(&message)?; + let (ret, duration) = match (sender_validation, sender_created) { + (SenderValidation::Skip, false) => { + vm.apply_implicit_message(message.message())? + } + _ => vm.apply_message(&message)?, + }; let state_root = match vm_flush { VMFlush::Flush => Some(vm.flush()?), VMFlush::Skip => None, diff --git a/src/tool/subcommands/api_cmd/api_compare_tests.rs b/src/tool/subcommands/api_cmd/api_compare_tests.rs index 5a17a0675c89..b61eb96dd067 100644 --- a/src/tool/subcommands/api_cmd/api_compare_tests.rs +++ b/src/tool/subcommands/api_cmd/api_compare_tests.rs @@ -1635,6 +1635,43 @@ fn eth_call_api_err_tests(epoch: ChainEpoch) -> Vec { tests } +fn eth_call_estimate_skip_sender_tests(epoch: ChainEpoch) -> anyhow::Result> { + let mut tests = Vec::new(); + + let contract = EthAddress::from_filecoin_address(&Address::from_str(EVM_ADDRESS)?)?; + let senders = [contract, generate_eth_random_address()?]; + + for from in senders { + let msg = EthCallMessage { + from: Some(from), + to: Some(contract), + ..EthCallMessage::default() + }; + + for api_path in [ApiPaths::V1, ApiPaths::V2] { + tests.push( + RpcTest::identity( + EthCall::request((msg.clone(), BlockNumberOrHash::from_block_number(epoch)))? + .with_api_path(api_path), + ) + .policy_on_rejected(PolicyOnRejected::PassWithIdenticalError), + ); + tests.push( + RpcTest::identity( + EthEstimateGas::request(( + msg.clone(), + Some(BlockNumberOrHash::from_block_number(epoch)), + ))? + .with_api_path(api_path), + ) + .policy_on_rejected(PolicyOnRejected::PassWithIdenticalError), + ); + } + } + + Ok(tests) +} + fn eth_tests_with_tipset( store: &DB, shared_tipset: &Tipset, @@ -2498,6 +2535,9 @@ fn eth_state_tests_with_tipset( // Test eth_call API errors tests.extend(eth_call_api_err_tests(shared_tipset.epoch())); + // Test eth_call/eth_estimateGas from contract and non-existent senders + tests.extend(eth_call_estimate_skip_sender_tests(shared_tipset.epoch())?); + Ok(tests) } From 2b8123a866f145f3b99f4b07274cab2eb110e8bf Mon Sep 17 00:00:00 2001 From: Shashank Date: Mon, 3 Aug 2026 14:05:15 +0530 Subject: [PATCH 02/11] update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f41141b4dc1d..8403d3687cd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,8 @@ ### Fixed +- [#7435](https://github.com/ChainSafe/forest/pull/7435): `eth_call` and `eth_estimateGas` now accept a `from` address that is an EVM contract or that doesn't exist on chain, matching Lotus/Geth. + ## Forest v0.35.0 "Shravan" Non-mandatory release for all node operators. It includes some fixes and improvements, notably around state-related RPC. Note that this release contains breaking changes, so please read the changelog carefully before upgrading. From c9d8b403aae265e5e71dd36ac936f766fb42ba17 Mon Sep 17 00:00:00 2001 From: Shashank Date: Mon, 3 Aug 2026 14:54:07 +0530 Subject: [PATCH 03/11] update lotus image --- scripts/tests/api_compare/.env | 2 +- scripts/tests/bootstrapper/.env | 2 +- scripts/tests/snapshot_parity/.env | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/tests/api_compare/.env b/scripts/tests/api_compare/.env index 6173cf3e55fd..fa45ea7e6cb9 100644 --- a/scripts/tests/api_compare/.env +++ b/scripts/tests/api_compare/.env @@ -1,6 +1,6 @@ # Note: this should be a `fat` image so that it contains the pre-downloaded filecoin proof parameters FOREST_IMAGE=ghcr.io/chainsafe/forest:edge-fat -LOTUS_IMAGE=filecoin/lotus-all-in-one:c4e3b46b1-calibnet +LOTUS_IMAGE=filecoin/lotus-all-in-one:v1.36.2-calibnet FIL_PROOFS_PARAMETER_CACHE=/var/tmp/filecoin-proof-parameters LOTUS_RPC_PORT=1234 LOTUS_VIA_GATEWAY_RPC_PORT=4568 diff --git a/scripts/tests/bootstrapper/.env b/scripts/tests/bootstrapper/.env index b56c7864942e..e0f9f538c44b 100644 --- a/scripts/tests/bootstrapper/.env +++ b/scripts/tests/bootstrapper/.env @@ -1,5 +1,5 @@ # Note: this should be a `fat` image so that it contains the pre-downloaded filecoin proof parameters -LOTUS_IMAGE=filecoin/lotus-all-in-one:c4e3b46b1-calibnet +LOTUS_IMAGE=filecoin/lotus-all-in-one:v1.36.2-calibnet FIL_PROOFS_PARAMETER_CACHE=/var/tmp/filecoin-proof-parameters LOTUS_RPC_PORT=1234 FOREST_RPC_PORT=2345 diff --git a/scripts/tests/snapshot_parity/.env b/scripts/tests/snapshot_parity/.env index 1cdd4e9da868..1bd6d37a7217 100644 --- a/scripts/tests/snapshot_parity/.env +++ b/scripts/tests/snapshot_parity/.env @@ -1,4 +1,4 @@ -LOTUS_IMAGE=filecoin/lotus-all-in-one:c4e3b46b1-calibnet +LOTUS_IMAGE=filecoin/lotus-all-in-one:v1.36.2-calibnet FIL_PROOFS_PARAMETER_CACHE=/var/tmp/filecoin-proof-parameters LOTUS_RPC_PORT=1234 FOREST_RPC_PORT=2345 From 34090e92d5d2792f98f342e86971cacf2c365c40 Mon Sep 17 00:00:00 2001 From: Shashank Date: Tue, 4 Aug 2026 02:27:12 +0530 Subject: [PATCH 04/11] update test --- src/rpc/methods/eth.rs | 2 +- .../subcommands/api_cmd/api_compare_tests.rs | 37 +++++++++---------- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/src/rpc/methods/eth.rs b/src/rpc/methods/eth.rs index d49ea0f931d4..fd89f8dffb2c 100644 --- a/src/rpc/methods/eth.rs +++ b/src/rpc/methods/eth.rs @@ -1890,7 +1890,7 @@ async fn eth_estimate_gas( // guts of EthCall). This will give us an ethereum specific error with revert // information. msg.set_gas_limit(BLOCK_GAS_LIMIT); - let err = match apply_message(ctx, Some(tipset), msg).await { + let err = match apply_message(ctx, None, msg).await { Ok(_) => Error::msg(server_err.to_string()), Err(e) if e.downcast_ref::().is_some_and(|eth_err| { diff --git a/src/tool/subcommands/api_cmd/api_compare_tests.rs b/src/tool/subcommands/api_cmd/api_compare_tests.rs index b61eb96dd067..d6f7c2e42814 100644 --- a/src/tool/subcommands/api_cmd/api_compare_tests.rs +++ b/src/tool/subcommands/api_cmd/api_compare_tests.rs @@ -1638,34 +1638,33 @@ fn eth_call_api_err_tests(epoch: ChainEpoch) -> Vec { fn eth_call_estimate_skip_sender_tests(epoch: ChainEpoch) -> anyhow::Result> { let mut tests = Vec::new(); - let contract = EthAddress::from_filecoin_address(&Address::from_str(EVM_ADDRESS)?)?; - let senders = [contract, generate_eth_random_address()?]; + let to = EthAddress::from_str("0x0c1d86d34e469770339b53613f3a2343accd62cb")?; + let calldata: EthBytes = + "0xf8b2cb4f000000000000000000000000CbfF24DED1CE6B53712078759233Ac8f91ea71B6".parse()?; + + let contract_sender = EthAddress::from_filecoin_address(&Address::from_str(EVM_ADDRESS)?)?; + let senders = [contract_sender, generate_eth_random_address()?]; for from in senders { let msg = EthCallMessage { from: Some(from), - to: Some(contract), + to: Some(to), + data: Some(calldata.clone()), ..EthCallMessage::default() }; for api_path in [ApiPaths::V1, ApiPaths::V2] { - tests.push( - RpcTest::identity( - EthCall::request((msg.clone(), BlockNumberOrHash::from_block_number(epoch)))? - .with_api_path(api_path), - ) - .policy_on_rejected(PolicyOnRejected::PassWithIdenticalError), - ); - tests.push( - RpcTest::identity( - EthEstimateGas::request(( - msg.clone(), - Some(BlockNumberOrHash::from_block_number(epoch)), - ))? + tests.push(RpcTest::identity( + EthCall::request((msg.clone(), BlockNumberOrHash::from_block_number(epoch)))? .with_api_path(api_path), - ) - .policy_on_rejected(PolicyOnRejected::PassWithIdenticalError), - ); + )); + tests.push(RpcTest::identity( + EthEstimateGas::request(( + msg.clone(), + Some(BlockNumberOrHash::from_block_number(epoch)), + ))? + .with_api_path(api_path), + )); } } From f606af7a1a1fae9d30dd54dfde19a3d74841109d Mon Sep 17 00:00:00 2001 From: Shashank Date: Tue, 4 Aug 2026 12:44:56 +0530 Subject: [PATCH 05/11] match lotus fallback --- src/rpc/methods/eth.rs | 11 +++++++++-- src/rpc/methods/gas.rs | 26 ++++++++++++++++---------- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/rpc/methods/eth.rs b/src/rpc/methods/eth.rs index fd89f8dffb2c..d20f93aa6f32 100644 --- a/src/rpc/methods/eth.rs +++ b/src/rpc/methods/eth.rs @@ -1883,6 +1883,13 @@ async fn eth_estimate_gas( match gas::estimate_message_gas(ctx, msg.clone(), None, tipset.key().clone().into()).await { Err(server_err) => { + if server_err + .downcast_ref::() + .is_some_and(|e| matches!(e, crate::state_manager::Error::SenderValidationFailed)) + { + return eth_estimate_gas_skip_sender(ctx, msg, tipset).await; + } + // On failure, GasEstimateMessageGas doesn't actually return the invocation result, // it just returns an error. That means we can't get the revert reason. // @@ -1890,8 +1897,8 @@ async fn eth_estimate_gas( // guts of EthCall). This will give us an ethereum specific error with revert // information. msg.set_gas_limit(BLOCK_GAS_LIMIT); - let err = match apply_message(ctx, None, msg).await { - Ok(_) => Error::msg(server_err.to_string()), + let err = match apply_message(ctx, Some(tipset), msg).await { + Ok(_) => server_err, Err(e) if e.downcast_ref::().is_some_and(|eth_err| { matches!(eth_err, EthErrors::ExecutionReverted { .. }) diff --git a/src/rpc/methods/gas.rs b/src/rpc/methods/gas.rs index 095a7ceb1581..eb327ce5d512 100644 --- a/src/rpc/methods/gas.rs +++ b/src/rpc/methods/gas.rs @@ -219,11 +219,11 @@ impl GasEstimateGasLimit { let curr_ts = data.chain_store().load_required_tipset_or_heaviest(tsk)?; let from_a = match sender_validation { SenderValidation::Skip => msg.from, - SenderValidation::Enforce => { - data.state_manager - .resolve_to_deterministic_address(msg.from, &curr_ts) - .await? - } + SenderValidation::Enforce => data + .state_manager + .resolve_to_deterministic_address(msg.from, &curr_ts) + .await + .map_err(|_| crate::state_manager::Error::SenderValidationFailed)?, }; let pending = data.mpool.pending_for(&from_a).await; @@ -275,13 +275,19 @@ impl GasEstimateGasLimit { .context("gas estimation failed")?; match res.msg_rct { Some(rct) => { - anyhow::ensure!( - rct.exit_code().is_success(), + if rct.exit_code().is_success() { + return Ok(rct.gas_used() as i64); + } + if sender_validation == SenderValidation::Enforce + && rct.exit_code() == fvm_shared4::error::ExitCode::SYS_SENDER_INVALID + { + return Err(crate::state_manager::Error::SenderValidationFailed.into()); + } + anyhow::bail!( "message execution failed: exit code: {}, reason: {}", rct.exit_code().value(), res.error.unwrap_or_default() - ); - Ok(rct.gas_used() as i64) + ) } None => Ok(-1), } @@ -315,7 +321,7 @@ pub async fn estimate_message_gas( mut msg: Message, msg_spec: Option, tsk: ApiTipsetKey, -) -> Result { +) -> anyhow::Result { if msg.gas_limit == 0 { let gl = GasEstimateGasLimit::estimate_gas_limit( data, From 6e8a78843582dc06d4c1697ede4cad468a7f15e7 Mon Sep 17 00:00:00 2001 From: Shashank Date: Fri, 7 Aug 2026 07:24:12 +0530 Subject: [PATCH 06/11] Add more tests --- .../subcommands/api_cmd/api_compare_tests.rs | 154 ++++++++++++++---- 1 file changed, 123 insertions(+), 31 deletions(-) diff --git a/src/tool/subcommands/api_cmd/api_compare_tests.rs b/src/tool/subcommands/api_cmd/api_compare_tests.rs index d6f7c2e42814..d027618dfae5 100644 --- a/src/tool/subcommands/api_cmd/api_compare_tests.rs +++ b/src/tool/subcommands/api_cmd/api_compare_tests.rs @@ -13,7 +13,7 @@ use crate::rpc; use crate::rpc::auth::AuthNewParams; use crate::rpc::beacon::BeaconGetEntry; use crate::rpc::eth::{ - ApiEthTx, BlockNumberOrHash, EthInt64, Predefined, new_eth_tx_from_signed_message, + ApiEthTx, BlockNumberOrHash, EthBigInt, EthInt64, Predefined, new_eth_tx_from_signed_message, trace::types::*, types::*, }; use crate::rpc::gas::{GasEstimateGasLimit, GasEstimateMessageGas}; @@ -261,6 +261,7 @@ pub struct TestResult { pub duration: Duration, } +#[derive(Clone)] pub(super) enum PolicyOnRejected { Fail, Pass, @@ -1635,42 +1636,134 @@ fn eth_call_api_err_tests(epoch: ChainEpoch) -> Vec { tests } -fn eth_call_estimate_skip_sender_tests(epoch: ChainEpoch) -> anyhow::Result> { - let mut tests = Vec::new(); - - let to = EthAddress::from_str("0x0c1d86d34e469770339b53613f3a2343accd62cb")?; - let calldata: EthBytes = - "0xf8b2cb4f000000000000000000000000CbfF24DED1CE6B53712078759233Ac8f91ea71B6".parse()?; - - let contract_sender = EthAddress::from_filecoin_address(&Address::from_str(EVM_ADDRESS)?)?; - let senders = [contract_sender, generate_eth_random_address()?]; +const SKIP_SENDER_CONTRACT: &str = "0x0c1d86d34e469770339b53613f3a2343accd62cb"; +const SKIP_SENDER_CALLDATA: &str = + "0xf8b2cb4f000000000000000000000000CbfF24DED1CE6B53712078759233Ac8f91ea71B6"; - for from in senders { - let msg = EthCallMessage { - from: Some(from), - to: Some(to), - data: Some(calldata.clone()), - ..EthCallMessage::default() - }; +fn eth_skip_sender_tests(epoch: ChainEpoch) -> anyhow::Result> { + let mut tests = eth_skip_sender_success_tests(epoch)?; + tests.extend(eth_skip_sender_insufficient_funds_tests(epoch)?); + tests.extend(eth_skip_sender_create_reject_tests(epoch)?); + tests.extend(eth_skip_sender_block_param_tests(epoch)?); + Ok(tests) +} +fn eth_skip_sender_cases( + epoch: ChainEpoch, + policy: PolicyOnRejected, + messages: impl IntoIterator, +) -> anyhow::Result> { + let mut tests = Vec::new(); + for msg in messages { for api_path in [ApiPaths::V1, ApiPaths::V2] { - tests.push(RpcTest::identity( - EthCall::request((msg.clone(), BlockNumberOrHash::from_block_number(epoch)))? - .with_api_path(api_path), - )); - tests.push(RpcTest::identity( - EthEstimateGas::request(( - msg.clone(), - Some(BlockNumberOrHash::from_block_number(epoch)), - ))? - .with_api_path(api_path), - )); + let block = BlockNumberOrHash::from_block_number(epoch); + tests.push( + RpcTest::identity( + EthCall::request((msg.clone(), block.clone()))?.with_api_path(api_path), + ) + .policy_on_rejected(policy.clone()), + ); + tests.push( + RpcTest::identity( + EthEstimateGas::request((msg.clone(), Some(block)))?.with_api_path(api_path), + ) + .policy_on_rejected(policy.clone()), + ); } } - Ok(tests) } +fn eth_skip_sender_success_tests(epoch: ChainEpoch) -> anyhow::Result> { + let to = EthAddress::from_str(SKIP_SENDER_CONTRACT)?; + let calldata: EthBytes = SKIP_SENDER_CALLDATA.parse()?; + let initcode = + EthBytes::from_str(concat!("0x", include_str!("contracts/cthulhu/invoke.hex")).trim())?; + let contract = EthAddress::from_filecoin_address(&Address::from_str(EVM_ADDRESS)?)?; + let non_existent = generate_eth_random_address()?; + let gas_price = EthBigInt::from(1_000_000_000_u64); + + let messages = [ + // `from` is an existing EVM contract. + (Some(contract), Some(to), Some(calldata.clone()), None), + // `from` is an address that does not exist on chain. + (Some(non_existent), Some(to), Some(calldata.clone()), None), + // Same as above, but with gasPrice set — it should be ignored. + (Some(contract), Some(to), Some(calldata.clone()), Some(gas_price)), + (Some(non_existent), Some(to), Some(calldata.clone()), Some(gas_price)), + // `from` and `to` are the same contract. + (Some(contract), Some(contract), Some(calldata.clone()), None), + // No `from` field — should still succeed. + (None, Some(to), Some(calldata), None), + // No `to` means contract creation; `from` does not exist on chain. + (Some(non_existent), None, Some(initcode), None), + ] + .map(|(from, to, data, gas_price)| EthCallMessage { + from, + to, + data, + gas_price, + ..Default::default() + }); + eth_skip_sender_cases(epoch, PolicyOnRejected::Fail, messages) +} + +fn eth_skip_sender_insufficient_funds_tests(epoch: ChainEpoch) -> anyhow::Result> { + let to = EthAddress::from_str(SKIP_SENDER_CONTRACT)?; + let contract = EthAddress::from_filecoin_address(&Address::from_str(EVM_ADDRESS)?)?; + let non_existent = generate_eth_random_address()?; + let eoa = EthAddress::from_filecoin_address(&KNOWN_CALIBNET_F4_ADDRESS)?; + let value = EthBigInt::from(TokenAmount::from_whole(1_000_000)); + + // Value is higher than the sender's balance. + let messages = [contract, non_existent, eoa] + .map(|from| EthCallMessage { + from: Some(from), + to: Some(to), + value: Some(value), + ..Default::default() + }); + eth_skip_sender_cases(epoch, PolicyOnRejected::PassWithIdenticalError, messages) +} + +fn eth_skip_sender_create_reject_tests(epoch: ChainEpoch) -> anyhow::Result> { + let initcode = + EthBytes::from_str(concat!("0x", include_str!("contracts/cthulhu/invoke.hex")).trim())?; + let div_zero = EthBytes::from_str(include_str!( + "./contracts/divide_by_zero_err/divide_by_zero_err.hex" + ))?; + let assert_err = EthBytes::from_str(include_str!("contracts/assert_err/assert_err.hex"))?; + let contract = EthAddress::from_filecoin_address(&Address::from_str(EVM_ADDRESS)?)?; + let non_existent = generate_eth_random_address()?; + + let messages = [ + // Contract creation with a contract as `from` is not allowed. + (Some(contract), initcode), + // Contract creation whose init code fails via divide by zero. + (Some(contract), div_zero.clone()), + (Some(non_existent), div_zero), + // Contract creation whose init code fails via assert. + (Some(contract), assert_err.clone()), + (Some(non_existent), assert_err), + ] + .map(|(from, data)| EthCallMessage { + from, + data: Some(data), + ..Default::default() + }); + eth_skip_sender_cases(epoch, PolicyOnRejected::PassWithIdenticalError, messages) +} + +fn eth_skip_sender_block_param_tests(epoch: ChainEpoch) -> anyhow::Result> { + let to = EthAddress::from_str(SKIP_SENDER_CONTRACT)?; + // Asking for a block in the future — both nodes should reject + let messages = [EthCallMessage { + to: Some(to), + ..Default::default() + }]; + eth_skip_sender_cases(epoch + 1000, PolicyOnRejected::Pass, messages) +} + fn eth_tests_with_tipset( store: &DB, shared_tipset: &Tipset, @@ -2534,8 +2627,7 @@ fn eth_state_tests_with_tipset( // Test eth_call API errors tests.extend(eth_call_api_err_tests(shared_tipset.epoch())); - // Test eth_call/eth_estimateGas from contract and non-existent senders - tests.extend(eth_call_estimate_skip_sender_tests(shared_tipset.epoch())?); + tests.extend(eth_skip_sender_tests(shared_tipset.epoch())?); Ok(tests) } From d9a6b5a200ef02b30edcf635c7a63431e4f34d50 Mon Sep 17 00:00:00 2001 From: Shashank Date: Fri, 7 Aug 2026 07:24:26 +0530 Subject: [PATCH 07/11] Update latest lotus image --- scripts/devnet/.env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/devnet/.env b/scripts/devnet/.env index 32f677915e23..5fd084c841f9 100644 --- a/scripts/devnet/.env +++ b/scripts/devnet/.env @@ -1,4 +1,4 @@ -LOTUS_IMAGE=filecoin/lotus-all-in-one:c4e3b46b1-2k +LOTUS_IMAGE=filecoin/lotus-all-in-one:v1.36.2-2k FOREST_DATA_DIR=/forest_data LOTUS_DATA_DIR=/lotus_data FIL_PROOFS_PARAMETER_CACHE=/var/tmp/filecoin-proof-parameters From 7a495b88f00f51c63318e6ba4d55121371ce7137 Mon Sep 17 00:00:00 2001 From: Shashank Date: Fri, 7 Aug 2026 08:14:57 +0530 Subject: [PATCH 08/11] fmt --- src/rpc/methods/eth.rs | 10 ++++-- src/rpc/methods/gas.rs | 31 ++++++++----------- src/state_manager/message_simulation.rs | 8 ++++- .../subcommands/api_cmd/api_compare_tests.rs | 17 +++++++--- 4 files changed, 41 insertions(+), 25 deletions(-) diff --git a/src/rpc/methods/eth.rs b/src/rpc/methods/eth.rs index a1121ebe785e..1f090a459219 100644 --- a/src/rpc/methods/eth.rs +++ b/src/rpc/methods/eth.rs @@ -2042,9 +2042,15 @@ async fn apply_message( Ok(invoc_res) } -pub async fn eth_gas_search(data: &Ctx, msg: Message, tsk: &ApiTipsetKey, sender_validation: SenderValidation,) -> anyhow::Result { +pub async fn eth_gas_search( + data: &Ctx, + msg: Message, + tsk: &ApiTipsetKey, + sender_validation: SenderValidation, +) -> anyhow::Result { let (apply_ret, prior_messages, ts) = - gas::GasEstimateGasLimit::estimate_call_with_gas(data, msg.clone(), tsk, sender_validation).await?; + gas::GasEstimateGasLimit::estimate_call_with_gas(data, msg.clone(), tsk, sender_validation) + .await?; if apply_ret.exit_code().is_success() { return Ok(msg.gas_limit()); } diff --git a/src/rpc/methods/gas.rs b/src/rpc/methods/gas.rs index 6c3b6ee51917..3c15cc9836db 100644 --- a/src/rpc/methods/gas.rs +++ b/src/rpc/methods/gas.rs @@ -269,27 +269,22 @@ impl GasEstimateGasLimit { tsk: &ApiTipsetKey, sender_validation: SenderValidation, ) -> Result { - let (res, ..) = Self::estimate_call_with_gas(data, msg, tsk, sender_validation) + let (apply_ret, ..) = Self::estimate_call_with_gas(data, msg, tsk, sender_validation) .await .context("gas estimation failed")?; - match res.msg_rct { - Some(rct) => { - if rct.exit_code().is_success() { - return Ok(rct.gas_used() as i64); - } - if sender_validation == SenderValidation::Enforce - && rct.exit_code() == fvm_shared4::error::ExitCode::SYS_SENDER_INVALID - { - return Err(crate::state_manager::Error::SenderValidationFailed.into()); - } - anyhow::bail!( - "message execution failed: exit code: {}, reason: {}", - rct.exit_code().value(), - res.failure_info().unwrap_or_default() - ) - } - None => Ok(-1), + if apply_ret.exit_code().is_success() { + return Ok(apply_ret.gas_used() as i64); + } + if sender_validation == SenderValidation::Enforce + && apply_ret.exit_code() == fvm_shared4::error::ExitCode::SYS_SENDER_INVALID + { + return Err(crate::state_manager::Error::SenderValidationFailed.into()); } + anyhow::bail!( + "message execution failed: exit code: {}, reason: {}", + apply_ret.exit_code().value(), + apply_ret.failure_info().unwrap_or_default() + ) } } diff --git a/src/state_manager/message_simulation.rs b/src/state_manager/message_simulation.rs index cdacfc1bcf09..11beaaaa0651 100644 --- a/src/state_manager/message_simulation.rs +++ b/src/state_manager/message_simulation.rs @@ -199,7 +199,13 @@ impl StateManager { let chain_msg = ChainMessage::for_gas_estimation(msg.clone(), from_protocol); let (apply_ret, duration, state_root) = self - .call_with_gas(chain_msg, Default::default(), Some(ts), vm_flush, sender_validation) + .call_with_gas( + chain_msg, + Default::default(), + Some(ts), + vm_flush, + sender_validation, + ) .await?; Ok(( diff --git a/src/tool/subcommands/api_cmd/api_compare_tests.rs b/src/tool/subcommands/api_cmd/api_compare_tests.rs index 26a3edbda448..d220297cb0b8 100644 --- a/src/tool/subcommands/api_cmd/api_compare_tests.rs +++ b/src/tool/subcommands/api_cmd/api_compare_tests.rs @@ -1697,8 +1697,18 @@ fn eth_skip_sender_success_tests(epoch: ChainEpoch) -> anyhow::Result anyhow::Result let value = EthBigInt::from(TokenAmount::from_whole(1_000_000)); // Value is higher than the sender's balance. - let messages = [contract, non_existent, eoa] - .map(|from| EthCallMessage { + let messages = [contract, non_existent, eoa].map(|from| EthCallMessage { from: Some(from), to: Some(to), value: Some(value), From fa5325c9bdf677062b63952c1342a2bc305f37b1 Mon Sep 17 00:00:00 2001 From: Shashank Date: Fri, 7 Aug 2026 08:30:04 +0530 Subject: [PATCH 09/11] Add test snapshots --- .../subcommands/api_cmd/test_snapshots.txt | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/tool/subcommands/api_cmd/test_snapshots.txt b/src/tool/subcommands/api_cmd/test_snapshots.txt index a89ad79de311..12b5e5bdc174 100644 --- a/src/tool/subcommands/api_cmd/test_snapshots.txt +++ b/src/tool/subcommands/api_cmd/test_snapshots.txt @@ -57,10 +57,34 @@ filecoin_ethblocknumber_1741272348346171.rpcsnap.json.zst filecoin_ethcall_1744204533050503.rpcsnap.json.zst filecoin_ethcall_1744204533058637.rpcsnap.json.zst filecoin_ethcall_1744204533066529.rpcsnap.json.zst +filecoin_ethcall_1786069718279002.rpcsnap.json.zst +filecoin_ethcall_1786069718279056.rpcsnap.json.zst +filecoin_ethcall_1786069718279137.rpcsnap.json.zst +filecoin_ethcall_1786069718279191.rpcsnap.json.zst +filecoin_ethcall_1786069718279253.rpcsnap.json.zst +filecoin_ethcall_1786069718284042.rpcsnap.json.zst +filecoin_ethcall_1786069718288176.rpcsnap.json.zst +filecoin_ethcall_1786069718289675.rpcsnap.json.zst +filecoin_ethcall_1786069718290119.rpcsnap.json.zst +filecoin_ethcall_1786069718291894.rpcsnap.json.zst +filecoin_ethcall_1786069718294611.rpcsnap.json.zst +filecoin_ethcall_1786069718294827.rpcsnap.json.zst filecoin_ethcall_v2_1765790311230200.rpcsnap.json.zst filecoin_ethcall_v2_1765790311230270.rpcsnap.json.zst filecoin_ethcall_v2_1765790311230334.rpcsnap.json.zst filecoin_ethchainid_1736937942819147.rpcsnap.json.zst +filecoin_ethestimategas_1786068422338127.rpcsnap.json.zst +filecoin_ethestimategas_1786068422541133.rpcsnap.json.zst +filecoin_ethestimategas_1786068422578959.rpcsnap.json.zst +filecoin_ethestimategas_1786068422812695.rpcsnap.json.zst +filecoin_ethestimategas_1786068422839478.rpcsnap.json.zst +filecoin_ethestimategas_1786068422839752.rpcsnap.json.zst +filecoin_ethestimategas_1786068422949851.rpcsnap.json.zst +filecoin_ethestimategas_1786068423116751.rpcsnap.json.zst +filecoin_ethestimategas_1786068423239597.rpcsnap.json.zst +filecoin_ethestimategas_1786068423870066.rpcsnap.json.zst +filecoin_ethestimategas_1786068423945207.rpcsnap.json.zst +filecoin_ethestimategas_1786068423971276.rpcsnap.json.zst filecoin_ethfeehistory_1781166099973654.rpcsnap.json.zst filecoin_ethfeehistory_v2_1781166099990041.rpcsnap.json.zst filecoin_ethgasprice_1758725940980141.rpcsnap.json.zst From d1abb35a2894993a3abc3d8ff1d24692c91f4d0f Mon Sep 17 00:00:00 2001 From: Shashank Date: Mon, 10 Aug 2026 13:05:36 +0530 Subject: [PATCH 10/11] cleanup tests --- .../subcommands/api_cmd/api_compare_tests.rs | 47 +++++-------------- .../subcommands/api_cmd/test_snapshots.txt | 32 ++++--------- 2 files changed, 20 insertions(+), 59 deletions(-) diff --git a/src/tool/subcommands/api_cmd/api_compare_tests.rs b/src/tool/subcommands/api_cmd/api_compare_tests.rs index d220297cb0b8..1730057b7629 100644 --- a/src/tool/subcommands/api_cmd/api_compare_tests.rs +++ b/src/tool/subcommands/api_cmd/api_compare_tests.rs @@ -1662,22 +1662,16 @@ fn eth_skip_sender_cases( messages: impl IntoIterator, ) -> anyhow::Result> { let mut tests = Vec::new(); + let block = BlockNumberOrHash::from_block_number(epoch); for msg in messages { - for api_path in [ApiPaths::V1, ApiPaths::V2] { - let block = BlockNumberOrHash::from_block_number(epoch); - tests.push( - RpcTest::identity( - EthCall::request((msg.clone(), block.clone()))?.with_api_path(api_path), - ) + tests.push( + RpcTest::identity(EthCall::request((msg.clone(), block.clone()))?) .policy_on_rejected(policy.clone()), - ); - tests.push( - RpcTest::identity( - EthEstimateGas::request((msg.clone(), Some(block)))?.with_api_path(api_path), - ) + ); + tests.push( + RpcTest::identity(EthEstimateGas::request((msg.clone(), Some(block.clone())))?) .policy_on_rejected(policy.clone()), - ); - } + ); } Ok(tests) } @@ -1689,38 +1683,21 @@ fn eth_skip_sender_success_tests(epoch: ChainEpoch) -> anyhow::Result Date: Tue, 11 Aug 2026 08:25:03 +0530 Subject: [PATCH 11/11] fix --- src/rpc/methods/eth.rs | 33 +++++------ src/rpc/methods/gas.rs | 23 ++++++-- src/state_manager/errors.rs | 14 +---- src/state_manager/message_simulation.rs | 22 ++++++-- src/state_manager/mod.rs | 10 ++++ .../subcommands/api_cmd/api_compare_tests.rs | 55 +++++++++---------- 6 files changed, 89 insertions(+), 68 deletions(-) diff --git a/src/rpc/methods/eth.rs b/src/rpc/methods/eth.rs index 1f090a459219..cb39097e2012 100644 --- a/src/rpc/methods/eth.rs +++ b/src/rpc/methods/eth.rs @@ -1877,15 +1877,19 @@ async fn eth_estimate_gas( // gas estimation actually run. msg.gas_limit = 0; - if resolve_sender_validation(ctx, &msg.from, &tipset).await == SenderValidation::Skip { + if sender_validation_for(ctx, &msg.from, &tipset) == SenderValidation::Skip { return eth_estimate_gas_skip_sender(ctx, msg, tipset).await; } match gas::estimate_message_gas(ctx, msg.clone(), None, tipset.key().clone().into()).await { Err(server_err) => { + // Covers both a sender that doesn't exist (address resolution failed) and one the FVM + // rejected in preflight; either way, retry with skip-sender-validation. if server_err .downcast_ref::() - .is_some_and(|e| matches!(e, crate::state_manager::Error::SenderValidationFailed)) + .is_some_and(|e| { + matches!(e, crate::state_manager::Error::SenderValidationFailed(_)) + }) { return eth_estimate_gas_skip_sender(ctx, msg, tipset).await; } @@ -1924,18 +1928,11 @@ async fn eth_estimate_gas( } } -/// Returns [`SenderValidation::Skip`] for an EVM-contract or non-existent sender, which the FVM -/// can't validate. Defaults to [`SenderValidation::Enforce`]. -async fn resolve_sender_validation( - ctx: &Ctx, - from: &FilecoinAddress, - tipset: &Tipset, -) -> SenderValidation { - let Ok(state) = ctx.state_manager.load_tipset_state(tipset).await else { - return SenderValidation::Enforce; - }; - match ctx.state_manager.get_actor(from, state.state_root) { - Ok(None) => SenderValidation::Skip, +/// An EVM contract sender fails the FVM's explicit-path sender check, so detect it up front and +/// estimate via the skip-sender-validation path, as Geth allows. A sender that doesn't exist at all +/// is caught when gas estimation fails. +fn sender_validation_for(ctx: &Ctx, from: &FilecoinAddress, tipset: &Tipset) -> SenderValidation { + match ctx.state_manager.get_actor(from, *tipset.parent_state()) { Ok(Some(actor)) if is_evm_actor(&actor.code) => SenderValidation::Skip, _ => SenderValidation::Enforce, } @@ -1970,9 +1967,7 @@ async fn eth_estimate_gas_skip_sender( } }; - let gas_limit = - ((gas_limit as f64 * ctx.mpool.gas_limit_overestimation()) as u64).min(BLOCK_GAS_LIMIT); - msg.set_gas_limit(gas_limit); + msg.set_gas_limit(gas::overestimate_gas_limit(ctx, gas_limit)); let expected_gas = eth_gas_search(ctx, msg, &tsk, SenderValidation::Skip).await?; Ok(expected_gas.into()) @@ -2002,10 +1997,12 @@ async fn apply_message( ) .await; + // A missing sender surfaces as `SenderValidationFailed`; a sender the FVM rejected in preflight + // (e.g. a contract) surfaces as a `SYS_SENDER_INVALID` receipt. let needs_skip = match &result { Err(e) => e .downcast_ref::() - .is_some_and(|e| matches!(e, crate::state_manager::Error::SenderValidationFailed)), + .is_some_and(|e| matches!(e, crate::state_manager::Error::SenderValidationFailed(_))), Ok((invoc_res, _)) => invoc_res .msg_rct .as_ref() diff --git a/src/rpc/methods/gas.rs b/src/rpc/methods/gas.rs index 3c15cc9836db..8acde4413dc8 100644 --- a/src/rpc/methods/gas.rs +++ b/src/rpc/methods/gas.rs @@ -222,7 +222,12 @@ impl GasEstimateGasLimit { .state_manager .resolve_to_deterministic_address(msg.from, &curr_ts) .await - .map_err(|_| crate::state_manager::Error::SenderValidationFailed)?, + .map_err(|e| { + crate::state_manager::Error::SenderValidationFailed(format!( + "resolving sender {}: {e:#}", + msg.from + )) + })?, }; let pending = data.mpool.pending_for(&from_a).await; @@ -278,7 +283,12 @@ impl GasEstimateGasLimit { if sender_validation == SenderValidation::Enforce && apply_ret.exit_code() == fvm_shared4::error::ExitCode::SYS_SENDER_INVALID { - return Err(crate::state_manager::Error::SenderValidationFailed.into()); + return Err(crate::state_manager::Error::SenderValidationFailed(format!( + "message execution failed (exit=[{}], vm error=[{}])", + apply_ret.exit_code(), + apply_ret.failure_info().unwrap_or_default() + )) + .into()); } anyhow::bail!( "message execution failed: exit code: {}, reason: {}", @@ -310,6 +320,12 @@ impl RpcMethod<3> for GasEstimateMessageGas { } } +/// Applies the message pool's overestimation multiplier to an estimated gas limit and clamps it to +/// the block gas limit. +pub fn overestimate_gas_limit(data: &Ctx, gas_limit: i64) -> u64 { + ((gas_limit as f64 * data.mpool.gas_limit_overestimation()) as u64).min(BLOCK_GAS_LIMIT) +} + pub async fn estimate_message_gas( data: &Ctx, mut msg: Message, @@ -324,8 +340,7 @@ pub async fn estimate_message_gas( SenderValidation::Enforce, ) .await?; - let gl = gl as f64 * data.mpool.gas_limit_overestimation(); - msg.set_gas_limit((gl as u64).min(BLOCK_GAS_LIMIT)); + msg.set_gas_limit(overestimate_gas_limit(data, gl)); } if msg.gas_premium.is_zero() { let gp = estimate_gas_premium(data, 10, &tsk).await?; diff --git a/src/state_manager/errors.rs b/src/state_manager/errors.rs index 06ec51dca637..d5bb45a5143f 100644 --- a/src/state_manager/errors.rs +++ b/src/state_manager/errors.rs @@ -19,23 +19,13 @@ pub enum Error { )] ExpensiveFork { epoch: ChainEpoch }, /// Sender doesn't exist or isn't a valid sender type. - #[error("sender validation failed")] - SenderValidationFailed, + #[error("sender validation failed: {0}")] + SenderValidationFailed(String), /// Other state manager error #[error("{0}")] Other(String), } -/// Whether to enforce the FVM sender checks. -#[derive(Debug, Copy, Clone, Default, PartialEq, Eq)] -pub enum SenderValidation { - /// Enforce the FVM explicit-path sender checks. - #[default] - Enforce, - /// Skip sender validation for EVM contracts and non-existent senders. - Skip, -} - impl Error { pub fn state(e: impl Display) -> Self { Self::State(e.to_string()) diff --git a/src/state_manager/message_simulation.rs b/src/state_manager/message_simulation.rs index 11beaaaa0651..0834b984c976 100644 --- a/src/state_manager/message_simulation.rs +++ b/src/state_manager/message_simulation.rs @@ -193,7 +193,10 @@ impl StateManager { SenderValidation::Skip => msg.from.protocol(), SenderValidation::Enforce => self .resolve_to_deterministic_address(msg.from, &ts) - .await? + .await + .map_err(|e| { + Error::SenderValidationFailed(format!("resolving sender {}: {e:#}", msg.from)) + })? .protocol(), }; let chain_msg = ChainMessage::for_gas_estimation(msg.clone(), from_protocol); @@ -280,7 +283,10 @@ impl StateManager { Some(actor) => actor, None => match sender_validation { SenderValidation::Enforce => { - return Err(Error::SenderValidationFailed); + return Err(Error::SenderValidationFailed(format!( + "sender {} not found on chain", + message.from() + ))); } SenderValidation::Skip => { let placeholder_send = Message { @@ -293,8 +299,9 @@ impl StateManager { let (create_ret, _) = vm.apply_implicit_message(&placeholder_send)?; if !create_ret.msg_receipt().exit_code().is_success() { return Err(Error::Other(format!( - "failed to create ephemeral sender placeholder: exit code {}", - create_ret.msg_receipt().exit_code() + "failed to create ephemeral sender placeholder (exit {}): {}", + create_ret.msg_receipt().exit_code(), + create_ret.failure_info().unwrap_or_default() ))); } sender_created = true; @@ -302,7 +309,12 @@ impl StateManager { .map_err(|e| { Error::Other(format!("Could not get placeholder actor: {e:#}")) })? - .ok_or(Error::SenderValidationFailed)? + .ok_or_else(|| { + Error::SenderValidationFailed(format!( + "ephemeral sender placeholder {} missing after creation", + message.from() + )) + })? } }, }; diff --git a/src/state_manager/mod.rs b/src/state_manager/mod.rs index 3709d5202ad0..61d046530999 100644 --- a/src/state_manager/mod.rs +++ b/src/state_manager/mod.rs @@ -215,6 +215,16 @@ pub enum VMFlush { Skip, } +/// Whether to enforce the FVM sender checks. +#[derive(Debug, Copy, Clone, Default, PartialEq, Eq)] +pub enum SenderValidation { + /// Enforce the FVM explicit-path sender checks. + #[default] + Enforce, + /// Skip sender validation for EVM contracts and non-existent senders. + Skip, +} + impl StateManager { pub fn new(cs: ChainStore) -> anyhow::Result { Self::new_with_engine(cs, GLOBAL_MULTI_ENGINE.clone()) diff --git a/src/tool/subcommands/api_cmd/api_compare_tests.rs b/src/tool/subcommands/api_cmd/api_compare_tests.rs index 1730057b7629..64b328b23554 100644 --- a/src/tool/subcommands/api_cmd/api_compare_tests.rs +++ b/src/tool/subcommands/api_cmd/api_compare_tests.rs @@ -145,6 +145,10 @@ const ZERO_ADDRESS: &str = "0x0000000000000000000000000000000000000000"; const MINER_ADDRESS: Address = Address::new_id(78216); // https://calibration.filscan.io/en/miner/t078216 const ACCOUNT_ADDRESS: Address = Address::new_id(1234); // account actor address `t01234` const EVM_ADDRESS: &str = "t410fbqoynu2oi2lxam43knqt6ordiowm2ywlml27z4i"; +// A calibnet EVM contract, and a `getBalance(address)` call against it. +const CALIBNET_EVM_CONTRACT: &str = "0x0c1d86d34e469770339b53613f3a2343accd62cb"; +const GET_BALANCE_CALLDATA: &str = + "0xf8b2cb4f000000000000000000000000CbfF24DED1CE6B53712078759233Ac8f91ea71B6"; /// Brief description of a single method call against a single host #[derive( @@ -261,7 +265,7 @@ pub struct TestResult { pub duration: Duration, } -#[derive(Clone)] +#[derive(Clone, Copy)] pub(super) enum PolicyOnRejected { Fail, Pass, @@ -1500,13 +1504,8 @@ fn eth_tests(server_mode: ServerMode) -> anyhow::Result> { let cases = [ ( - Some(EthAddress::from_str( - "0x0c1d86d34e469770339b53613f3a2343accd62cb", - )?), - Some( - "0xf8b2cb4f000000000000000000000000CbfF24DED1CE6B53712078759233Ac8f91ea71B6" - .parse()?, - ), + Some(EthAddress::from_str(CALIBNET_EVM_CONTRACT)?), + Some(GET_BALANCE_CALLDATA.parse()?), ), (Some(EthAddress::from_str(ZERO_ADDRESS)?), None), // Assert contract creation, which is invoked via setting the `to` field to `None` and @@ -1547,11 +1546,11 @@ fn eth_tests(server_mode: ServerMode) -> anyhow::Result> { let cases = [ Some(EthAddressList::List(vec![])), Some(EthAddressList::List(vec![ - EthAddress::from_str("0x0c1d86d34e469770339b53613f3a2343accd62cb")?, + EthAddress::from_str(CALIBNET_EVM_CONTRACT)?, EthAddress::from_str("0x89beb26addec4bc7e9f475aacfd084300d6de719")?, ])), Some(EthAddressList::Single(EthAddress::from_str( - "0x0c1d86d34e469770339b53613f3a2343accd62cb", + CALIBNET_EVM_CONTRACT, )?)), None, ]; @@ -1644,10 +1643,6 @@ fn eth_call_api_err_tests(epoch: ChainEpoch) -> Vec { tests } -const SKIP_SENDER_CONTRACT: &str = "0x0c1d86d34e469770339b53613f3a2343accd62cb"; -const SKIP_SENDER_CALLDATA: &str = - "0xf8b2cb4f000000000000000000000000CbfF24DED1CE6B53712078759233Ac8f91ea71B6"; - fn eth_skip_sender_tests(epoch: ChainEpoch) -> anyhow::Result> { let mut tests = eth_skip_sender_success_tests(epoch)?; tests.extend(eth_skip_sender_insufficient_funds_tests(epoch)?); @@ -1656,29 +1651,31 @@ fn eth_skip_sender_tests(epoch: ChainEpoch) -> anyhow::Result> { Ok(tests) } -fn eth_skip_sender_cases( +/// Both methods share the sender-validation logic, so every message is tested against both. +fn eth_call_and_estimate_gas_tests( epoch: ChainEpoch, policy: PolicyOnRejected, - messages: impl IntoIterator, + messages: impl IntoIterator, ) -> anyhow::Result> { - let mut tests = Vec::new(); + let messages = messages.into_iter(); + let mut tests = Vec::with_capacity(2 * messages.len()); let block = BlockNumberOrHash::from_block_number(epoch); for msg in messages { tests.push( RpcTest::identity(EthCall::request((msg.clone(), block.clone()))?) - .policy_on_rejected(policy.clone()), + .policy_on_rejected(policy), ); tests.push( - RpcTest::identity(EthEstimateGas::request((msg.clone(), Some(block.clone())))?) - .policy_on_rejected(policy.clone()), + RpcTest::identity(EthEstimateGas::request((msg, Some(block.clone())))?) + .policy_on_rejected(policy), ); } Ok(tests) } fn eth_skip_sender_success_tests(epoch: ChainEpoch) -> anyhow::Result> { - let to = EthAddress::from_str(SKIP_SENDER_CONTRACT)?; - let calldata: EthBytes = SKIP_SENDER_CALLDATA.parse()?; + let to = EthAddress::from_str(CALIBNET_EVM_CONTRACT)?; + let calldata: EthBytes = GET_BALANCE_CALLDATA.parse()?; let initcode = EthBytes::from_str(concat!("0x", include_str!("contracts/cthulhu/invoke.hex")).trim())?; let contract = EthAddress::from_filecoin_address(&Address::from_str(EVM_ADDRESS)?)?; @@ -1700,11 +1697,11 @@ fn eth_skip_sender_success_tests(epoch: ChainEpoch) -> anyhow::Result anyhow::Result> { - let to = EthAddress::from_str(SKIP_SENDER_CONTRACT)?; + let to = EthAddress::from_str(CALIBNET_EVM_CONTRACT)?; let contract = EthAddress::from_filecoin_address(&Address::from_str(EVM_ADDRESS)?)?; let non_existent = generate_eth_random_address()?; let eoa = EthAddress::from_filecoin_address(&KNOWN_CALIBNET_F4_ADDRESS)?; @@ -1717,7 +1714,7 @@ fn eth_skip_sender_insufficient_funds_tests(epoch: ChainEpoch) -> anyhow::Result value: Some(value), ..Default::default() }); - eth_skip_sender_cases(epoch, PolicyOnRejected::PassWithIdenticalError, messages) + eth_call_and_estimate_gas_tests(epoch, PolicyOnRejected::PassWithIdenticalError, messages) } fn eth_skip_sender_create_reject_tests(epoch: ChainEpoch) -> anyhow::Result> { @@ -1745,17 +1742,17 @@ fn eth_skip_sender_create_reject_tests(epoch: ChainEpoch) -> anyhow::Result anyhow::Result> { - let to = EthAddress::from_str(SKIP_SENDER_CONTRACT)?; - // Asking for a block in the future — both nodes should reject + let to = EthAddress::from_str(CALIBNET_EVM_CONTRACT)?; + // Asking for a block in the future — both nodes reject let messages = [EthCallMessage { to: Some(to), ..Default::default() }]; - eth_skip_sender_cases(epoch + 1000, PolicyOnRejected::Pass, messages) + eth_call_and_estimate_gas_tests(epoch + 1000, PolicyOnRejected::Pass, messages) } fn eth_tests_with_tipset(