Repository: ChainSafe/forest
Pull request #5467: fix: improve error message for EthEstimateGas and EthCall
Author: login: akaladarshi
Labels: name: RPC
description: requires calibnet RPC checks to run on CI
Created: 2025-03-26T17:56:56Z | Merged: 2025-04-11T12:27:52Z

## Summary of changes

<!-- Please write a comprehensive summary of your changes and what was the motivation behind them -->

Changes introduced in this pull request:

- Adds `apply_message` in `eth.rs` which executes message
  - `EthEstimateGas` if the gas_estimation fails
  - `EthCall` to directly execute the message and parse the `eth` errors
 
- Adds `EthError` enum which will hold all the Ethereum errors
  - Right now only contains `ExecutionReverted`

- Adds `fn parse_eth_revert` which parses the ethereum errors (ABI encoded)

## Reference issue to close (if applicable)

<!-- Include the issue reference this pull request is connected to -->
<!-- See more keywords here https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword -->
<!--(e.g. Closes #1)-->

Closes #4554 

## Other information and links

<!-- Add any other context about the pull request here. Those might be helpful links based on your investigation, relevant commits from this or other repositories or anything else -->

## Change checklist

<!-- Please add a changelog entry for your change if needed. -->
<!-- Follow this format https://keepachangelog.com/en/1.0.0/ -->

- [ ] I have performed a self-review of my own code,
- [ ] I have made corresponding changes to the documentation. All new code adheres to the team's [documentation standards](https://github.com/ChainSafe/forest/wiki/Documentation-practices),
- [ ] I have added tests that prove my fix is effective or that my feature works (if possible),
- [ ] I have made sure the [CHANGELOG][1] is up-to-date. All user-facing changes should be reflected in this document.

<!-- Thank you 🔥 -->

[1]: https://github.com/ChainSafe/forest/blob/main/CHANGELOG.md



Diff:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a2bec675c8d2..90e3280551e2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -31,6 +31,8 @@
 
 ### Changed
 
+- [#5467](https://github.com/ChainSafe/forest/pull/5467) Improve error message for `Filecoin.EthEstimateGas` and `Filecoin.EthCall`.
+
 ### Removed
 
 ### Fixed
diff --git a/src/chain/store/index.rs b/src/chain/store/index.rs
index 5b7cf93bd3ad..cc33e174f717 100644
--- a/src/chain/store/index.rs
+++ b/src/chain/store/index.rs
@@ -5,6 +5,7 @@ use std::{num::NonZeroUsize, sync::Arc};
 
 use crate::beacon::{BeaconEntry, IGNORE_DRAND_VAR};
 use crate::blocks::{Tipset, TipsetKey};
+use crate::chain::Error;
 use crate::metrics;
 use crate::shim::clock::ChainEpoch;
 use crate::utils::misc::env::is_env_truthy;
@@ -14,8 +15,6 @@ use lru::LruCache;
 use nonzero_ext::nonzero;
 use parking_lot::Mutex;
 
-use crate::chain::Error;
-
 const DEFAULT_TIPSET_CACHE_SIZE: NonZeroUsize = nonzero!(131072_usize);
 
 type TipsetCache = Mutex<LruCache<TipsetKey, Arc<Tipset>>>;
diff --git a/src/rpc/error.rs b/src/rpc/error.rs
index 9e47cf4d43e4..a4afc8cb316c 100644
--- a/src/rpc/error.rs
+++ b/src/rpc/error.rs
@@ -3,11 +3,30 @@
 
 use std::fmt::{self, Display};
 
+use crate::rpc::eth::errors::EthErrors;
 use jsonrpsee::{
     core::ClientError,
     types::error::{self, ErrorCode, ErrorObjectOwned},
 };
 
+/// Trait for errors that can provide additional RPC error data
+pub trait RpcErrorData {
+    /// Return the error code to use in RPC responses
+    fn error_code(&self) -> Option<i32> {
+        None
+    }
+
+    /// Return the error message to use in RPC responses
+    fn error_message(&self) -> Option<String> {
+        None
+    }
+
+    /// Return additional data to include in the RPC error response
+    fn error_data(&self) -> Option<serde_json::Value> {
+        None
+    }
+}
+
 /// An error returned _by the remote server_, not due to e.g serialization errors,
 /// protocol errors, or the connection failing.
 #[derive(derive_more::From, derive_more::Into, Debug, PartialEq)]
@@ -63,6 +82,29 @@ impl ServerError {
     }
 }
 
+impl<E: std::error::Error + RpcErrorData + 'static> From<E> for ServerError {
+    fn from(error: E) -> Self {
+        let code = error.error_code().unwrap_or(error::INTERNAL_ERROR_CODE);
+        let message = error.error_message().unwrap_or_else(|| error.to_string());
+        let data = error.error_data();
+
+        Self::new(code, message, data)
+    }
+}
+
+// Default implementation for anyhow::Error to handle downcasting once
+impl From<anyhow::Error> for ServerError {
+    fn from(error: anyhow::Error) -> Self {
+        // Try to downcast to known RpcErrorData implementations
+        if let Some(eth_error) = error.downcast_ref::<EthErrors>() {
+            return eth_error.clone().into();
+        }
+
+        // Default fallback
+        Self::internal_error(error.to_string(), None)
+    }
+}
+
 impl Display for ServerError {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         f.write_str("JSON-RPC error:\n")?;
@@ -112,7 +154,6 @@ macro_rules! from2internal {
 //               Just mapping everything to an internal error is not appropriate
 from2internal! {
     String,
-    anyhow::Error,
     base64::DecodeError,
     cid::multibase::Error,
     crate::chain::store::Error,
diff --git a/src/rpc/methods/eth.rs b/src/rpc/methods/eth.rs
index e043b0f8d84f..b93935321779 100644
--- a/src/rpc/methods/eth.rs
+++ b/src/rpc/methods/eth.rs
@@ -1,6 +1,7 @@
 // Copyright 2019-2025 ChainSafe Systems
 // SPDX-License-Identifier: Apache-2.0, MIT
 
+pub(crate) mod errors;
 mod eth_tx;
 pub mod filter;
 mod trace;
@@ -25,10 +26,13 @@ use crate::lotus_json::{HasLotusJson, lotus_json_with_self};
 use crate::message::{ChainMessage, Message as _, SignedMessage};
 use crate::rpc::EthEventHandler;
 use crate::rpc::error::ServerError;
+use crate::rpc::eth::errors::EthErrors;
 use crate::rpc::eth::filter::{
     SkipEvent, event::EventFilter, mempool::MempoolFilter, tipset::TipSetFilter,
 };
 use crate::rpc::eth::types::{EthBlockTrace, EthTrace};
+use crate::rpc::eth::utils::decode_revert_reason;
+use crate::rpc::state::ApiInvocResult;
 use crate::rpc::types::{ApiTipsetKey, EventEntry, MessageLookup};
 use crate::rpc::{ApiPaths, Ctx, Permission, RpcMethod};
 use crate::shim::actors::EVMActorStateLoad as _;
@@ -66,6 +70,7 @@ use serde::{Deserialize, Serialize};
 use std::ops::RangeInclusive;
 use std::str::FromStr;
 use std::sync::Arc;
+use tracing::log;
 use utils::{decode_payload, lookup_eth_address};
 
 static FOREST_TRACE_FILTER_MAX_RESULT: Lazy<u64> =
@@ -184,6 +189,30 @@ pub struct EthUint64(
 
 lotus_json_with_self!(EthUint64);
 
+impl EthUint64 {
+    pub fn from_bytes(data: &[u8]) -> Result<Self> {
+        if data.len() != EVM_WORD_LENGTH {
+            bail!("eth int must be {EVM_WORD_LENGTH} bytes");
+        }
+
+        // big endian format stores u64 in the last 8 bytes,
+        // since ethereum words are 32 bytes, the first 24 bytes must be 0
+        if data
+            .get(..24)
+            .is_none_or(|slice| slice.iter().any(|&byte| byte != 0))
+        {
+            bail!("eth int overflows 64 bits");
+        }
+
+        // Extract the uint64 from the last 8 bytes
+        Ok(Self(u64::from_be_bytes(
+            data.get(24..EVM_WORD_LENGTH)
+                .ok_or_else(|| anyhow::anyhow!("data too short"))?
+                .try_into()?,
+        )))
+    }
+}
+
 #[derive(
     PartialEq,
     Debug,
@@ -1684,34 +1713,81 @@ impl RpcMethod<2> for EthEstimateGas {
         // Set the gas limit to the zero sentinel value, which makes
         // gas estimation actually run.
         msg.gas_limit = 0;
-        let tsk = if let Some(block_param) = block_param {
-            Some(
-                tipset_by_block_number_or_hash(ctx.chain_store(), block_param)?
-                    .key()
-                    .clone(),
-            )
+        let tipset = if let Some(block_param) = block_param {
+            tipset_by_block_number_or_hash(ctx.chain_store(), block_param)?
         } else {
-            None
+            ctx.chain_store().heaviest_tipset()
         };
-        match gas::estimate_message_gas(&ctx, msg, None, tsk.clone().into()).await {
-            Err(e) => {
+
+        match gas::estimate_message_gas(&ctx, msg.clone(), None, tipset.key().clone().into()).await
+        {
+            Err(mut 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.
-                // TODO(forest): https://github.com/ChainSafe/forest/issues/4554
-                Err(anyhow::anyhow!("failed to estimate gas: {e}").into())
+                msg.set_gas_limit(BLOCK_GAS_LIMIT);
+                if let Err(e) = apply_message(&ctx, Some(tipset), msg).await {
+                    // if the error is an execution reverted, return it directly
+                    if e.downcast_ref::<EthErrors>().is_some_and(|eth_err| {
+                        matches!(eth_err, EthErrors::ExecutionReverted { .. })
+                    }) {
+                        return Err(e.into());
+                    }
+
+                    err = e.into();
+                }
+
+                Err(anyhow::anyhow!("failed to estimate gas: {err}").into())
             }
             Ok(gassed_msg) => {
-                let expected_gas = Self::eth_gas_search(&ctx, gassed_msg, &tsk.into()).await?;
+                log::info!("correct gassed_msg: do eth_gas_search {:?}", gassed_msg);
+                let expected_gas =
+                    Self::eth_gas_search(&ctx, gassed_msg, &tipset.key().into()).await?;
+                log::info!("trying eth_gas search: {}", expected_gas);
                 Ok(expected_gas.into())
             }
         }
     }
 }
 
+async fn apply_message<DB>(
+    ctx: &Ctx<DB>,
+    tipset: Option<Arc<Tipset>>,
+    msg: Message,
+) -> Result<ApiInvocResult, Error>
+where
+    DB: Blockstore + Send + Sync + 'static,
+{
+    let invoc_res = ctx
+        .state_manager
+        .apply_on_state_with_gas(tipset, msg)
+        .await
+        .map_err(|e| anyhow::anyhow!("failed to apply on state with gas: {e}"))?;
+
+    // Extract receipt or return early if none
+    match &invoc_res.msg_rct {
+        None => return Err(anyhow::anyhow!("no message receipt in execution result")),
+        Some(receipt) => {
+            if !receipt.exit_code().is_success() {
+                let (data, reason) = decode_revert_reason(receipt.return_data());
+
+                return Err(EthErrors::execution_reverted(
+                    ExitCode::from(receipt.exit_code()),
+                    reason.as_str(),
+                    invoc_res.error.as_str(),
+                    data.as_slice(),
+                )
+                .into());
+            }
+        }
+    };
+
+    Ok(invoc_res)
+}
+
 impl EthEstimateGas {
     pub async fn eth_gas_search<DB>(
         data: &Ctx<DB>,
@@ -1782,7 +1858,7 @@ impl EthEstimateGas {
             DB: Blockstore + Send + Sync + 'static,
         {
             msg.gas_limit = limit;
-            let (_invoc_res, apply_ret) = data
+            let (_invoc_res, apply_ret, _) = data
                 .state_manager
                 .call_with_gas(
                     &mut msg.into(),
@@ -2397,9 +2473,9 @@ impl RpcMethod<2> for EthCall {
         ctx: Ctx<impl Blockstore + Send + Sync + 'static>,
         (tx, block_param): Self::Params,
     ) -> Result<Self::Ok, ServerError> {
-        let msg = tx.try_into()?;
+        let msg = Message::try_from(tx)?;
         let ts = tipset_by_block_number_or_hash(ctx.chain_store(), block_param)?;
-        let invoke_result = ctx.state_manager.call(&msg, Some(ts))?;
+        let invoke_result = apply_message(&ctx, Some(ts), msg.clone()).await?;
 
         if msg.to() == FilecoinAddress::ETHEREUM_ACCOUNT_MANAGER_ACTOR {
             Ok(EthBytes::default())
@@ -3738,4 +3814,94 @@ mod test {
         ];
         assert!(eth_log_from_event(&entries).is_none());
     }
+
+    #[test]
+    fn test_from_bytes_valid() {
+        let zero_bytes = [0u8; 32];
+        assert_eq!(
+            EthUint64::from_bytes(&zero_bytes).unwrap().0,
+            0,
+            "zero bytes"
+        );
+
+        let mut value_bytes = [0u8; 32];
+        value_bytes[31] = 42;
+        assert_eq!(
+            EthUint64::from_bytes(&value_bytes).unwrap().0,
+            42,
+            "simple value"
+        );
+
+        let mut max_bytes = [0u8; 32];
+        max_bytes[24..32].copy_from_slice(&u64::MAX.to_be_bytes());
+        assert_eq!(
+            EthUint64::from_bytes(&max_bytes).unwrap().0,
+            u64::MAX,
+            "valid max value"
+        );
+    }
+
+    #[test]
+    fn test_from_bytes_wrong_length() {
+        let short_bytes = [0u8; 31];
+        assert!(
+            EthUint64::from_bytes(&short_bytes).is_err(),
+            "bytes too short"
+        );
+
+        let long_bytes = [0u8; 33];
+        assert!(
+            EthUint64::from_bytes(&long_bytes).is_err(),
+            "bytes too long"
+        );
+
+        let empty_bytes = [];
+        assert!(
+            EthUint64::from_bytes(&empty_bytes).is_err(),
+            "bytes too short"
+        );
+    }
+
+    #[test]
+    fn test_from_bytes_overflow() {
+        let mut overflow_bytes = [0u8; 32];
+        overflow_bytes[10] = 1;
+        assert!(
+            EthUint64::from_bytes(&overflow_bytes).is_err(),
+            "overflow with non-zero byte at position 10"
+        );
+
+        overflow_bytes = [0u8; 32];
+        overflow_bytes[23] = 1;
+        assert!(
+            EthUint64::from_bytes(&overflow_bytes).is_err(),
+            "overflow with non-zero byte at position 23"
+        );
+
+        overflow_bytes = [0u8; 32];
+        overflow_bytes
+            .iter_mut()
+            .take(24)
+            .for_each(|byte| *byte = 0xFF);
+
+        assert!(
+            EthUint64::from_bytes(&overflow_bytes).is_err(),
+            "overflow bytes with non-zero bytes at positions 0-23"
+        );
+
+        overflow_bytes = [0u8; 32];
+        for i in 0..24 {
+            overflow_bytes[i] = 0xFF;
+            assert!(
+                EthUint64::from_bytes(&overflow_bytes).is_err(),
+                "overflow with non-zero byte at position {i}"
+            );
+        }
+
+        overflow_bytes = [0xFF; 32];
+        assert!(
+            EthUint64::from_bytes(&overflow_bytes).is_err(),
+            "overflow with all ones"
+        );
+    }
 }
diff --git a/src/rpc/methods/eth/errors.rs b/src/rpc/methods/eth/errors.rs
new file mode 100644
index 000000000000..0eb6089c72ec
--- /dev/null
+++ b/src/rpc/methods/eth/errors.rs
@@ -0,0 +1,56 @@
+// Copyright 2019-2025 ChainSafe Systems
+// SPDX-License-Identifier: Apache-2.0, MIT
+
+use crate::rpc::error::RpcErrorData;
+use crate::shim::error::ExitCode;
+use serde::Serialize;
+use std::fmt::Debug;
+use thiserror::Error;
+
+/// This error indicates that the execution reverted while executing the message.
+/// Code is taken from https://github.com/filecoin-project/lotus/blob/release/v1.32.1/api/api_errors.go#L27
+pub const EXECUTION_REVERTED_CODE: i32 = 11;
+
+#[derive(Clone, Debug, Error, Serialize)]
+pub enum EthErrors {
+    #[error("{message}")]
+    ExecutionReverted {
+        message: String,
+        data: Option<String>,
+    },
+}
+
+impl EthErrors {
+    /// Create a new ExecutionReverted error with formatted message
+    pub fn execution_reverted(exit_code: ExitCode, error: &str, reason: &str, data: &[u8]) -> Self {
+        Self::ExecutionReverted {
+            message: format!(
+                "message execution failed (exit=[{}], revert reason=[{}], vm error=[{}])",
+                exit_code, reason, error
+            ),
+            data: (!data.is_empty()).then(|| format!("0x{}", hex::encode(data))),
+        }
+    }
+}
+
+impl RpcErrorData for EthErrors {
+    fn error_code(&self) -> Option<i32> {
+        match self {
+            EthErrors::ExecutionReverted { .. } => Some(EXECUTION_REVERTED_CODE),
+        }
+    }
+
+    fn error_message(&self) -> Option<String> {
+        match self {
+            EthErrors::ExecutionReverted { message, .. } => Some(message.clone()),
+        }
+    }
+
+    fn error_data(&self) -> Option<serde_json::Value> {
+        match self {
+            EthErrors::ExecutionReverted { data, .. } => {
+                data.clone().map(serde_json::Value::String)
+            }
+        }
+    }
+}
diff --git a/src/rpc/methods/eth/utils.rs b/src/rpc/methods/eth/utils.rs
index c809f0c94c40..de831a65ff1d 100644
--- a/src/rpc/methods/eth/utils.rs
+++ b/src/rpc/methods/eth/utils.rs
@@ -6,13 +6,17 @@ use crate::rpc::state::{MessageTrace, ReturnTrace};
 use crate::shim::address::Address as FilecoinAddress;
 use crate::shim::fvm_shared_latest::IDENTITY_HASH;
 use crate::shim::state_tree::StateTree;
+use ahash::{HashMap, HashMapExt};
 
+use crate::rpc::eth::{EVM_WORD_LENGTH, EthUint64};
 use anyhow::{Result, bail};
 use cbor4ii::core::Value;
 use cbor4ii::core::dec::Decode as _;
 use fvm_ipld_blockstore::Blockstore;
 use fvm_ipld_encoding::{CBOR, DAG_CBOR, IPLD_RAW, RawBytes};
+use once_cell::sync::Lazy;
 use serde::de;
+use tracing::log;
 
 pub fn lookup_eth_address<DB: Blockstore>(
     addr: &FilecoinAddress,
@@ -97,12 +101,401 @@ where
     }
 }
 
+/// Extract and decode Ethereum revert reason from receipt return data
+pub fn decode_revert_reason(return_data: RawBytes) -> (Vec<u8>, String) {
+    let (data, reason) = match decode_payload(&return_data, CBOR) {
+        Err(e) => {
+            log::warn!("failed to unmarshal cbor bytes from message receipt return error: {e}");
+            (
+                EthBytes::default(),
+                String::from("ERROR: revert reason is not cbor encoded bytes"),
+            )
+        }
+        Ok(data) => (data.clone(), parse_eth_revert(data.0.as_slice())),
+    };
+
+    (data.0, reason)
+}
+
+const ERROR_FUNCTION_SELECTOR: [u8; 4] = [0x08, 0xc3, 0x79, 0xa0]; // keccak256("Error(string)") [first 4 bytes]
+const PANIC_FUNCTION_SELECTOR: [u8; 4] = [0x4e, 0x48, 0x7b, 0x71]; // keccak256("Panic(uint256)") [first 4 bytes]
+
+// Lazily initialized HashMap for panic codes
+static PANIC_ERROR_CODES: Lazy<HashMap<u64, &'static str>> = Lazy::new(|| {
+    let mut m = HashMap::new();
+    m.insert(0x00, "Panic()");
+    m.insert(0x01, "Assert()");
+    m.insert(0x11, "ArithmeticOverflow()");
+    m.insert(0x12, "DivideByZero()");
+    m.insert(0x21, "InvalidEnumVariant()");
+    m.insert(0x22, "InvalidStorageArray()");
+    m.insert(0x31, "PopEmptyArray()");
+    m.insert(0x32, "ArrayIndexOutOfBounds()");
+    m.insert(0x41, "OutOfMemory()");
+    m.insert(0x51, "CalledUninitializedFunction()");
+    m
+});
+
+/// EVM error and panic related constants
+const EVM_FUNC_SELECTOR_LENGTH: usize = 4;
+const EVM_PANIC_CODE_LENGTH: usize = 32;
+const EVM_UINT_PADDING_LENGTH: usize = 24;
+
+/// Parse an ABI encoded revert reason from a raw return value.
+///
+/// Handles both `Error(string)` and `Panic(uint256)` formats according to
+/// Solidity's revert conventions.
+///
+/// See https://docs.soliditylang.org/en/latest/control-structures.html#panic-via-assert-and-error-via-require
+fn parse_eth_revert(data: &[u8]) -> String {
+    // If it's not long enough to contain an ABI encoded response, return immediately.
+    if data.len() < EVM_FUNC_SELECTOR_LENGTH + EVM_WORD_LENGTH {
+        return format!("0x{}", hex::encode(data));
+    }
+
+    // Extract function selector (first 4 bytes)
+    let selector = data
+        .get(..EVM_FUNC_SELECTOR_LENGTH)
+        .expect("checked data length >= 4");
+
+    match selector {
+        selector if selector == PANIC_FUNCTION_SELECTOR.as_slice() => parse_panic_revert(data),
+        selector if selector == ERROR_FUNCTION_SELECTOR.as_slice() => parse_error_revert(data),
+        _ => format!("0x{}", hex::encode(data)),
+    }
+}
+
+fn parse_error_revert(data: &[u8]) -> String {
+    let fallback = || format!("0x{}", hex::encode(data));
+
+    let parse_result: Result<String, ()> = (|| {
+        let data = data
+            .get(EVM_FUNC_SELECTOR_LENGTH..)
+            .filter(|d| d.len() >= EVM_WORD_LENGTH)
+            .ok_or(())?;
+
+        // Get offset, from the first 32 bytes of the data
+        let offset_bytes = data.get(..EVM_WORD_LENGTH).ok_or(())?;
+        let offset = EthUint64::from_bytes(offset_bytes).map_err(|_| ())?.0 as usize;
+
+        // Validate offset range
+        if offset >= data.len() || data.len().saturating_sub(offset) < EVM_WORD_LENGTH {
+            return Err(());
+        }
+
+        // Get string length, from the offset + 32 bytes of the data
+        let length_bytes = data.get(offset..offset + EVM_WORD_LENGTH).ok_or(())?;
+        let len = EthUint64::from_bytes(length_bytes).map_err(|_| ())?.0 as usize;
+
+        // Validate string length
+        let string_start = offset + EVM_WORD_LENGTH;
+        if string_start > data.len() || len > data.len() - string_start {
+            return Err(());
+        }
+
+        // Attempt to decode valid UTF-8
+        let string = data.get(string_start..string_start + len).ok_or(())?;
+        Ok(format!(
+            "Error({})",
+            std::str::from_utf8(string).map_err(|_| ())?
+        ))
+    })();
+
+    parse_result.unwrap_or_else(|_| fallback())
+}
+
+fn parse_panic_revert(data: &[u8]) -> String {
+    let fallback = || format!("0x{}", hex::encode(data));
+
+    let parse_result: Result<String, ()> = (|| {
+        let code_bytes = data
+            .get(EVM_FUNC_SELECTOR_LENGTH..EVM_FUNC_SELECTOR_LENGTH + EVM_PANIC_CODE_LENGTH)
+            .ok_or(())?;
+
+        // Check if first 24 bytes are all zeros
+        if !code_bytes
+            .get(..EVM_UINT_PADDING_LENGTH)
+            .ok_or(())?
+            .iter()
+            .all(|&v| v == 0)
+        {
+            return Ok(format!("Panic(0x{})", hex::encode(code_bytes)));
+        }
+
+        let code_data = code_bytes.get(..EVM_WORD_LENGTH).ok_or(())?;
+        let code = EthUint64::from_bytes(code_data).map_err(|_| ())?.0;
+        Ok(PANIC_ERROR_CODES
+            .get(&code)
+            .map(|s| s.to_string())
+            .unwrap_or_else(|| format!("Panic(0x{:x})", code)))
+    })();
+
+    parse_result.unwrap_or_else(|_| fallback())
+}
+
 #[cfg(test)]
 mod test {
     use super::*;
     use cbor4ii::core::{enc::Encode, utils::BufWriter};
     use cbor4ii::serde::Serializer;
 
+    fn create_error_data(msg: &str) -> Vec<u8> {
+        let mut encoded = Vec::new();
+
+        // Step 1: Add function selector (keccak256("Error(string)") first 4 bytes)
+        encoded.extend_from_slice(&[0x08, 0xc3, 0x79, 0xa0]);
+
+        // Add offset to string data (32 bytes, value = 32)
+        // This points to where the string length is stored
+        let mut offset_bytes = [0u8; 32];
+        offset_bytes[24..32].copy_from_slice(&32u64.to_be_bytes());
+        encoded.extend_from_slice(&offset_bytes);
+
+        // Add string length (32 bytes)
+        let mut length_bytes = [0u8; 32];
+        length_bytes[24..32].copy_from_slice(&(msg.len() as u64).to_be_bytes());
+        encoded.extend_from_slice(&length_bytes);
+
+        // Add string data
+        encoded.extend_from_slice(msg.as_bytes());
+
+        // Pad to 32-byte boundary
+        let padding_needed = (32 - (msg.len() % 32)) % 32;
+        encoded.extend_from_slice(&vec![0; padding_needed]);
+
+        encoded
+    }
+
+    fn create_panic_data(code: u64) -> Vec<u8> {
+        let mut data = Vec::new();
+        data.extend_from_slice(&PANIC_FUNCTION_SELECTOR);
+
+        // Add padding (24 bytes) + code (32 bytes)
+        data.extend_from_slice(&[0; 24]);
+        data.extend_from_slice(&code.to_be_bytes());
+        data
+    }
+
+    #[test]
+    fn test_all_valid_parse_panic_revert() {
+        for (code, msg) in PANIC_ERROR_CODES.iter() {
+            let data = create_panic_data(*code);
+            assert_eq!(parse_panic_revert(&data), format!("{}", msg));
+        }
+    }
+
+    #[test]
+    fn test_all_valid_hex_parse_error_revert() {
+        let panic_data =
+            hex::decode("4e487b710000000000000000000000000000000000000000000000000000000000000000")
+                .unwrap();
+        assert_eq!(parse_panic_revert(&panic_data), "Panic()");
+
+        let assert_data =
+            hex::decode("4e487b710000000000000000000000000000000000000000000000000000000000000001")
+                .unwrap();
+        assert_eq!(parse_panic_revert(&assert_data), "Assert()");
+
+        let arithmetic_overflow_data =
+            hex::decode("4e487b710000000000000000000000000000000000000000000000000000000000000011")
+                .unwrap();
+        assert_eq!(
+            parse_panic_revert(&arithmetic_overflow_data),
+            "ArithmeticOverflow()"
+        );
+
+        let divide_by_zero_data =
+            hex::decode("4e487b710000000000000000000000000000000000000000000000000000000000000012")
+                .unwrap();
+        assert_eq!(parse_panic_revert(&divide_by_zero_data), "DivideByZero()");
+
+        let invalid_enum_variant_data =
+            hex::decode("4e487b710000000000000000000000000000000000000000000000000000000000000021")
+                .unwrap();
+        assert_eq!(
+            parse_panic_revert(&invalid_enum_variant_data),
+            "InvalidEnumVariant()"
+        );
+
+        let invalid_storage_array_data =
+            hex::decode("4e487b710000000000000000000000000000000000000000000000000000000000000022")
+                .unwrap();
+        assert_eq!(
+            parse_panic_revert(&invalid_storage_array_data),
+            "InvalidStorageArray()"
+        );
+
+        let pop_empty_array_data =
+            hex::decode("4e487b710000000000000000000000000000000000000000000000000000000000000031")
+                .unwrap();
+        assert_eq!(parse_panic_revert(&pop_empty_array_data), "PopEmptyArray()");
+
+        let array_index_out_of_bounds_data =
+            hex::decode("4e487b710000000000000000000000000000000000000000000000000000000000000032")
+                .unwrap();
+        assert_eq!(
+            parse_panic_revert(&array_index_out_of_bounds_data),
+            "ArrayIndexOutOfBounds()"
+        );
+
+        let out_of_memory_data =
+            hex::decode("4e487b710000000000000000000000000000000000000000000000000000000000000041")
+                .unwrap();
+        assert_eq!(parse_panic_revert(&out_of_memory_data), "OutOfMemory()");
+
+        let call_uninitialized_data =
+            hex::decode("4e487b710000000000000000000000000000000000000000000000000000000000000051")
+                .unwrap();
+        assert_eq!(
+            parse_panic_revert(&call_uninitialized_data),
+            "CalledUninitializedFunction()"
+        );
+    }
+
+    #[test]
+    fn test_parse_error_revert() {
+        let err_msg = "Not enough Ether provided";
+        let error_data = create_error_data(err_msg);
+        assert_eq!(
+            parse_error_revert(&error_data),
+            format!("Error({})", err_msg)
+        );
+
+        // ABI-encoded Error("Hello World")
+        let err_data = hex::decode(
+            "\
+            08c379a0\
+            0000000000000000000000000000000000000000000000000000000000000020\
+            000000000000000000000000000000000000000000000000000000000000000b\
+            48656c6c6f20576f726c64000000000000000000000000000000000000000000\
+            ",
+        )
+        .unwrap();
+        assert_eq!(parse_error_revert(&err_data), "Error(Hello World)");
+
+        // ERC20 insufficient balance
+        let insufficient = hex::decode(
+            "08c379a0\
+                0000000000000000000000000000000000000000000000000000000000000020\
+                0000000000000000000000000000000000000000000000000000000000000026\
+                45524332303a207472616e7366657220616d6f756e7420657863656564732062\
+                616c616e63650000000000000000000000000000000000000000000000000000",
+        )
+        .unwrap();
+        assert_eq!(
+            parse_eth_revert(&insufficient),
+            "Error(ERC20: transfer amount exceeds balance)"
+        );
+    }
+
+    #[test]
+    fn test_parse_eth_revert_main_function() {
+        // Test normal Error case
+        let message = "Transaction failed";
+        let data = create_error_data(message);
+        assert_eq!(parse_eth_revert(&data), format!("Error({})", message));
+
+        // Test normal Panic case
+        let panic_data = create_panic_data(0x01); // Assert()
+        assert_eq!(parse_eth_revert(&panic_data), "Assert()");
+
+        // Test data too short for any revert reason
+        let short_data = vec![0x1, 0x2, 0x3];
+        assert_eq!(
+            parse_eth_revert(&short_data),
+            format!("0x{}", hex::encode(&short_data))
+        );
+
+        // Test unknown function selector
+        let mut unknown_selector = vec![0; EVM_FUNC_SELECTOR_LENGTH + EVM_WORD_LENGTH];
+        unknown_selector[0] = 0xAA;
+        unknown_selector[1] = 0xBB;
+        unknown_selector[2] = 0xCC;
+        unknown_selector[3] = 0xDD;
+        assert_eq!(
+            parse_eth_revert(&unknown_selector),
+            format!("0x{}", hex::encode(&unknown_selector))
+        );
+    }
+
+    #[test]
+    fn test_parse_error_revert_special_cases() {
+        // Test with empty error message
+        let data = create_error_data("");
+        assert_eq!(parse_error_revert(&data), "Error()");
+
+        // Test with special characters
+        let special = "Error message with special chars: !@#$%6^&*()_+{}|:<>!?";
+        let data = create_error_data(special);
+        assert_eq!(parse_error_revert(&data), format!("Error({})", special));
+
+        // Test with Unicode characters
+        let unicode = "Error with Unicode: 你好世界";
+        let data = create_error_data(unicode);
+        assert_eq!(parse_error_revert(&data), format!("Error({})", unicode));
+
+        // Test with invalid offset (points outside data)
+        let mut invalid_offset = create_error_data("Test");
+        // Modify offset to point outside available data
+        invalid_offset
+            .iter_mut()
+            .skip(24)
+            .take(8)
+            .for_each(|byte| *byte = 0xFF);
+        assert_eq!(
+            parse_error_revert(&invalid_offset),
+            format!("0x{}", hex::encode(&invalid_offset))
+        );
+
+        // Test with invalid length (exceeds available data)
+        let mut invalid_length = create_error_data("Test");
+        // Set offset to valid 32, but make length too large
+        invalid_length
+            .iter_mut()
+            .skip(32 + 24)
+            .take(8)
+            .for_each(|byte| *byte = 0xFF);
+        assert_eq!(
+            parse_error_revert(&invalid_length),
+            format!("0x{}", hex::encode(&invalid_length))
+        );
+
+        // Test with truncated data (not enough for string data)
+        let truncated = create_error_data("Test");
+        let truncated = &truncated[0..70]; // Cut off after length field
+        assert_eq!(
+            parse_error_revert(truncated),
+            format!("0x{}", hex::encode(truncated))
+        );
+
+        // Test with invalid UTF-8 in the string
+        let mut invalid_utf8 = create_error_data("Test string");
+        // Insert invalid UTF-8 sequence
+        let string_start = 32 + 32;
+        invalid_utf8[string_start + 2] = 0xFF;
+        assert_eq!(
+            parse_error_revert(&invalid_utf8),
+            format!("0x{}", hex::encode(&invalid_utf8))
+        );
+    }
+
+    #[test]
+    fn test_eth_revert_boundary_conditions() {
+        // Test with exactly minimum size data
+        let min_size = vec![0; EVM_FUNC_SELECTOR_LENGTH + EVM_WORD_LENGTH];
+        assert_eq!(
+            parse_eth_revert(&min_size),
+            format!("0x{}", hex::encode(&min_size))
+        );
+
+        // Test with exactly one byte less than minimum
+        let too_small = vec![0; EVM_FUNC_SELECTOR_LENGTH + EVM_WORD_LENGTH - 1];
+        assert_eq!(
+            parse_eth_revert(&too_small),
+            format!("0x{}", hex::encode(&too_small))
+        );
+    }
+
     #[test]
     fn test_decode_payload() {
         // empty
diff --git a/src/rpc/methods/gas.rs b/src/rpc/methods/gas.rs
index 84547d1abe58..af45da0b5353 100644
--- a/src/rpc/methods/gas.rs
+++ b/src/rpc/methods/gas.rs
@@ -3,6 +3,7 @@
 
 use std::sync::Arc;
 
+use super::state::InvocResult;
 use crate::blocks::Tipset;
 use crate::chain::{BASE_FEE_MAX_CHANGE_DENOM, BLOCK_GAS_TARGET};
 use crate::interpreter::VMTrace;
@@ -21,8 +22,6 @@ use num::BigInt;
 use num_traits::{FromPrimitive, Zero};
 use rand_distr::{Distribution, Normal};
 
-use super::state::InvocResult;
-
 const MIN_GAS_PREMIUM: f64 = 100000.0;
 
 /// Estimate the fee cap
@@ -239,7 +238,7 @@ impl GasEstimateGasLimit {
             _ => ChainMessage::Unsigned(msg),
         };
 
-        let (invoc_res, apply_ret) = data
+        let (invoc_res, apply_ret, _) = data
             .state_manager
             .call_with_gas(
                 &mut chain_msg,
@@ -255,16 +254,21 @@ impl GasEstimateGasLimit {
         data: &Ctx<DB>,
         msg: Message,
         tsk: &ApiTipsetKey,
-    ) -> anyhow::Result<i64>
+    ) -> Result<i64>
     where
         DB: Blockstore + Send + Sync + 'static,
     {
-        let (res, ..) = Self::estimate_call_with_gas(data, msg, tsk, VMTrace::NotTraced).await?;
+        let (res, ..) = Self::estimate_call_with_gas(data, msg, tsk, VMTrace::NotTraced)
+            .await
+            .map_err(|e| anyhow::anyhow!("gas estimation failed: {e}"))?;
         match res.msg_rct {
             Some(rct) => {
-                if rct.exit_code().value() != 0 {
-                    return Ok(-1);
-                }
+                anyhow::ensure!(
+                    rct.exit_code().is_success(),
+                    "message execution failed: exit code: {}, reason: {}",
+                    rct.exit_code().value(),
+                    res.error.unwrap_or_default()
+                );
                 Ok(rct.gas_used() as i64)
             }
             None => Ok(-1),
diff --git a/src/state_manager/mod.rs b/src/state_manager/mod.rs
index fd65fa9b65ba..45509c63e12a 100644
--- a/src/state_manager/mod.rs
+++ b/src/state_manager/mod.rs
@@ -20,7 +20,7 @@ use crate::interpreter::{
 };
 use crate::interpreter::{MessageCallbackCtx, VMTrace};
 use crate::lotus_json::{LotusJson, lotus_json_with_self};
-use crate::message::{ChainMessage, Message as MessageTrait};
+use crate::message::{ChainMessage, Message as MessageTrait, SignedMessage};
 use crate::networks::ChainConfig;
 use crate::rpc::state::{ApiInvocResult, InvocResult, MessageGasCost};
 use crate::rpc::types::{MiningBaseInfo, SectorOnChainInfo};
@@ -28,6 +28,7 @@ use crate::shim::actors::init::{self, State};
 use crate::shim::actors::miner::{MinerInfo, MinerPower, Partition};
 use crate::shim::actors::verifreg::{Allocation, AllocationID, Claim};
 use crate::shim::actors::*;
+use crate::shim::crypto::{Signature, SignatureType};
 use crate::shim::{
     actors::{
         LoadActorStateFromBlockstore, miner::ext::MinerStateExt as _,
@@ -62,6 +63,7 @@ use fil_actors_shared::v13::runtime::Policy;
 use futures::{FutureExt, channel::oneshot, select};
 use fvm_ipld_blockstore::Blockstore;
 use fvm_ipld_encoding::to_vec;
+use fvm_shared4::crypto::signature::SECP_SIG_LEN;
 use itertools::Itertools as _;
 use lru::LruCache;
 use nonzero_ext::nonzero;
@@ -72,6 +74,7 @@ use rayon::prelude::ParallelBridge;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use std::ops::RangeInclusive;
+use std::time::Duration;
 use std::{num::NonZeroUsize, sync::Arc};
 use tokio::sync::{Mutex as TokioMutex, RwLock, broadcast::error::RecvError};
 use tracing::{error, info, instrument, trace, warn};
@@ -677,6 +680,49 @@ where
         self.call_raw(message, chain_rand, &ts)
     }
 
+    pub async fn apply_on_state_with_gas(
+        self: &Arc<Self>,
+        tipset: Option<Arc<Tipset>>,
+        msg: Message,
+    ) -> anyhow::Result<ApiInvocResult> {
+        let ts = tipset.unwrap_or_else(|| self.cs.heaviest_tipset());
+
+        // Handle state forks
+        // TODO(elmattic): https://github.com/ChainSafe/forest/issues/3733
+
+        let from_a = self.resolve_to_key_addr(&msg.from, &ts).await?;
+
+        // Pretend that the message is signed. This has an influence on the gas
+        // cost. We obviously can't generate a valid signature. Instead, we just
+        // fill the signature with zeros. The validity is not checked.
+        let mut chain_msg = match from_a.protocol() {
+            Protocol::Secp256k1 => ChainMessage::Signed(SignedMessage::new_unchecked(
+                msg.clone(),
+                Signature::new_secp256k1(vec![0; SECP_SIG_LEN]),
+            )),
+            Protocol::Delegated => ChainMessage::Signed(SignedMessage::new_unchecked(
+                msg.clone(),
+                // In Lotus, delegated signatures have the same length as SECP256k1.
+                // This may or may not change in the future.
+                Signature::new(SignatureType::Delegated, vec![0; SECP_SIG_LEN]),
+            )),
+            _ => ChainMessage::Unsigned(msg.clone()),
+        };
+
+        let (_invoc_res, apply_ret, duration) = self
+            .call_with_gas(&mut chain_msg, &[], Some(ts), VMTrace::Traced)
+            .await?;
+        Ok(ApiInvocResult {
+            msg_cid: msg.cid(),
+            msg,
+            msg_rct: Some(apply_ret.msg_receipt()),
+            error: apply_ret.failure_info().unwrap_or_default(),
+            duration: duration.as_nanos().clamp(0, u64::MAX as u128) as u64,
+            gas_cost: MessageGasCost::default(),
+            execution_trace: structured::parse_events(apply_ret.exec_trace()).unwrap_or_default(),
+        })
+    }
+
     /// Computes message on the given [Tipset] state, after applying other
     /// messages and returns the values computed in the VM.
     pub async fn call_with_gas(
@@ -685,12 +731,12 @@ where
         prior_messages: &[ChainMessage],
         tipset: Option<Arc<Tipset>>,
         trace_config: VMTrace,
-    ) -> Result<(InvocResult, ApplyRet), Error> {
+    ) -> Result<(InvocResult, ApplyRet, Duration), Error> {
         let ts = tipset.unwrap_or_else(|| self.cs.heaviest_tipset());
         let (st, _) = self
             .tipset_state(&ts)
             .await
-            .map_err(|_| Error::Other("Could not load tipset state".to_string()))?;
+            .map_err(|e| Error::Other(format!("Could not load tipset state: {e}")))?;
         let chain_rand = self.chain_rand(Arc::clone(&ts));
 
         // Since we're simulating a future message, pretend we're applying it in the
@@ -699,7 +745,7 @@ where
         let genesis_info = GenesisInfo::from_chain_config(self.chain_config().clone());
         // 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, _) = stacker::grow(64 << 20, || -> ApplyResult {
+        let (ret, duration) = stacker::grow(64 << 20, || -> ApplyResult {
             let mut vm = VM::new(
                 ExecutionContext {
                     heaviest_tipset: Arc::clone(&ts),
@@ -728,11 +774,14 @@ where
                 .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()))?;
             message.set_sequence(from_actor.sequence);
-
             vm.apply_message(message)
         })?;
 
-        Ok((InvocResult::new(message.message().clone(), &ret), ret))
+        Ok((
+            InvocResult::new(message.message().clone(), &ret),
+            ret,
+            duration,
+        ))
     }
 
     /// Replays the given message and returns the result of executing the
diff --git a/src/tool/subcommands/api_cmd/test_snapshots.txt b/src/tool/subcommands/api_cmd/test_snapshots.txt
index 421e8c2deb57..f098b68dd9bd 100644
--- a/src/tool/subcommands/api_cmd/test_snapshots.txt
+++ b/src/tool/subcommands/api_cmd/test_snapshots.txt
@@ -18,7 +18,9 @@ filecoin_chaintipsetweight_1736937942819105.rpcsnap.json.zst
 filecoin_ethaccounts_1737446676689764.rpcsnap.json.zst
 filecoin_ethaddresstofilecoinaddress_1740132538465536.rpcsnap.json.zst
 filecoin_ethblocknumber_1741272348346171.rpcsnap.json.zst
-filecoin_ethcall_1737446676693468.rpcsnap.json.zst
+filecoin_ethcall_1744204533050503.rpcsnap.json.zst
+filecoin_ethcall_1744204533058637.rpcsnap.json.zst
+filecoin_ethcall_1744204533066529.rpcsnap.json.zst
 filecoin_ethchainid_1736937942819147.rpcsnap.json.zst
 filecoin_ethfeehistory_1737446676883828.rpcsnap.json.zst
 filecoin_ethgasprice_1741280031788015.rpcsnap.json.zst


Reviews:
user: login: elmattic
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T12:48:25Z
user: login: elmattic
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T12:53:14Z
user: login: elmattic
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T13:18:11Z
user: login: elmattic
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T13:24:49Z
user: login: elmattic
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T13:41:51Z
user: login: akaladarshi
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T08:37:41Z
user: login: akaladarshi
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T08:40:54Z
user: login: elmattic
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T09:10:45Z
user: login: LesnyRumcajs
body: LGTM, other than remarks already done by @elmattic.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T09:59:00Z
user: login: elmattic
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T11:28:18Z
user: login: LesnyRumcajs
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T11:37:15Z
user: login: akaladarshi
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T10:45:35Z
user: login: akaladarshi
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T10:45:39Z
user: login: akaladarshi
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T10:45:43Z
user: login: akaladarshi
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T10:45:47Z
user: login: akaladarshi
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T10:46:08Z
user: login: akaladarshi
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T10:46:13Z
user: login: akaladarshi
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T10:46:41Z
user: login: LesnyRumcajs
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T14:22:38Z
user: login: LesnyRumcajs
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T16:19:33Z
user: login: akaladarshi
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T17:38:33Z
user: login: elmattic
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T17:54:37Z
user: login: akaladarshi
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T19:05:07Z
user: login: akaladarshi
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T19:05:13Z
user: login: akaladarshi
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T19:05:26Z
user: login: akaladarshi
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T19:05:31Z
user: login: akaladarshi
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T19:05:37Z
user: login: LesnyRumcajs
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T08:29:43Z
user: login: LesnyRumcajs
body: LGTM
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-10T08:30:29Z
user: login: elmattic
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T08:44:29Z

Review comments:
diff_hunk: @@ -15,6 +16,13 @@ pub struct ServerError {
     inner: ErrorObjectOwned,
 }
 
+/// Custom error codes for the Forest node.
+pub enum ForestError {
+    /// This error indicates that the execution reverted while executing the message.
+    /// Code is taken from https://github.com/filecoin-project/lotus/blob/master/api/api_errors.go#L27
path: src/rpc/error.rs
user: login: elmattic
body: Could you provide a stable link (with a specific lotus version) to prevent code changes under the hood?
created_at: 2025-04-02T12:48:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -72,9 +74,10 @@ use rayon::prelude::ParallelBridge;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use std::ops::RangeInclusive;
+use std::time::Duration;
 use std::{num::NonZeroUsize, sync::Arc};
 use tokio::sync::{broadcast::error::RecvError, Mutex as TokioMutex, RwLock};
-use tracing::{error, info, instrument, trace, warn};
+use tracing::{error, info, instrument, log, trace, warn};
path: src/state_manager/mod.rs
user: login: elmattic
body: You can remove unused `log` import.
created_at: 2025-04-02T12:53:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -97,6 +100,130 @@ where
     }
 }
 
+/// Extract and decode Ethereum revert reason from receipt return data
+pub fn decode_revert_reason(return_data: RawBytes) -> (Vec<u8>, String) {
+    let (data, reason) = match decode_payload(&return_data, CBOR) {
+        Err(e) => {
+            log::warn!("failed to unmarshal cbor bytes from message receipt return error: {e}");
+            (
+                EthBytes::default(),
+                String::from("ERROR: revert reason is not cbor encoded bytes"),
+            )
+        }
+        Ok(data) => (data.clone(), parse_eth_revert(data.0.as_slice())),
+    };
+
+    (data.0, reason)
+}
+
+const ERROR_FUNCTION_SELECTOR: [u8; 4] = [0x08, 0xc3, 0x79, 0xa0]; // Error(string)
path: src/rpc/methods/eth/utils.rs
user: login: elmattic
body: Could you explain how to obtain those magic bytes?
created_at: 2025-04-02T13:18:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -97,6 +100,130 @@ where
     }
 }
 
+/// Extract and decode Ethereum revert reason from receipt return data
+pub fn decode_revert_reason(return_data: RawBytes) -> (Vec<u8>, String) {
+    let (data, reason) = match decode_payload(&return_data, CBOR) {
+        Err(e) => {
+            log::warn!("failed to unmarshal cbor bytes from message receipt return error: {e}");
+            (
+                EthBytes::default(),
+                String::from("ERROR: revert reason is not cbor encoded bytes"),
+            )
+        }
+        Ok(data) => (data.clone(), parse_eth_revert(data.0.as_slice())),
+    };
+
+    (data.0, reason)
+}
+
+const ERROR_FUNCTION_SELECTOR: [u8; 4] = [0x08, 0xc3, 0x79, 0xa0]; // Error(string)
+const PANIC_FUNCTION_SELECTOR: [u8; 4] = [0x4e, 0x48, 0x7b, 0x71]; // Panic(uint256)
+
+// Lazily initialized HashMap for panic codes
+static PANIC_ERROR_CODES: Lazy<HashMap<u64, &'static str>> = Lazy::new(|| {
+    let mut m = HashMap::new();
+    m.insert(0x00, "Panic()");
+    m.insert(0x01, "Assert()");
+    m.insert(0x11, "ArithmeticOverflow()");
+    m.insert(0x12, "DivideByZero()");
+    m.insert(0x21, "InvalidEnumVariant()");
+    m.insert(0x22, "InvalidStorageArray()");
+    m.insert(0x31, "PopEmptyArray()");
+    m.insert(0x32, "ArrayIndexOutOfBounds()");
+    m.insert(0x41, "OutOfMemory()");
+    m.insert(0x51, "CalledUninitializedFunction()");
+    m
+});
+
+/// Parse an ABI encoded revert reason from a raw return value.
+///
+/// Handles both `Error(string)` and `Panic(uint256)` formats according to
+/// Solidity's revert conventions.
+///
+/// See https://docs.soliditylang.org/en/latest/control-structures.html#panic-via-assert-and-error-via-require
+fn parse_eth_revert(data: &[u8]) -> String {
+    // If it's not long enough to contain an ABI encoded response, return immediately.
+    if data.len() < 4 + 32 {
path: src/rpc/methods/eth/utils.rs
user: login: elmattic
body: nit: Would be better to use constants like `EVM_WORD_LENGTH`, `EVM_FUNC_SELECTOR_LENGTH`, etc.
created_at: 2025-04-02T13:24:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -97,6 +100,130 @@ where
     }
 }
 
+/// Extract and decode Ethereum revert reason from receipt return data
+pub fn decode_revert_reason(return_data: RawBytes) -> (Vec<u8>, String) {
+    let (data, reason) = match decode_payload(&return_data, CBOR) {
+        Err(e) => {
+            log::warn!("failed to unmarshal cbor bytes from message receipt return error: {e}");
+            (
+                EthBytes::default(),
+                String::from("ERROR: revert reason is not cbor encoded bytes"),
+            )
+        }
+        Ok(data) => (data.clone(), parse_eth_revert(data.0.as_slice())),
+    };
+
+    (data.0, reason)
+}
+
+const ERROR_FUNCTION_SELECTOR: [u8; 4] = [0x08, 0xc3, 0x79, 0xa0]; // Error(string)
+const PANIC_FUNCTION_SELECTOR: [u8; 4] = [0x4e, 0x48, 0x7b, 0x71]; // Panic(uint256)
+
+// Lazily initialized HashMap for panic codes
+static PANIC_ERROR_CODES: Lazy<HashMap<u64, &'static str>> = Lazy::new(|| {
+    let mut m = HashMap::new();
+    m.insert(0x00, "Panic()");
+    m.insert(0x01, "Assert()");
+    m.insert(0x11, "ArithmeticOverflow()");
+    m.insert(0x12, "DivideByZero()");
+    m.insert(0x21, "InvalidEnumVariant()");
+    m.insert(0x22, "InvalidStorageArray()");
+    m.insert(0x31, "PopEmptyArray()");
+    m.insert(0x32, "ArrayIndexOutOfBounds()");
+    m.insert(0x41, "OutOfMemory()");
+    m.insert(0x51, "CalledUninitializedFunction()");
+    m
+});
+
+/// Parse an ABI encoded revert reason from a raw return value.
+///
+/// Handles both `Error(string)` and `Panic(uint256)` formats according to
+/// Solidity's revert conventions.
+///
+/// See https://docs.soliditylang.org/en/latest/control-structures.html#panic-via-assert-and-error-via-require
+fn parse_eth_revert(data: &[u8]) -> String {
+    // If it's not long enough to contain an ABI encoded response, return immediately.
+    if data.len() < 4 + 32 {
+        return format!("0x{}", hex::encode(data));
+    }
+
+    // Extract function selector (first 4 bytes)
+    let selector = data.get(..4).expect("checked data length >= 4");
+
+    match selector {
+        selector if selector == PANIC_FUNCTION_SELECTOR.as_slice() => parse_panic_revert(data),
+        selector if selector == ERROR_FUNCTION_SELECTOR.as_slice() => parse_error_revert(data),
+        _ => format!("0x{}", hex::encode(data)),
+    }
+}
+
+fn parse_error_revert(data: &[u8]) -> String {
path: src/rpc/methods/eth/utils.rs
user: login: elmattic
body: Would be great to cover `parse_error_revert` and `parse_panic_revert` with some unit tests.
created_at: 2025-04-02T13:41:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -97,6 +100,130 @@ where
     }
 }
 
+/// Extract and decode Ethereum revert reason from receipt return data
+pub fn decode_revert_reason(return_data: RawBytes) -> (Vec<u8>, String) {
+    let (data, reason) = match decode_payload(&return_data, CBOR) {
+        Err(e) => {
+            log::warn!("failed to unmarshal cbor bytes from message receipt return error: {e}");
+            (
+                EthBytes::default(),
+                String::from("ERROR: revert reason is not cbor encoded bytes"),
+            )
+        }
+        Ok(data) => (data.clone(), parse_eth_revert(data.0.as_slice())),
+    };
+
+    (data.0, reason)
+}
+
+const ERROR_FUNCTION_SELECTOR: [u8; 4] = [0x08, 0xc3, 0x79, 0xa0]; // Error(string)
path: src/rpc/methods/eth/utils.rs
user: login: akaladarshi
body: We can obtain them by getting the first 4 bytes of the `keccak256` hash:
 - keccak256("Error(string)") = 08c379a0
 - keccak256("Panic(uint256)") = 4e487b71
created_at: 2025-04-03T08:37:41Z
author_association: COLLABORATOR
diff_hunk: @@ -97,6 +100,130 @@ where
     }
 }
 
+/// Extract and decode Ethereum revert reason from receipt return data
+pub fn decode_revert_reason(return_data: RawBytes) -> (Vec<u8>, String) {
+    let (data, reason) = match decode_payload(&return_data, CBOR) {
+        Err(e) => {
+            log::warn!("failed to unmarshal cbor bytes from message receipt return error: {e}");
+            (
+                EthBytes::default(),
+                String::from("ERROR: revert reason is not cbor encoded bytes"),
+            )
+        }
+        Ok(data) => (data.clone(), parse_eth_revert(data.0.as_slice())),
+    };
+
+    (data.0, reason)
+}
+
+const ERROR_FUNCTION_SELECTOR: [u8; 4] = [0x08, 0xc3, 0x79, 0xa0]; // Error(string)
+const PANIC_FUNCTION_SELECTOR: [u8; 4] = [0x4e, 0x48, 0x7b, 0x71]; // Panic(uint256)
+
+// Lazily initialized HashMap for panic codes
+static PANIC_ERROR_CODES: Lazy<HashMap<u64, &'static str>> = Lazy::new(|| {
+    let mut m = HashMap::new();
+    m.insert(0x00, "Panic()");
+    m.insert(0x01, "Assert()");
+    m.insert(0x11, "ArithmeticOverflow()");
+    m.insert(0x12, "DivideByZero()");
+    m.insert(0x21, "InvalidEnumVariant()");
+    m.insert(0x22, "InvalidStorageArray()");
+    m.insert(0x31, "PopEmptyArray()");
+    m.insert(0x32, "ArrayIndexOutOfBounds()");
+    m.insert(0x41, "OutOfMemory()");
+    m.insert(0x51, "CalledUninitializedFunction()");
+    m
+});
+
+/// Parse an ABI encoded revert reason from a raw return value.
+///
+/// Handles both `Error(string)` and `Panic(uint256)` formats according to
+/// Solidity's revert conventions.
+///
+/// See https://docs.soliditylang.org/en/latest/control-structures.html#panic-via-assert-and-error-via-require
+fn parse_eth_revert(data: &[u8]) -> String {
+    // If it's not long enough to contain an ABI encoded response, return immediately.
+    if data.len() < 4 + 32 {
+        return format!("0x{}", hex::encode(data));
+    }
+
+    // Extract function selector (first 4 bytes)
+    let selector = data.get(..4).expect("checked data length >= 4");
+
+    match selector {
+        selector if selector == PANIC_FUNCTION_SELECTOR.as_slice() => parse_panic_revert(data),
+        selector if selector == ERROR_FUNCTION_SELECTOR.as_slice() => parse_error_revert(data),
+        _ => format!("0x{}", hex::encode(data)),
+    }
+}
+
+fn parse_error_revert(data: &[u8]) -> String {
path: src/rpc/methods/eth/utils.rs
user: login: akaladarshi
body: I wanted to get the first round of review because this is a big change in `EthCall`, once we are satisfied I will be adding the tests.
created_at: 2025-04-03T08:40:54Z
author_association: COLLABORATOR
diff_hunk: @@ -97,6 +100,130 @@ where
     }
 }
 
+/// Extract and decode Ethereum revert reason from receipt return data
+pub fn decode_revert_reason(return_data: RawBytes) -> (Vec<u8>, String) {
+    let (data, reason) = match decode_payload(&return_data, CBOR) {
+        Err(e) => {
+            log::warn!("failed to unmarshal cbor bytes from message receipt return error: {e}");
+            (
+                EthBytes::default(),
+                String::from("ERROR: revert reason is not cbor encoded bytes"),
+            )
+        }
+        Ok(data) => (data.clone(), parse_eth_revert(data.0.as_slice())),
+    };
+
+    (data.0, reason)
+}
+
+const ERROR_FUNCTION_SELECTOR: [u8; 4] = [0x08, 0xc3, 0x79, 0xa0]; // Error(string)
path: src/rpc/methods/eth/utils.rs
user: login: elmattic
body: Good, maybe put in the comment i.e. `... 0xa0]; // keccak256("Error(string)")`
created_at: 2025-04-03T09:10:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -51,6 +51,7 @@ This is a mandatory release for mainnet node operators. It includes the NV25 _Te
 ### Changed
 
 - [#5452](https://github.com/ChainSafe/forest/pull/5452) Speed up void database migration.
+- [#5467](https://github.com/ChainSafe/forest/pull/5467) Improve error message for EthEstimateGas and EthCall
path: CHANGELOG.md
user: login: LesnyRumcajs
body: ```suggestion
- [#5467](https://github.com/ChainSafe/forest/pull/5467) Improve error message for `Filecoin.EthEstimateGas` and `Filecoin.EthCall`.
```
created_at: 2025-04-03T09:45:07Z
author_association: MEMBER
path: src/rpc/methods/eth/utils.rs
user: login: LesnyRumcajs
body: Let's have complete test coverage on those new methods. They are self-contained so it should be straightforward to do so.
created_at: 2025-04-03T09:50:10Z
author_association: MEMBER
path: src/rpc/methods/eth/utils.rs
user: login: elmattic
body: It would be really helpful to have Solidity programs (in `src/tool/subcommands/api_cmd/contracts`) that produce errors, so we can call them and observe similar errors to Lotus. What do you think?
created_at: 2025-04-03T11:28:18Z
author_association: CONTRIBUTOR
path: src/rpc/methods/eth/utils.rs
user: login: LesnyRumcajs
body: Excellent idea!
created_at: 2025-04-03T11:37:15Z
author_association: MEMBER
diff_hunk: @@ -15,6 +16,13 @@ pub struct ServerError {
     inner: ErrorObjectOwned,
 }
 
+/// Custom error codes for the Forest node.
+pub enum ForestError {
+    /// This error indicates that the execution reverted while executing the message.
+    /// Code is taken from https://github.com/filecoin-project/lotus/blob/master/api/api_errors.go#L27
path: src/rpc/error.rs
user: login: akaladarshi
body: Done
created_at: 2025-04-07T10:45:35Z
author_association: COLLABORATOR
diff_hunk: @@ -72,9 +74,10 @@ use rayon::prelude::ParallelBridge;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use std::ops::RangeInclusive;
+use std::time::Duration;
 use std::{num::NonZeroUsize, sync::Arc};
 use tokio::sync::{broadcast::error::RecvError, Mutex as TokioMutex, RwLock};
-use tracing::{error, info, instrument, trace, warn};
+use tracing::{error, info, instrument, log, trace, warn};
path: src/state_manager/mod.rs
user: login: akaladarshi
body: Done
created_at: 2025-04-07T10:45:39Z
author_association: COLLABORATOR
diff_hunk: @@ -97,6 +100,130 @@ where
     }
 }
 
+/// Extract and decode Ethereum revert reason from receipt return data
+pub fn decode_revert_reason(return_data: RawBytes) -> (Vec<u8>, String) {
+    let (data, reason) = match decode_payload(&return_data, CBOR) {
+        Err(e) => {
+            log::warn!("failed to unmarshal cbor bytes from message receipt return error: {e}");
+            (
+                EthBytes::default(),
+                String::from("ERROR: revert reason is not cbor encoded bytes"),
+            )
+        }
+        Ok(data) => (data.clone(), parse_eth_revert(data.0.as_slice())),
+    };
+
+    (data.0, reason)
+}
+
+const ERROR_FUNCTION_SELECTOR: [u8; 4] = [0x08, 0xc3, 0x79, 0xa0]; // Error(string)
path: src/rpc/methods/eth/utils.rs
user: login: akaladarshi
body: Done
created_at: 2025-04-07T10:45:43Z
author_association: COLLABORATOR
diff_hunk: @@ -97,6 +100,130 @@ where
     }
 }
 
+/// Extract and decode Ethereum revert reason from receipt return data
+pub fn decode_revert_reason(return_data: RawBytes) -> (Vec<u8>, String) {
+    let (data, reason) = match decode_payload(&return_data, CBOR) {
+        Err(e) => {
+            log::warn!("failed to unmarshal cbor bytes from message receipt return error: {e}");
+            (
+                EthBytes::default(),
+                String::from("ERROR: revert reason is not cbor encoded bytes"),
+            )
+        }
+        Ok(data) => (data.clone(), parse_eth_revert(data.0.as_slice())),
+    };
+
+    (data.0, reason)
+}
+
+const ERROR_FUNCTION_SELECTOR: [u8; 4] = [0x08, 0xc3, 0x79, 0xa0]; // Error(string)
+const PANIC_FUNCTION_SELECTOR: [u8; 4] = [0x4e, 0x48, 0x7b, 0x71]; // Panic(uint256)
+
+// Lazily initialized HashMap for panic codes
+static PANIC_ERROR_CODES: Lazy<HashMap<u64, &'static str>> = Lazy::new(|| {
+    let mut m = HashMap::new();
+    m.insert(0x00, "Panic()");
+    m.insert(0x01, "Assert()");
+    m.insert(0x11, "ArithmeticOverflow()");
+    m.insert(0x12, "DivideByZero()");
+    m.insert(0x21, "InvalidEnumVariant()");
+    m.insert(0x22, "InvalidStorageArray()");
+    m.insert(0x31, "PopEmptyArray()");
+    m.insert(0x32, "ArrayIndexOutOfBounds()");
+    m.insert(0x41, "OutOfMemory()");
+    m.insert(0x51, "CalledUninitializedFunction()");
+    m
+});
+
+/// Parse an ABI encoded revert reason from a raw return value.
+///
+/// Handles both `Error(string)` and `Panic(uint256)` formats according to
+/// Solidity's revert conventions.
+///
+/// See https://docs.soliditylang.org/en/latest/control-structures.html#panic-via-assert-and-error-via-require
+fn parse_eth_revert(data: &[u8]) -> String {
+    // If it's not long enough to contain an ABI encoded response, return immediately.
+    if data.len() < 4 + 32 {
path: src/rpc/methods/eth/utils.rs
user: login: akaladarshi
body: Done.
created_at: 2025-04-07T10:45:47Z
author_association: COLLABORATOR
diff_hunk: @@ -97,6 +100,130 @@ where
     }
 }
 
+/// Extract and decode Ethereum revert reason from receipt return data
+pub fn decode_revert_reason(return_data: RawBytes) -> (Vec<u8>, String) {
+    let (data, reason) = match decode_payload(&return_data, CBOR) {
+        Err(e) => {
+            log::warn!("failed to unmarshal cbor bytes from message receipt return error: {e}");
+            (
+                EthBytes::default(),
+                String::from("ERROR: revert reason is not cbor encoded bytes"),
+            )
+        }
+        Ok(data) => (data.clone(), parse_eth_revert(data.0.as_slice())),
+    };
+
+    (data.0, reason)
+}
+
+const ERROR_FUNCTION_SELECTOR: [u8; 4] = [0x08, 0xc3, 0x79, 0xa0]; // Error(string)
+const PANIC_FUNCTION_SELECTOR: [u8; 4] = [0x4e, 0x48, 0x7b, 0x71]; // Panic(uint256)
+
+// Lazily initialized HashMap for panic codes
+static PANIC_ERROR_CODES: Lazy<HashMap<u64, &'static str>> = Lazy::new(|| {
+    let mut m = HashMap::new();
+    m.insert(0x00, "Panic()");
+    m.insert(0x01, "Assert()");
+    m.insert(0x11, "ArithmeticOverflow()");
+    m.insert(0x12, "DivideByZero()");
+    m.insert(0x21, "InvalidEnumVariant()");
+    m.insert(0x22, "InvalidStorageArray()");
+    m.insert(0x31, "PopEmptyArray()");
+    m.insert(0x32, "ArrayIndexOutOfBounds()");
+    m.insert(0x41, "OutOfMemory()");
+    m.insert(0x51, "CalledUninitializedFunction()");
+    m
+});
+
+/// Parse an ABI encoded revert reason from a raw return value.
+///
+/// Handles both `Error(string)` and `Panic(uint256)` formats according to
+/// Solidity's revert conventions.
+///
+/// See https://docs.soliditylang.org/en/latest/control-structures.html#panic-via-assert-and-error-via-require
+fn parse_eth_revert(data: &[u8]) -> String {
+    // If it's not long enough to contain an ABI encoded response, return immediately.
+    if data.len() < 4 + 32 {
+        return format!("0x{}", hex::encode(data));
+    }
+
+    // Extract function selector (first 4 bytes)
+    let selector = data.get(..4).expect("checked data length >= 4");
+
+    match selector {
+        selector if selector == PANIC_FUNCTION_SELECTOR.as_slice() => parse_panic_revert(data),
+        selector if selector == ERROR_FUNCTION_SELECTOR.as_slice() => parse_error_revert(data),
+        _ => format!("0x{}", hex::encode(data)),
+    }
+}
+
+fn parse_error_revert(data: &[u8]) -> String {
path: src/rpc/methods/eth/utils.rs
user: login: akaladarshi
body: Done
created_at: 2025-04-07T10:46:07Z
author_association: COLLABORATOR
diff_hunk: @@ -51,6 +51,7 @@ This is a mandatory release for mainnet node operators. It includes the NV25 _Te
 ### Changed
 
 - [#5452](https://github.com/ChainSafe/forest/pull/5452) Speed up void database migration.
+- [#5467](https://github.com/ChainSafe/forest/pull/5467) Improve error message for EthEstimateGas and EthCall
path: CHANGELOG.md
user: login: akaladarshi
body: Done
created_at: 2025-04-07T10:46:12Z
author_association: COLLABORATOR
path: src/rpc/methods/eth/utils.rs
user: login: akaladarshi
body: Should we create a separate issue for this?
created_at: 2025-04-07T10:46:41Z
author_association: COLLABORATOR
path: src/rpc/methods/eth/utils.rs
user: login: LesnyRumcajs
body: Yeah, it's not something to tackle in this PR.
created_at: 2025-04-07T14:22:38Z
author_association: MEMBER
diff_hunk: @@ -184,6 +189,30 @@ pub struct EthUint64(
 
 lotus_json_with_self!(EthUint64);
 
+impl EthUint64 {
+    pub fn from_bytes(data: &[u8]) -> Result<Self> {
+        if data.len() != EVM_WORD_LENGTH {
+            bail!("eth int must be 32 bytes");
+        }
+
+        // big indian format stores u64 in the last 8 bytes,
path: src/rpc/methods/eth.rs
user: login: LesnyRumcajs
body: ```suggestion
        // big endian format stores u64 in the last 8 bytes,
```
:)
created_at: 2025-04-09T16:09:40Z
author_association: MEMBER
diff_hunk: @@ -184,6 +189,30 @@ pub struct EthUint64(
 
 lotus_json_with_self!(EthUint64);
 
+impl EthUint64 {
+    pub fn from_bytes(data: &[u8]) -> Result<Self> {
+        if data.len() != EVM_WORD_LENGTH {
+            bail!("eth int must be 32 bytes");
path: src/rpc/methods/eth.rs
user: login: LesnyRumcajs
body: ```suggestion
            bail!("eth int must be {EVM_WORD_LENGTH} bytes");
```
created_at: 2025-04-09T16:10:20Z
author_association: MEMBER
diff_hunk: @@ -184,6 +189,30 @@ pub struct EthUint64(
 
 lotus_json_with_self!(EthUint64);
 
+impl EthUint64 {
+    pub fn from_bytes(data: &[u8]) -> Result<Self> {
path: src/rpc/methods/eth.rs
user: login: LesnyRumcajs
body: Do we have coverage for this method?
created_at: 2025-04-09T16:11:49Z
author_association: MEMBER
diff_hunk: @@ -259,11 +258,17 @@ impl GasEstimateGasLimit {
     where
         DB: Blockstore + Send + Sync + 'static,
     {
-        let (res, ..) = Self::estimate_call_with_gas(data, msg, tsk, VMTrace::NotTraced).await?;
+        let (res, ..) = Self::estimate_call_with_gas(data, msg, tsk, VMTrace::NotTraced)
+            .await
+            .map_err(|e| anyhow::anyhow!("gas estimation failed: {e}"))?;
         match res.msg_rct {
             Some(rct) => {
-                if rct.exit_code().value() != 0 {
-                    return Ok(-1);
+                if !rct.exit_code().is_success() {
path: src/rpc/methods/gas.rs
user: login: LesnyRumcajs
body: we could use `ensure!`
created_at: 2025-04-09T16:14:48Z
author_association: MEMBER
diff_hunk: @@ -18,7 +18,9 @@ filecoin_chaintipsetweight_1736937942819105.rpcsnap.json.zst
 filecoin_ethaccounts_1737446676689764.rpcsnap.json.zst
 filecoin_ethaddresstofilecoinaddress_1740132538465536.rpcsnap.json.zst
 filecoin_ethblocknumber_1741272348346171.rpcsnap.json.zst
-filecoin_ethcall_1737446676693468.rpcsnap.json.zst
+filecoin_ethcall_1744204533050503.rpcsnap.json.zst
+filecoin_ethcall_1744204533058637.rpcsnap.json.zst
+filecoin_ethcall_1744204533066529.rpcsnap.json.zst
path: src/tool/subcommands/api_cmd/test_snapshots.txt
user: login: LesnyRumcajs
body: What are the differences between each of those snapshots?
created_at: 2025-04-09T16:15:54Z
author_association: MEMBER
diff_hunk: @@ -649,6 +652,49 @@ where
         self.call_raw(message, chain_rand, &ts)
     }
 
+    pub async fn apply_on_state_with_gas(
+        self: &Arc<Self>,
+        tipset: Option<Arc<Tipset>>,
+        msg: Message,
+    ) -> anyhow::Result<ApiInvocResult> {
+        let ts = tipset.unwrap_or_else(|| self.cs.heaviest_tipset());
+
+        // Handle state forks
+        // TODO(elmattic): https://github.com/ChainSafe/forest/issues/3733
+
+        let from_a = self.resolve_to_key_addr(&msg.from, &ts).await?;
+
+        // Pretend that the message is signed. This has an influence on the gas
+        // cost. We obviously can't generate a valid signature. Instead, we just
+        // fill the signature with zeros. The validity is not checked.
+        let mut chain_msg = match from_a.protocol() {
+            Protocol::Secp256k1 => ChainMessage::Signed(SignedMessage::new_unchecked(
+                msg.clone(),
+                Signature::new_secp256k1(vec![0; SECP_SIG_LEN]),
+            )),
+            Protocol::Delegated => ChainMessage::Signed(SignedMessage::new_unchecked(
+                msg.clone(),
+                // In Lotus, delegated signatures have the same length as SECP256k1.
+                // This may or may not change in the future.
+                Signature::new(SignatureType::Delegated, vec![0; SECP_SIG_LEN]),
+            )),
+            _ => ChainMessage::Unsigned(msg.clone()),
+        };
+
+        let (_invoc_res, apply_ret, duration) = self
+            .call_with_gas(&mut chain_msg, &[], Some(ts), VMTrace::Traced)
+            .await?;
+        Ok(ApiInvocResult {
+            msg: msg.clone(),
path: src/state_manager/mod.rs
user: login: LesnyRumcajs
body: Is there a way to limit the number of clones for `msg` in this method?
created_at: 2025-04-09T16:19:24Z
author_association: MEMBER
diff_hunk: @@ -18,7 +18,9 @@ filecoin_chaintipsetweight_1736937942819105.rpcsnap.json.zst
 filecoin_ethaccounts_1737446676689764.rpcsnap.json.zst
 filecoin_ethaddresstofilecoinaddress_1740132538465536.rpcsnap.json.zst
 filecoin_ethblocknumber_1741272348346171.rpcsnap.json.zst
-filecoin_ethcall_1737446676693468.rpcsnap.json.zst
+filecoin_ethcall_1744204533050503.rpcsnap.json.zst
+filecoin_ethcall_1744204533058637.rpcsnap.json.zst
+filecoin_ethcall_1744204533066529.rpcsnap.json.zst
path: src/tool/subcommands/api_cmd/test_snapshots.txt
user: login: akaladarshi
body: Each one contain different `eth_calls`:
 1. No data and `to` field set to `0x0000000000000000000000000000000000000000`
 2. Contains a token transfer call to an `0x0c1d86d34e469770339b53613f3a2343accd62cb`
 3. Contract deployment call
 
 Generated from these [cases](https://github.com/ChainSafe/forest/blob/56da0ea7bb0de5a26300154113fcda71501c50b5/src/tool/subcommands/api_cmd/api_compare_tests.rs#L1208).
created_at: 2025-04-09T17:38:33Z
author_association: COLLABORATOR
diff_hunk: @@ -18,7 +18,9 @@ filecoin_chaintipsetweight_1736937942819105.rpcsnap.json.zst
 filecoin_ethaccounts_1737446676689764.rpcsnap.json.zst
 filecoin_ethaddresstofilecoinaddress_1740132538465536.rpcsnap.json.zst
 filecoin_ethblocknumber_1741272348346171.rpcsnap.json.zst
-filecoin_ethcall_1737446676693468.rpcsnap.json.zst
+filecoin_ethcall_1744204533050503.rpcsnap.json.zst
+filecoin_ethcall_1744204533058637.rpcsnap.json.zst
+filecoin_ethcall_1744204533066529.rpcsnap.json.zst
path: src/tool/subcommands/api_cmd/test_snapshots.txt
user: login: elmattic
body: As @akaladarshi mentioned, it's generated from the same API compare calls as before, just re-exported to capture the new usage of the chainstore.
created_at: 2025-04-09T17:54:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -649,6 +652,49 @@ where
         self.call_raw(message, chain_rand, &ts)
     }
 
+    pub async fn apply_on_state_with_gas(
+        self: &Arc<Self>,
+        tipset: Option<Arc<Tipset>>,
+        msg: Message,
+    ) -> anyhow::Result<ApiInvocResult> {
+        let ts = tipset.unwrap_or_else(|| self.cs.heaviest_tipset());
+
+        // Handle state forks
+        // TODO(elmattic): https://github.com/ChainSafe/forest/issues/3733
+
+        let from_a = self.resolve_to_key_addr(&msg.from, &ts).await?;
+
+        // Pretend that the message is signed. This has an influence on the gas
+        // cost. We obviously can't generate a valid signature. Instead, we just
+        // fill the signature with zeros. The validity is not checked.
+        let mut chain_msg = match from_a.protocol() {
+            Protocol::Secp256k1 => ChainMessage::Signed(SignedMessage::new_unchecked(
+                msg.clone(),
+                Signature::new_secp256k1(vec![0; SECP_SIG_LEN]),
+            )),
+            Protocol::Delegated => ChainMessage::Signed(SignedMessage::new_unchecked(
+                msg.clone(),
+                // In Lotus, delegated signatures have the same length as SECP256k1.
+                // This may or may not change in the future.
+                Signature::new(SignatureType::Delegated, vec![0; SECP_SIG_LEN]),
+            )),
+            _ => ChainMessage::Unsigned(msg.clone()),
+        };
+
+        let (_invoc_res, apply_ret, duration) = self
+            .call_with_gas(&mut chain_msg, &[], Some(ts), VMTrace::Traced)
+            .await?;
+        Ok(ApiInvocResult {
+            msg: msg.clone(),
path: src/state_manager/mod.rs
user: login: akaladarshi
body: Done.
created_at: 2025-04-09T19:05:07Z
author_association: COLLABORATOR
diff_hunk: @@ -259,11 +258,17 @@ impl GasEstimateGasLimit {
     where
         DB: Blockstore + Send + Sync + 'static,
     {
-        let (res, ..) = Self::estimate_call_with_gas(data, msg, tsk, VMTrace::NotTraced).await?;
+        let (res, ..) = Self::estimate_call_with_gas(data, msg, tsk, VMTrace::NotTraced)
+            .await
+            .map_err(|e| anyhow::anyhow!("gas estimation failed: {e}"))?;
         match res.msg_rct {
             Some(rct) => {
-                if rct.exit_code().value() != 0 {
-                    return Ok(-1);
+                if !rct.exit_code().is_success() {
path: src/rpc/methods/gas.rs
user: login: akaladarshi
body: Done.
created_at: 2025-04-09T19:05:13Z
author_association: COLLABORATOR
diff_hunk: @@ -184,6 +189,30 @@ pub struct EthUint64(
 
 lotus_json_with_self!(EthUint64);
 
+impl EthUint64 {
+    pub fn from_bytes(data: &[u8]) -> Result<Self> {
path: src/rpc/methods/eth.rs
user: login: akaladarshi
body: Added more tests for this.
created_at: 2025-04-09T19:05:26Z
author_association: COLLABORATOR
diff_hunk: @@ -184,6 +189,30 @@ pub struct EthUint64(
 
 lotus_json_with_self!(EthUint64);
 
+impl EthUint64 {
+    pub fn from_bytes(data: &[u8]) -> Result<Self> {
+        if data.len() != EVM_WORD_LENGTH {
+            bail!("eth int must be 32 bytes");
path: src/rpc/methods/eth.rs
user: login: akaladarshi
body: Done.
created_at: 2025-04-09T19:05:31Z
author_association: COLLABORATOR
diff_hunk: @@ -184,6 +189,30 @@ pub struct EthUint64(
 
 lotus_json_with_self!(EthUint64);
 
+impl EthUint64 {
+    pub fn from_bytes(data: &[u8]) -> Result<Self> {
+        if data.len() != EVM_WORD_LENGTH {
+            bail!("eth int must be 32 bytes");
+        }
+
+        // big indian format stores u64 in the last 8 bytes,
path: src/rpc/methods/eth.rs
user: login: akaladarshi
body: Done.
created_at: 2025-04-09T19:05:37Z
author_association: COLLABORATOR
diff_hunk: @@ -18,7 +18,9 @@ filecoin_chaintipsetweight_1736937942819105.rpcsnap.json.zst
 filecoin_ethaccounts_1737446676689764.rpcsnap.json.zst
 filecoin_ethaddresstofilecoinaddress_1740132538465536.rpcsnap.json.zst
 filecoin_ethblocknumber_1741272348346171.rpcsnap.json.zst
-filecoin_ethcall_1737446676693468.rpcsnap.json.zst
+filecoin_ethcall_1744204533050503.rpcsnap.json.zst
+filecoin_ethcall_1744204533058637.rpcsnap.json.zst
+filecoin_ethcall_1744204533066529.rpcsnap.json.zst
path: src/tool/subcommands/api_cmd/test_snapshots.txt
user: login: LesnyRumcajs
body: Great, thanks. It'd be great to have some comments in those lists to explain what cases the snapshots cover. Obviously not something to do in this PR.
created_at: 2025-04-10T08:29:43Z
author_association: MEMBER

PR comments:
user: login: akaladarshi
created_at: 2025-03-26T18:33:52Z
body: @LesnyRumcajs I had a few questions regarding this issue:

- Are we trying to achieve exact same json rpc response as of lotus? asking because `Lotus` uses their own `go-jsonrpc` library to [register](https://github.com/filecoin-project/lotus/blob/3d0018c2f023c78bafc7493a6a6138e940037da0/api/api_errors.go#L66) errors and parse them accordingly, that's why they have fine grain control over error codes and how they print, so my question

- Right now the code is very fragmented, we have different functions `call_with_gas`, `call_raw`, `estimate_call_with_gas`, ultimately all of them are trying to execute the `message` on top of the VM but with different configuration.
  - I was thinking of refactoring them into proper structure but that will require larger restructuring, should we tackle it separatley?

- ~~To return proper errors we need little bit more understanding how to implement it, I have added one [here](https://github.com/akaladarshi/forest/blob/2bf22acd26ae1936f1919c8cb8db0433fb81e250/src/rpc/methods/eth/errors.rs#L8), let me know what do you think~~
author_association: COLLABORATOR
user: login: LesnyRumcajs
created_at: 2025-03-27T16:41:16Z
body: > Are we trying to achieve exact same json rpc response as of lotus? asking because Lotus uses their own go-jsonrpc library to [register](https://github.com/filecoin-project/lotus/blob/3d0018c2f023c78bafc7493a6a6138e940037da0/api/api_errors.go#L66) errors and parse them accordingly, that's why they have fine grain control over error codes and how they print, so my question

They don't have to be byte-to-byte identical, but they should be as expressive and helpful.

> Right now the code is very fragmented, we have different functions call_with_gas, call_raw, estimate_call_with_gas, ultimately all of them are trying to execute the message on top of the VM but with different configuration.

I'd love it if we have some refactoring around this. That said, it should be a separate PR.
author_association: MEMBER
user: login: LesnyRumcajs
created_at: 2025-03-31T07:17:25Z
body: @akaladarshi It seems some RPC regression tests are failing for `eth_call`. 

```
src/tool/subcommands/api_cmd/test_snapshot.rs:105:5:
assertion `left == right` failed
  left: Ok(String("0x0000000000000000000000000000000000000000000000000000000000002710"))
 right: Err("JSON-RPC error:\n\tcode: -32603\n\tmessage: failed to apply on state with gas: Could not load tipset state\n")
```

You can read more on those [here](https://docs.forest.chainsafe.io/developers/guides/rpc_test_snapshot). I'm happy to assist in adding a new snapshot, but we need to confirm that this result (success, whereas previously it was a failure I guess... or the other way around) is correct. Best to use https://github.com/ChainSafe/forest/blob/main/scripts/tests/api_compare/setup.sh for this (or do it locally, without docker compose) to check the results from Forest and Lotus are more or less the same.
author_association: MEMBER
user: login: akaladarshi
created_at: 2025-03-31T08:12:25Z
body: > without

@LesnyRumcajs Can we retry the failed CI job once more, because the error is `Could not load tipset state` (could be something db related), also we are running the `rpc_regression_tests` in make test (Unit tests/ tests) as well and it's passing in the CI.
author_association: COLLABORATOR
user: login: LesnyRumcajs
created_at: 2025-03-31T09:11:56Z
body: > > without
> 
> @LesnyRumcajs Can we retry the failed CI job once more, because the error is `Could not load tipset state` (could be something db related), also we are running the `rpc_regression_tests` in make test (Unit tests/ tests) as well and it's passing in the CI.

We are not really running these in the debug mode.
https://github.com/ChainSafe/forest/blob/6cffa72ed4670c1f5aa568acd4764d756053655e/src/tool/subcommands/api_cmd/test_snapshot.rs#L187-L190

I re-triggered but I expect them to fail with the same error.
author_association: MEMBER
user: login: akaladarshi
created_at: 2025-03-31T16:43:32Z
body: While investigating the regression test issue, I came across interesting behaviour of current (main branch)`EthCall` implementation.

Current Forest `EthCall` Implementation is completely different from what we have in lotus.

Lotus: 
   - [EthCall](https://github.com/filecoin-project/lotus/blob/de21d03a6954e97693a24c29aca7b126bd97c666/node/impl/eth/gas.go#L239) gets the tipset based on provided block param and calls `applyMessage`
   - [applyMessage](https://github.com/filecoin-project/lotus/blob/de21d03a6954e97693a24c29aca7b126bd97c666/node/impl/eth/gas.go#L264) checks for the expensive fork and fetches the state root from the provided tipset and call `ApplyOnStateWithGas`:
     - It fetches the state & receipt CID for a given tipset by walking the chain instead of executing it (basically return the root if it exist in the store otherwise return error)
  - [ApplyOnStateWithGas](https://github.com/filecoin-project/lotus/blob/de21d03a6954e97693a24c29aca7b126bd97c666/chain/stmgr/call.go#L63): 
     - Use the provide `state root` (from above step)
     -  `execNoMessages` which means don't apply any prior messages, only apply current message
     - `checkGas` true which means convert message into it's singed message (ChainMessage)
     - Execute the signed message (ChainMessage) on top of VM by calling `ApplyMessage`
   
     
Forest:

  - [EthCall](https://github.com/ChainSafe/forest/blob/746d129c99590daf30da65c96eebfe8e65b73022/src/rpc/methods/eth.rs#L2364) gets the tipset from the provided block params and calls the `state_manager.call`
     - state manager [call](https://github.com/ChainSafe/forest/blob/746d129c99590daf30da65c96eebfe8e65b73022/src/state_manager/mod.rs#L609) fetches the heaviest tipset if not provided and calls the `call_raw`
     - [call_raw](https://github.com/ChainSafe/forest/blob/746d129c99590daf30da65c96eebfe8e65b73022/src/state_manager/mod.rs#L528):
         -  Gets the `parent state root` cid form the provided tipset
         - Execute all the prior message sent by the current message sender on top of the VM
         - calculate new sate after above step and set the new sequece
         - Execute the current message on top of VM by calling [apply_implicit_message](https://github.com/ChainSafe/forest/blob/746d129c99590daf30da65c96eebfe8e65b73022/src/interpreter/vm.rs#L443) (it applies different kind of messages as compared to normal [apply_message](https://github.com/ChainSafe/forest/blob/746d129c99590daf30da65c96eebfe8e65b73022/src/interpreter/vm.rs#L466))
         

@LesnyRumcajs If you get time can you please take a look at this difference, because based on this we shouldn't be asking why regression test is failing now, instead we should be asking why it was passing earlier.

cc @elmattic  if you can share some thoughts on this it would be helpful, to get better understanding.

Edit: we can fix the regression test if in this PR instead of getting the state root cid like this in [call_with_gas](https://github.com/ChainSafe/forest/blob/746d129c99590daf30da65c96eebfe8e65b73022/src/state_manager/mod.rs#L629) for EthCall:

>  let (st, _) = self .tipset_state(&ts).await.map_err(|_| Error::Other("Could not load tipset state".to_string()))?;

We fetch the parent state root (not sure why):

>  let st = ts.parent_state()
author_association: COLLABORATOR
user: login: LesnyRumcajs
created_at: 2025-04-07T14:21:48Z
body: The most important rule in Forest - no green checkmark in CI :heavy_check_mark:  - no review! :x: :) 
author_association: MEMBER
user: login: elmattic
created_at: 2025-04-11T08:45:41Z
body: Hopefully Calibnet RPC checks will pass. 🐉 
author_association: CONTRIBUTOR