diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b208bf8abe1..49ac9f5af646 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,8 @@ ### Fixed +- [#7394](https://github.com/ChainSafe/forest/issues/7394): `eth_call` and `eth_estimateGas` now accept a `from` address that is an EVM contract or that doesn't exist on chain, matching Lotus. + - [#5795](https://github.com/ChainSafe/forest/issues/5795): `Filecoin.ChainNotify` now closes the subscription channel when a client falls too far behind instead of silently dropping head changes, matching Lotus, so clients can detect the gap and resubscribe. ## Forest v0.36.0 "bafy2bzacedpdckv7nsqfjwqnqwtgu7ipqbox4tfuuhwxhdox27uuznfyv3o2g" diff --git a/scripts/devnet/.env b/scripts/devnet/.env index f25d9da12b4a..0c290ce2808a 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 # The genesis network name. Lotus stamps it into the genesis block, and both Forest nodes must agree. NETWORK_NAME=devnet FOREST_DATA_DIR=/forest_data 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 diff --git a/src/rpc/methods/eth.rs b/src/rpc/methods/eth.rs index 5181eac7d133..e998a126f7d8 100644 --- a/src/rpc/methods/eth.rs +++ b/src/rpc/methods/eth.rs @@ -12,6 +12,8 @@ pub(crate) mod trace; pub mod types; mod utils; +pub(crate) use utils::decode_revert_reason; + use crate::utils::encoding::hex; pub use bloom::Bloom; pub(crate) use bloom::store_block_logs_bloom; @@ -47,7 +49,6 @@ use crate::rpc::{ EventRevertStatus, SkipEvent, event::EventFilter, mempool::MempoolFilter, tipset::TipSetFilter, }, - utils::decode_revert_reason, }, methods::chain::{ChainGetTipSetV2, PathChange}, state::ApiInvocResult, @@ -63,8 +64,14 @@ use crate::shim::fvm_shared_latest::MethodNum; use crate::shim::fvm_shared_latest::address::{Address as VmAddress, DelegatedAddress}; use crate::shim::gas::GasOutputs; use crate::shim::message::Message; -use crate::shim::{clock::ChainEpoch, state_tree::StateTree}; -use crate::state_manager::{ExecutedMessage, ExecutedTipset, StateManager, TipsetState, VMFlush}; +use crate::shim::{ + clock::ChainEpoch, + state_tree::{ActorState, StateTree}, +}; +use crate::state_manager::{ + Error as StateManagerError, 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; @@ -1903,36 +1910,90 @@ async fn eth_estimate_gas( // gas estimation actually run. msg.gas_limit = 0; + if sender_validation_for_actor( + ctx.state_manager + .get_actor(&msg.from, *tipset.parent_state()), + ) == 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, - // it just returns an error. That means we can't get the revert reason. - // - // So we re-execute the message with EthCall (well, applyMessage which contains the - // 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 { - Ok(_) => Error::msg(server_err.to_string()), - Err(e) - if e.downcast_ref::().is_some_and(|eth_err| { - matches!(eth_err, EthErrors::ExecutionReverted { .. }) - }) => - { - return Err(e.into()); - } - Err(e) => e, - }; + Err(err) => { + if matches!( + err.downcast_ref(), + Some(StateManagerError::SenderValidationFailed(_)) + ) { + return eth_estimate_gas_skip_sender(ctx, msg, &tipset).await; + } + // Return reverts as-is to preserve the JSON-RPC error codec. + if matches!( + err.downcast_ref(), + Some(EthErrors::ExecutionReverted { .. }) + ) { + return Err(err.into()); + } 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, SenderValidation::Enforce).await?; Ok(expected_gas.into()) } } } +fn sender_validation_for_actor(actor: anyhow::Result>) -> SenderValidation { + match actor { + Ok(Some(actor)) if is_evm_actor(&actor.code) => SenderValidation::Skip, + _ => SenderValidation::Enforce, + } +} + +/// Estimates gas for a sender that is a contract or doesn't exist on chain. +async fn eth_estimate_gas_skip_sender( + ctx: &Ctx, + mut msg: Message, + tipset: &Tipset, +) -> Result { + let gas_limit = match gas::GasEstimateGasLimit::estimate_gas_limit( + ctx, + msg.clone(), + tipset, + SenderValidation::Skip, + ) + .await + { + Ok(gas_limit) => gas_limit, + Err(estimate_err) => { + return Err(recover_estimate_gas_error(ctx, msg, tipset, estimate_err).await); + } + }; + + let gas_limit = (gas_limit as f64 * ctx.mpool.gas_limit_overestimation()) as u64; + msg.set_gas_limit(gas_limit.min(BLOCK_GAS_LIMIT)); + + let expected_gas = eth_gas_search(ctx, msg, tipset, SenderValidation::Skip).await?; + Ok(expected_gas.into()) +} + +/// Re-execute to recover an `ExecutionReverted` from a failed gas estimate. +async fn recover_estimate_gas_error( + ctx: &Ctx, + mut msg: Message, + tipset: &Tipset, + estimate_err: anyhow::Error, +) -> ServerError { + msg.set_gas_limit(BLOCK_GAS_LIMIT); + if let Err(e) = apply_message(ctx, Some(tipset), &msg).await + && matches!(e.downcast_ref(), Some(EthErrors::ExecutionReverted { .. })) + { + return e.into(); + } + estimate_err.context("failed to estimate gas").into() +} + /// Builds an eth `ExecutionReverted` (code 3) from a failed message's exit code and return /// payload, decoding the revert reason and data. fn execution_reverted_error( @@ -1944,12 +2005,25 @@ fn execution_reverted_error( EthErrors::execution_reverted(exit_code.into(), &reason, vm_error, &data) } +fn needs_skip_sender(result: &Result<(ApiInvocResult, Option), Error>) -> bool { + match result { + Err(e) => matches!( + e.downcast_ref(), + Some(StateManagerError::SenderValidationFailed(_)) + ), + Ok((invoc_res, _)) => invoc_res + .msg_rct + .as_ref() + .is_some_and(|rct| rct.exit_code() == fvm_shared4::error::ExitCode::SYS_SENDER_INVALID), + } +} + async fn apply_message( ctx: &Ctx, - tipset: Option, - msg: Message, + tipset: Option<&Tipset>, + msg: &Message, ) -> Result { - if let Some(ts) = &tipset + if let Some(ts) = tipset && ts.epoch() > 0 && ctx .chain_config() @@ -1958,11 +2032,31 @@ 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, VMTrace::NotTraced) - .await - .context("failed to apply on state with gas")?; + .apply_on_state_with_gas( + tipset, + msg, + VMFlush::Skip, + VMTrace::NotTraced, + SenderValidation::Enforce, + ) + .await; + + let (invoc_res, _) = if needs_skip_sender(&result) { + ctx.state_manager + .apply_on_state_with_gas( + tipset, + msg, + VMFlush::Skip, + VMTrace::NotTraced, + SenderValidation::Skip, + ) + .await + .context("failed to apply on state with gas (skipping sender validation)")? + } else { + result.context("failed to apply on state with gas")? + }; // Extract receipt or return early if none match &invoc_res.msg_rct { @@ -1982,12 +2076,22 @@ 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, + curr_ts: &Tipset, + sender_validation: SenderValidation, +) -> anyhow::Result { // Probe the message as the caller specified it: the question is whether *its* limit // suffices, which the block maximum would always answer yes to. - let (apply_ret, prior_messages, ts, from) = - gas::GasEstimateGasLimit::probe_as_specified(data, msg.clone(), tsk, VMTrace::NotTraced) - .await?; + let (apply_ret, prior_messages, ts, from) = gas::GasEstimateGasLimit::probe_as_specified( + data, + msg.clone(), + curr_ts, + VMTrace::NotTraced, + sender_validation, + ) + .await?; if apply_ret.exit_code().is_success() { return Ok(msg.gas_limit()); } @@ -2003,6 +2107,7 @@ pub async fn eth_gas_search(data: &Ctx, msg: Message, tsk: &ApiTipsetKey) -> any Some(ts.shallow_clone()), VMFlush::Skip, VMTrace::Traced, + sender_validation, ) .await? .0 @@ -2020,8 +2125,16 @@ pub async fn eth_gas_search(data: &Ctx, msg: Message, tsk: &ApiTipsetKey) -> any .into()); } - let ret = gas_search(data, &msg, from.protocol(), prior_messages, ts).await?; - Ok(((ret as f64) * data.mpool.gas_limit_overestimation()) as u64) + let ret = gas_search( + data, + &msg, + from.protocol(), + prior_messages, + ts, + sender_validation, + ) + .await?; + Ok((ret as f64 * data.mpool.gas_limit_overestimation()) as u64) } /// `gas_search` does an exponential search to find a gas value to execute the @@ -2034,6 +2147,7 @@ async fn gas_search( from_protocol: Protocol, prior_messages: Arc>, ts: Tipset, + sender_validation: SenderValidation, ) -> anyhow::Result { // `max(1)` keeps the doubling below able to make progress. let mut high = msg.gas_limit.max(1); @@ -2050,6 +2164,7 @@ async fn gas_search( Some(ts.shallow_clone()), VMFlush::Skip, VMTrace::NotTraced, + sender_validation, ) .await?; anyhow::Ok(apply_ret.exit_code().is_success()) @@ -2774,7 +2889,7 @@ impl RpcMethod<2> for EthCall { async fn eth_call(ctx: &Ctx, tx: EthCallMessage, ts: Tipset) -> Result { let msg = Message::try_from(tx)?; - let invoke_result = apply_message(ctx, Some(ts), msg.clone()).await?; + let invoke_result = apply_message(ctx, Some(&ts), &msg).await?; if msg.to() == FilecoinAddress::ETHEREUM_ACCOUNT_MANAGER_ACTOR { Ok(EthBytes::default()) @@ -3915,10 +4030,11 @@ 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(), + Some(&ts), + &msg, VMFlush::Flush, VMTrace::Traced, + SenderValidation::Enforce, ) .await .context("failed to apply message")?; @@ -4290,6 +4406,72 @@ mod test { assert_eq!(server.code(), errors::EXECUTION_REVERTED_CODE); } + fn invoc_result_with_exit_code(exit_code: fvm_shared4::error::ExitCode) -> ApiInvocResult { + ApiInvocResult { + msg_rct: Some(Receipt::V4(fvm_shared4::receipt::Receipt { + exit_code, + return_data: RawBytes::default(), + gas_used: 0, + events_root: None, + })), + ..Default::default() + } + } + + #[test] + fn needs_skip_sender_covers_both_shapes_of_sender_rejection() { + assert!(needs_skip_sender(&Err( + crate::state_manager::Error::SenderValidationFailed("sender t410f... not found".into()) + .into() + ))); + + assert!(needs_skip_sender(&Ok(( + invoc_result_with_exit_code(fvm_shared4::error::ExitCode::SYS_SENDER_INVALID), + None + )))); + + assert!(!needs_skip_sender(&Ok(( + invoc_result_with_exit_code(fvm_shared4::error::ExitCode::USR_ASSERTION_FAILED), + None + )))); + assert!(!needs_skip_sender(&Ok(( + invoc_result_with_exit_code(fvm_shared4::error::ExitCode::OK), + None + )))); + assert!(!needs_skip_sender(&Err(anyhow::anyhow!( + "blockstore read failed" + )))); + assert!(!needs_skip_sender(&Ok((ApiInvocResult::default(), None)))); + } + + #[test] + fn only_an_evm_sender_skips_validation() { + use crate::rpc::methods::eth::trace::test_helpers::{ + create_test_actor, get_evm_actor_code_cid, + }; + + let evm_code = get_evm_actor_code_cid().expect("EVM actor code CID should be available"); + let mut evm_actor = create_test_actor(0, 0); + evm_actor.code = evm_code; + assert_eq!( + sender_validation_for_actor(Ok(Some(evm_actor))), + SenderValidation::Skip + ); + + assert_eq!( + sender_validation_for_actor(Ok(Some(create_test_actor(0, 0)))), + SenderValidation::Enforce + ); + assert_eq!( + sender_validation_for_actor(Ok(None)), + SenderValidation::Enforce + ); + assert_eq!( + sender_validation_for_actor(Err(anyhow::anyhow!("blockstore read failed"))), + SenderValidation::Enforce + ); + } + #[rstest] // Non-empty access list → JSON array. #[case::populated_array(ApiEthTx { access_list: Some(NotNullVec(vec![EthHash::default()])), ..Default::default() }, Some(1))] diff --git a/src/rpc/methods/eth/trace/mod.rs b/src/rpc/methods/eth/trace/mod.rs index 532cffc55489..af4fde4babec 100644 --- a/src/rpc/methods/eth/trace/mod.rs +++ b/src/rpc/methods/eth/trace/mod.rs @@ -15,7 +15,7 @@ mod geth; mod parity; mod state_diff; #[cfg(test)] -mod test_helpers; +pub(super) mod test_helpers; pub(crate) mod types; mod utils; diff --git a/src/rpc/methods/gas.rs b/src/rpc/methods/gas.rs index e0b47431b234..35298557169f 100644 --- a/src/rpc/methods/gas.rs +++ b/src/rpc/methods/gas.rs @@ -6,6 +6,8 @@ use crate::chain::{BASE_FEE_MAX_CHANGE_DENOM, BLOCK_GAS_TARGET}; use crate::interpreter::VMTrace; use crate::message::{ChainMessage, MessageRead as _, MessageReadWrite as _}; use crate::prelude::*; +use crate::rpc::eth::decode_revert_reason; +use crate::rpc::eth::errors::EthErrors; use crate::rpc::{ApiPaths, Ctx, Permission, RpcMethod, error::ServerError, types::*}; use crate::shim::executor::ApplyRet; use crate::shim::{ @@ -13,7 +15,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; @@ -197,10 +199,11 @@ impl RpcMethod<2> for GasEstimateGasLimit { async fn handle( ctx: Ctx, - (msg, tsk): Self::Params, + (msg, ApiTipsetKey(tsk)): Self::Params, _: &http::Extensions, ) -> Result { - Ok(Self::estimate_gas_limit(&ctx, msg, &tsk).await?) + let ts = ctx.chain_store().load_required_tipset_or_heaviest(&tsk)?; + Ok(Self::estimate_gas_limit(&ctx, msg, &ts, SenderValidation::Enforce).await?) } } @@ -209,12 +212,13 @@ impl GasEstimateGasLimit { pub async fn measure_gas_used( data: &Ctx, mut msg: Message, - tsk: &ApiTipsetKey, + curr_ts: &Tipset, + sender_validation: SenderValidation, ) -> anyhow::Result<(ApplyRet, Arc>, Tipset, Address)> { msg.set_gas_limit(BLOCK_GAS_LIMIT); msg.set_gas_fee_cap(TokenAmount::from_atto(0)); msg.set_gas_premium(TokenAmount::from_atto(0)); - Self::probe_as_specified(data, msg, tsk, VMTrace::NotTraced).await + Self::probe_as_specified(data, msg, curr_ts, VMTrace::NotTraced, sender_validation).await } /// Runs `msg` exactly as given. The limit and fees are left alone: a gas search compares this @@ -224,14 +228,23 @@ impl GasEstimateGasLimit { pub async fn probe_as_specified( data: &Ctx, msg: Message, - ApiTipsetKey(tsk): &ApiTipsetKey, + curr_ts: &Tipset, vm_trace: VMTrace, + sender_validation: SenderValidation, ) -> anyhow::Result<(ApplyRet, Arc>, Tipset, Address)> { - 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 + .map_err(|e| { + crate::state_manager::Error::SenderValidationFailed(format!( + "resolving sender {} ({e:#})", + msg.from + )) + })?, + }; let pending = data.mpool.pending_for(&from_a).await; let prior_messages: Arc> = pending @@ -251,22 +264,38 @@ impl GasEstimateGasLimit { Some(ts.shallow_clone()), VMFlush::Skip, vm_trace, + sender_validation, ) .await?; Ok((apply_ret, prior_messages, ts, from_a)) } - pub async fn estimate_gas_limit(data: &Ctx, msg: Message, tsk: &ApiTipsetKey) -> Result { - let (apply_ret, ..) = Self::measure_gas_used(data, msg, tsk) + pub async fn estimate_gas_limit( + data: &Ctx, + msg: Message, + curr_ts: &Tipset, + sender_validation: SenderValidation, + ) -> Result { + let (apply_ret, ..) = Self::measure_gas_used(data, msg, curr_ts, sender_validation) .await .context("gas estimation failed")?; - anyhow::ensure!( - apply_ret.exit_code().is_success(), - "message execution failed: exit code: {}, reason: {}", - apply_ret.exit_code().value(), - apply_ret.failure_info().unwrap_or_default() - ); - Ok(apply_ret.gas_used() as i64) + let exit_code = apply_ret.exit_code(); + if exit_code.is_success() { + return Ok(apply_ret.gas_used() as i64); + } + if sender_validation == SenderValidation::Enforce + && exit_code == fvm_shared4::error::ExitCode::SYS_SENDER_INVALID + { + let exit_code = crate::shim::error::ExitCode::from(exit_code); + return Err(crate::state_manager::Error::SenderValidationFailed(format!( + "message execution failed (exit=[{exit_code}], vm error=[{}])", + apply_ret.failure_info().unwrap_or_default() + )) + .into()); + } + let vm_error = apply_ret.failure_info().unwrap_or_default(); + let (data, reason) = decode_revert_reason(apply_ret.return_data()); + Err(EthErrors::execution_reverted(exit_code.into(), &reason, &vm_error, &data).into()) } } @@ -297,11 +326,19 @@ 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, msg.clone(), &tsk).await?; - let gl = gl as f64 * data.mpool.gas_limit_overestimation(); - msg.set_gas_limit((gl as u64).min(BLOCK_GAS_LIMIT)); + let ApiTipsetKey(key) = &tsk; + let ts = data.chain_store().load_required_tipset_or_heaviest(key)?; + let gl = GasEstimateGasLimit::estimate_gas_limit( + data, + msg.clone(), + &ts, + SenderValidation::Enforce, + ) + .await?; + let gl = (gl as f64 * data.mpool.gas_limit_overestimation()) as u64; + msg.set_gas_limit(gl.min(BLOCK_GAS_LIMIT)); } 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 2651ffb3b3c6..f1d692cd75a6 100644 --- a/src/state_manager/errors.rs +++ b/src/state_manager/errors.rs @@ -18,6 +18,10 @@ pub enum Error { "required historical state unavailable: refusing explicit call due to state fork at epoch {epoch}" )] ExpensiveFork { epoch: ChainEpoch }, + /// The sender doesn't exist on chain, or is not a valid sender type. + /// Control flow only: callers use it to retry with skip-sender-validation. + #[error("{0}: sender validation failed")] + SenderValidationFailed(String), /// Other state manager error #[error("{0}")] Other(String), diff --git a/src/state_manager/message_simulation.rs b/src/state_manager/message_simulation.rs index f6f864a42041..0dd7fa136f99 100644 --- a/src/state_manager/message_simulation.rs +++ b/src/state_manager/message_simulation.rs @@ -7,7 +7,7 @@ use crate::interpreter::{ExecutionContext, IMPLICIT_MESSAGE_GAS_LIMIT, VM, VMTra use crate::message::{MessageRead as _, MessageReadWrite as _}; use crate::rpc::state::{ApiInvocResult, 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; @@ -182,18 +182,33 @@ impl StateManager { pub async fn apply_on_state_with_gas( &self, - tipset: Option, - msg: Message, + tipset: Option<&Tipset>, + msg: &Message, vm_flush: VMFlush, vm_trace: VMTrace, + sender_validation: SenderValidation, ) -> anyhow::Result<(ApiInvocResult, Option)> { - let ts = tipset.unwrap_or_else(|| self.heaviest_tipset()); + let ts = tipset.map_or_else(|| self.heaviest_tipset(), Tipset::shallow_clone); - 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 + .context("could not resolve key")? + .protocol(), + }; + 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, vm_trace) + .call_with_gas( + chain_msg, + Default::default(), + Some(ts), + vm_flush, + vm_trace, + sender_validation, + ) .await?; let msg_rct = Some(apply_ret.msg_receipt()); @@ -201,7 +216,7 @@ impl StateManager { Ok(( ApiInvocResult { msg_cid: msg.cid(), - msg, + msg: msg.clone(), msg_rct, error, duration: duration.as_nanos().clamp(0, u128::from(u64::MAX)) as u64, @@ -222,6 +237,7 @@ impl StateManager { tipset: Option, vm_flush: VMFlush, vm_trace: VMTrace, + sender_validation: SenderValidation, ) -> Result<(ApplyRet, Duration, Option), Error> { let ts = tipset.unwrap_or_else(|| self.heaviest_tipset()); let TipsetState { state_root, .. } = self @@ -237,7 +253,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(), @@ -262,13 +278,17 @@ impl StateManager { vm.apply_message(msg)?; } - let from_actor = 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()))?; - + let (from_actor, apply) = + sender_for_simulation(&mut vm, message.from(), sender_validation)?; message.set_sequence(from_actor.sequence); - let (ret, duration) = vm.apply_message(&message)?; + let (ret, duration) = match apply { + // An existing non-account sender needs the implicit path, which skips the + // account-type, nonce and balance checks, and charges no inclusion cost. + SenderApply::Implicit => vm.apply_implicit_message(message.message())?, + // A fresh placeholder is a valid sender, so the explicit path keeps gas + // accounting, inclusion cost included, matching a real first send. + SenderApply::Explicit => vm.apply_message(&message)?, + }; let state_root = match vm_flush { VMFlush::Flush => Some(vm.flush()?), VMFlush::Skip => None, @@ -281,3 +301,62 @@ impl StateManager { .await? } } + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +enum SenderApply { + Explicit, + Implicit, +} + +/// Looks up `from` and decides how to apply the simulated message. +/// +/// With [`SenderValidation::Skip`], eth methods accept a missing or non-account (EVM) sender. +/// A missing sender is created as an ephemeral placeholder via an implicit system send, then the +/// user message is applied explicitly so first-send gas still matches a real send. An existing +/// non-account must be applied implicitly to bypass account-type / nonce / balance checks. +fn sender_for_simulation( + vm: &mut VM, + from: Address, + sender_validation: SenderValidation, +) -> Result<(ActorState, SenderApply), Error> { + match ( + vm.get_actor(&from) + .map_err(|e| Error::Other(format!("Could not get actor from state: {e:#}")))?, + sender_validation, + ) { + (Some(actor), SenderValidation::Enforce) => Ok((actor, SenderApply::Explicit)), + (Some(actor), SenderValidation::Skip) => Ok((actor, SenderApply::Implicit)), + (None, SenderValidation::Enforce) => Err(Error::SenderValidationFailed(format!( + "sender {from} not found on chain" + ))), + (None, SenderValidation::Skip) => { + let (create_ret, _) = vm.apply_implicit_message(&placeholder_send(from))?; + let exit_code = create_ret.msg_receipt().exit_code(); + if !exit_code.is_success() { + return Err(Error::Other(format!( + "failed to create ephemeral sender placeholder {from} (exit={exit_code}): {}", + create_ret.failure_info().unwrap_or_default() + ))); + } + let actor = vm + .get_actor(&from) + .map_err(|e| Error::Other(format!("Could not get placeholder actor: {e:#}")))? + .ok_or_else(|| { + Error::Other(format!( + "ephemeral sender placeholder {from} missing after creation" + )) + })?; + Ok((actor, SenderApply::Explicit)) + } + } +} + +fn placeholder_send(to: Address) -> Message { + Message { + from: Address::SYSTEM_ACTOR, + to, + method_num: METHOD_SEND, + gas_limit: IMPLICIT_MESSAGE_GAS_LIMIT as u64, + ..Default::default() + } +} diff --git a/src/state_manager/mod.rs b/src/state_manager/mod.rs index 7cf25ecd5bcb..745f37c87b51 100644 --- a/src/state_manager/mod.rs +++ b/src/state_manager/mod.rs @@ -215,6 +215,14 @@ pub enum VMFlush { Skip, } +/// Controls whether the FVM sender checks are enforced when simulating a message. +#[derive(Debug, Copy, Clone, Default, PartialEq, Eq)] +pub enum SenderValidation { + #[default] + Enforce, + 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 c8ac5724d19e..ac734a93fa8f 100644 --- a/src/tool/subcommands/api_cmd/api_compare_tests.rs +++ b/src/tool/subcommands/api_cmd/api_compare_tests.rs @@ -16,7 +16,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}; @@ -147,7 +147,11 @@ const ZERO_ADDRESS: &str = "0x0000000000000000000000000000000000000000"; // miner actor address `t078216` 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"; +// An EVM contract, `t410fbqoynu2oi2lxam43knqt6ordiowm2ywlml27z4i`, 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( @@ -264,6 +268,7 @@ pub struct TestResult { pub duration: Duration, } +#[derive(Clone, Copy)] pub(super) enum PolicyOnRejected { Fail, Pass, @@ -1504,13 +1509,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 @@ -1551,11 +1551,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, ]; @@ -1648,6 +1648,129 @@ fn eth_call_api_err_tests(epoch: ChainEpoch) -> Vec { tests } +/// `eth_call` and `eth_estimateGas` accept a `from` that is an EVM contract or that doesn't exist on +/// chain, matching Geth. Ported from the Lotus `eth_call_estimate_test.go` suite. +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) +} + +/// 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, +) -> anyhow::Result> { + 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), + ); + tests.push( + RpcTest::identity(EthEstimateGas::request((msg, Some(block.clone())))?) + .policy_on_rejected(policy), + ); + } + Ok(tests) +} + +/// The senders that used to be rejected outright, and now have to work on both nodes. +fn eth_skip_sender_success_tests(epoch: ChainEpoch) -> anyhow::Result> { + let contract = 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 non_existent = generate_eth_random_address()?; + let eoa = EthAddress::from_filecoin_address(&KNOWN_CALIBNET_F4_ADDRESS)?; + let gas_price = EthBigInt::from(1_000_000_000u64); + + let messages = [ + (Some(contract), Some(eoa), None, None), + (Some(contract), Some(eoa), None, Some(gas_price)), + (Some(contract), Some(contract), Some(calldata.clone()), None), + ( + Some(non_existent), + Some(contract), + Some(calldata.clone()), + None, + ), + ( + Some(non_existent), + Some(contract), + Some(calldata.clone()), + Some(gas_price), + ), + (None, Some(contract), Some(calldata), None), + (Some(non_existent), None, Some(initcode), None), + ] + .map(|(from, to, data, gas_price)| EthCallMessage { + from, + to, + data, + gas_price, + ..Default::default() + }); + eth_call_and_estimate_gas_tests(epoch, PolicyOnRejected::Fail, messages) +} + +/// Skipping the sender checks must not skip the balance check. +fn eth_skip_sender_insufficient_funds_tests(epoch: ChainEpoch) -> anyhow::Result> { + let contract = EthAddress::from_str(CALIBNET_EVM_CONTRACT)?; + 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)); + + let messages = [contract, non_existent, eoa].map(|from| EthCallMessage { + from: Some(from), + to: Some(eoa), + value: Some(value), + ..Default::default() + }); + eth_call_and_estimate_gas_tests(epoch, PolicyOnRejected::PassWithIdenticalError, messages) +} + +/// Contract creation that both nodes must refuse or report as reverting. +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_str(CALIBNET_EVM_CONTRACT)?; + let non_existent = generate_eth_random_address()?; + + let messages = [ + (Some(contract), initcode), + (Some(contract), div_zero.clone()), + (Some(non_existent), div_zero), + (Some(contract), assert_err.clone()), + (Some(non_existent), assert_err), + ] + .map(|(from, data)| EthCallMessage { + from, + data: Some(data), + ..Default::default() + }); + eth_call_and_estimate_gas_tests(epoch, PolicyOnRejected::PassWithIdenticalError, messages) +} + +fn eth_skip_sender_block_param_tests(epoch: ChainEpoch) -> anyhow::Result> { + let messages = [EthCallMessage { + from: Some(generate_eth_random_address()?), + to: Some(EthAddress::from_str(CALIBNET_EVM_CONTRACT)?), + data: Some(GET_BALANCE_CALLDATA.parse()?), + ..Default::default() + }]; + eth_call_and_estimate_gas_tests(epoch + 1000, PolicyOnRejected::Pass, messages) +} + fn eth_tests_with_tipset( store: &DB, shared_tipset: &Tipset, @@ -2453,7 +2576,7 @@ fn read_state_api_tests(tipset: &Tipset) -> anyhow::Result> { tipset.key().into(), ))?), RpcTest::identity(StateReadState::request(( - Address::from_str(EVM_ADDRESS)?, // evm actor + EthAddress::from_str(CALIBNET_EVM_CONTRACT)?.to_filecoin_address()?, // evm actor tipset.key().into(), ))?), ]; @@ -2511,6 +2634,8 @@ fn eth_state_tests_with_tipset( // Test eth_call API errors tests.extend(eth_call_api_err_tests(shared_tipset.epoch())); + tests.extend(eth_skip_sender_tests(shared_tipset.epoch())?); + Ok(tests) } diff --git a/src/tool/subcommands/api_cmd/test_snapshots.txt b/src/tool/subcommands/api_cmd/test_snapshots.txt index e5e40767e8bc..24f4014be5c4 100644 --- a/src/tool/subcommands/api_cmd/test_snapshots.txt +++ b/src/tool/subcommands/api_cmd/test_snapshots.txt @@ -57,10 +57,18 @@ 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_contract_from_1786069718284042.rpcsnap.json.zst +filecoin_ethcall_create_1786069718289675.rpcsnap.json.zst +filecoin_ethcall_nonexistent_from_1786069718279056.rpcsnap.json.zst +filecoin_ethcall_omitted_from_1786069718288176.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_contract_from_1786068422578959.rpcsnap.json.zst +filecoin_ethestimategas_create_1786068423971276.rpcsnap.json.zst +filecoin_ethestimategas_nonexistent_from_1786068422839752.rpcsnap.json.zst +filecoin_ethestimategas_omitted_from_1786068423870066.rpcsnap.json.zst filecoin_ethfeehistory_1781166099973654.rpcsnap.json.zst filecoin_ethfeehistory_v2_1781166099990041.rpcsnap.json.zst filecoin_ethgasprice_1758725940980141.rpcsnap.json.zst