diff --git a/CHANGELOG.md b/CHANGELOG.md index 687fb85761c..faf394fdc35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,8 @@ - [#7700](https://github.com/ChainSafe/forest/issues/7700): Fixed `eth_call` and `trace_call` ignoring the `gas` field of the call. The given gas limit is now honored (capped at the block gas limit), so calls with insufficient gas fail as they would on-chain. +- [#7702](https://github.com/ChainSafe/forest/issues/7702): Fixed `eth_estimateGas` ignoring the `gas` field of the call. The given gas limit (capped at the block gas limit) is now the upper bound of the estimate; a call that does not fit fails with `out of gas: gas required exceeds: ` (code `-32003`), or with `gas required exceeds allowance ()` (code `-32000`) when `gas` is below the message inclusion cost. Omitting `gas` keeps the previous estimate. + ## Forest v0.37.0 "Sharad Sampat" Mandatory release for calibnet node operators. It includes support for the NV29 _Solstice_ network upgrade for calibnet, which is set to activate at epoch `4109133` (2026-09-28T12:59:30Z). There are also additional (breaking) changes and improvements; see below. diff --git a/src/dev/subcommands/devnet_cmd/eth_gas.rs b/src/dev/subcommands/devnet_cmd/eth_gas.rs index 7884204af51..e405bd58ab3 100644 --- a/src/dev/subcommands/devnet_cmd/eth_gas.rs +++ b/src/dev/subcommands/devnet_cmd/eth_gas.rs @@ -1,7 +1,8 @@ // Copyright 2019-2026 ChainSafe Systems // SPDX-License-Identifier: Apache-2.0, MIT -//! `eth_estimateGas` parity tests against the Lotus node on the docker devnet. +//! `eth_estimateGas` parity tests against the Lotus node on the docker devnet, plus Forest-only +//! tests of a caller-supplied `gas` cap. //! //! [EIP-150] caps a `CALL` at 63/64 of remaining gas, so a nested call chain needs a far higher //! gas *limit* than the gas it *uses*. Estimating from gas used alone therefore under-shoots, @@ -11,16 +12,18 @@ use crate::dev::subcommands::tests_cmd::helpers::*; use crate::rpc::Client; -use crate::rpc::eth::errors::EXECUTION_REVERTED_CODE; +use crate::rpc::eth::errors::{ + EXECUTION_REVERTED_CODE, INVALID_INPUT_CODE, TRANSACTION_REJECTED_CODE, +}; use crate::rpc::eth::{ - BlockNumberOrHash, Predefined, + BlockNumberOrHash, EthUint64, Predefined, types::{EthAddress, EthBytes, EthCallMessage}, }; use crate::rpc::prelude::*; use crate::shim::address::Address; +use crate::shim::econ::BLOCK_GAS_LIMIT; use crate::utils::encoding::keccak_256; use anyhow::{Context as _, ensure}; -use jsonrpsee::core::ClientError; use libtest_mimic::{Arguments, Failed, Trial}; use std::str::FromStr as _; use tokio::sync::OnceCell; @@ -29,14 +32,11 @@ use tokio::sync::OnceCell; /// Regenerate with `contracts/compile.sh` after editing the source. const NESTED_GAS_HEX: &str = include_str!("contracts/nested_gas/nested_gas.hex"); const RECURSE_SIGNATURE: &str = "recurse(uint256)"; -/// Reverts explicitly unless given a large gas limit, so estimating it fails for a reason no -/// amount of extra gas can be shown to fix. +/// Reverts explicitly unless given a large gas limit, so estimating it without a `gas` cap fails +/// for a reason no amount of extra gas can be shown to fix. const REQUIRES_HIGH_GAS_SIGNATURE: &str = "requiresHighGasLimit()"; /// The `require` string in [`REQUIRES_HIGH_GAS_SIGNATURE`]. const REVERT_REASON: &str = "gas limit too low"; -/// Both implementations prefix this branch's error with it. Asserting on it pins *which* rejection -/// happened: a message that failed earlier, during plain gas estimation, would never carry it. -const GAS_SEARCH_FAILURE: &str = "gas search failed"; /// Shallow enough that the 63/64 penalty stays inside any estimator's safety margin, so both /// nodes must agree. Guards against a failure that is really "the two disagree about gas". @@ -47,7 +47,7 @@ const NESTED_DEPTH: u64 = 100; /// afford it makes the estimate saturate at the block gas limit instead of converging. const SENDER_FUND_AMT: &str = "10 FIL"; -/// `eth_estimateGas` parity tests +/// `eth_estimateGas` parity and gas cap tests #[derive(Debug, clap::Args)] pub struct EthGasTestCommand {} @@ -81,6 +81,13 @@ fn tests() -> Vec { trial("eth_estimate_gas_reports_a_non_gas_failure", || { block_on(estimate_reports_a_non_gas_failure()) }), + trial("eth_estimate_gas_honors_gas_cap", || { + block_on(estimate_honors_gas_cap()) + }), + trial( + "eth_estimate_gas_under_cap_searches_past_gas_dependent_revert", + || block_on(estimate_under_cap_searches_past_gas_dependent_revert()), + ), ] } @@ -140,25 +147,56 @@ async fn sender() -> anyhow::Result<&'static str> { .as_str()) } -async fn estimate( - client: &Client, - calldata: Vec, - block: BlockNumberOrHash, -) -> anyhow::Result { +/// A call from the funded sender to the deployed contract. +async fn call_message(calldata: Vec, gas: Option) -> anyhow::Result { let (from, to) = tokio::try_join!(sender(), contract())?; let from = Address::from_str(from).context("parsing the sender address")?; - let msg = EthCallMessage { + Ok(EthCallMessage { from: Some(EthAddress::from_filecoin_address(&from)?), to: Some(*to), data: Some(EthBytes(calldata)), + gas: gas.map(EthUint64), ..Default::default() - }; + }) +} + +async fn estimate( + client: &Client, + calldata: Vec, + block: BlockNumberOrHash, + gas: Option, +) -> anyhow::Result { + let msg = call_message(calldata, gas).await?; let gas = client .call(EthEstimateGas::request((msg, Some(block)))?) .await?; Ok(gas.0) } +/// Estimating under `cap` must fail with exactly this JSON-RPC error. +async fn expect_estimate_error( + client: &Client, + calldata: Vec, + block: BlockNumberOrHash, + cap: u64, + code: i32, + expected: &str, +) -> anyhow::Result<()> { + let err = match estimate(client, calldata, block, Some(cap)).await { + Ok(gas) => anyhow::bail!("returned {gas} for a call that does not fit in a cap of {cap}"), + Err(e) => e, + }; + let obj = rpc_call_err(&err) + .with_context(|| format!("expected a JSON-RPC error for a cap of {cap}: {err:?}"))?; + ensure!( + obj.code() == code && obj.message() == expected, + "expected code {code} `{expected}` for a cap of {cap}, got code {} `{}`", + obj.code(), + obj.message() + ); + Ok(()) +} + /// A height both nodes have already executed. `Latest` is resolved per node, so at an epoch /// boundary or under slight sync skew the two could pick different tipsets; pinning both to the /// lower of their heads makes the cross-node comparison deterministic. @@ -200,6 +238,7 @@ async fn estimate_agrees(depth: u64) -> anyhow::Result<()> { &forest_c, recurse_calldata(depth), BlockNumberOrHash::from_block_number(block), + None, ) .await .context("EthEstimateGas on forest") @@ -209,6 +248,7 @@ async fn estimate_agrees(depth: u64) -> anyhow::Result<()> { &lotus_c, recurse_calldata(depth), BlockNumberOrHash::from_block_number(block), + None, ) .await .context("EthEstimateGas on lotus") @@ -231,6 +271,7 @@ async fn estimate_is_sufficient_on_chain() -> anyhow::Result<()> { &forest, recurse_calldata(NESTED_DEPTH), BlockNumberOrHash::PredefinedBlock(Predefined::Latest), + None, ) .await?; let from = sender().await?; @@ -263,6 +304,7 @@ async fn estimate_reports_a_non_gas_failure() -> anyhow::Result<()> { client, selector(REQUIRES_HIGH_GAS_SIGNATURE), BlockNumberOrHash::from_block_number(block), + None, ) .await { @@ -272,22 +314,15 @@ async fn estimate_reports_a_non_gas_failure() -> anyhow::Result<()> { ), Err(e) => e, }; - let Some(ClientError::Call(obj)) = err.downcast_ref::() else { - anyhow::bail!("{node} returned a non-JSON-RPC error, cannot check parity: {err:?}"); - }; + let obj = rpc_call_err(&err).with_context(|| { + format!("{node} returned a non-JSON-RPC error, cannot check parity: {err:?}") + })?; eprintln!( "{node} rejected the call: code={} has_data={} msg={}", obj.code(), obj.data().is_some(), obj.message() ); - // Cross-node parity: both name the branch ("gas search failed") and the decoded revert reason. - ensure!( - obj.message().contains(GAS_SEARCH_FAILURE), - "{node} rejected the call before the gas search, so this no longer exercises the \ - branch it is meant to pin (expected `{GAS_SEARCH_FAILURE}`): {}", - obj.message() - ); ensure!( obj.message().contains(REVERT_REASON), "{node} rejected the call without naming the revert reason `{REVERT_REASON}`: {}", @@ -313,3 +348,93 @@ async fn estimate_reports_a_non_gas_failure() -> anyhow::Result<()> { } Ok(()) } + +/// A caller-supplied `gas` bounds the estimate. +async fn estimate_honors_gas_cap() -> anyhow::Result<()> { + let (forest, _, block) = pinned_common_block().await?; + let block = BlockNumberOrHash::from_block_number(block); + let calldata = recurse_calldata(NESTED_DEPTH); + let uncapped = estimate(&forest, calldata.clone(), block.clone(), None).await?; + + let estimate_with_spare_cap = + estimate(&forest, calldata.clone(), block.clone(), Some(uncapped * 2)).await?; + ensure!( + estimate_with_spare_cap == uncapped, + "a cap above the need changed the estimate: capped={estimate_with_spare_cap} uncapped={uncapped}" + ); + + // Just under the estimate still covers the need, so the result is clamped to the cap and must work. + let tight = uncapped - 1; + let estimate_with_tight_cap = + estimate(&forest, calldata.clone(), block.clone(), Some(tight)).await?; + ensure!( + estimate_with_tight_cap <= tight, + "the estimate {estimate_with_tight_cap} exceeds the cap {tight}" + ); + let call = call_message(calldata.clone(), Some(estimate_with_tight_cap)).await?; + forest + .call(EthCall::request((call, block.clone()))?) + .await + .with_context(|| { + format!("eth_call at the capped estimate {estimate_with_tight_cap} failed") + })?; + + // Half the estimate is short of what the nesting needs. + let half = uncapped / 2; + expect_estimate_error( + &forest, + calldata.clone(), + block.clone(), + half, + TRANSACTION_REJECTED_CODE, + &format!("out of gas: gas required exceeds: {half}"), + ) + .await?; + // Below the inclusion cost, preflight rejects the message before it runs. + expect_estimate_error( + &forest, + calldata, + block, + 21_000, + INVALID_INPUT_CODE, + "gas required exceeds allowance (21000)", + ) + .await +} + +/// Under a cap, a revert that more gas fixes is searched past instead of reported. Forest only: Lotus reports it. +async fn estimate_under_cap_searches_past_gas_dependent_revert() -> anyhow::Result<()> { + let (forest, _, block) = pinned_common_block().await?; + let block = BlockNumberOrHash::from_block_number(block); + // The `gasleft()` bound in `requiresHighGasLimit()`. + let threshold: u64 = 50_000_000; + let calldata = selector(REQUIRES_HIGH_GAS_SIGNATURE); + + let gas = estimate( + &forest, + calldata.clone(), + block.clone(), + Some(BLOCK_GAS_LIMIT), + ) + .await?; + ensure!( + gas > threshold, + "expected an estimate above {threshold}, got {gas}" + ); + let call = call_message(calldata.clone(), Some(gas)).await?; + forest + .call(EthCall::request((call, block.clone()))?) + .await + .with_context(|| format!("eth_call at the estimate {gas} failed"))?; + + let cap = threshold / 2; + expect_estimate_error( + &forest, + calldata, + block, + cap, + TRANSACTION_REJECTED_CODE, + &format!("out of gas: gas required exceeds: {cap}"), + ) + .await +} diff --git a/src/dev/subcommands/devnet_cmd/eth_skip_sender.rs b/src/dev/subcommands/devnet_cmd/eth_skip_sender.rs index be67a25817b..a8619e0a01e 100644 --- a/src/dev/subcommands/devnet_cmd/eth_skip_sender.rs +++ b/src/dev/subcommands/devnet_cmd/eth_skip_sender.rs @@ -25,7 +25,6 @@ use crate::shim::econ::TokenAmount; use crate::shim::state_tree::ActorState; use crate::utils::encoding::{hex, keccak_256}; use anyhow::{Context as _, ensure}; -use jsonrpsee::core::ClientError; use libtest_mimic::{Arguments, Failed, Trial}; use std::str::FromStr as _; use tokio::sync::OnceCell; @@ -359,13 +358,6 @@ async fn estimate_msg(client: &Client, msg: EthCallMessage) -> anyhow::Result Option<&jsonrpsee::types::ErrorObjectOwned> { - match err.downcast_ref::() { - Some(ClientError::Call(obj)) => Some(obj), - _ => None, - } -} - fn rpc_data(obj: &jsonrpsee::types::ErrorObjectOwned) -> Option { let raw = obj.data()?; serde_json::from_str::(raw.get()) diff --git a/src/dev/subcommands/tests_cmd/helpers.rs b/src/dev/subcommands/tests_cmd/helpers.rs index 8b84387a3d0..750709e8f80 100644 --- a/src/dev/subcommands/tests_cmd/helpers.rs +++ b/src/dev/subcommands/tests_cmd/helpers.rs @@ -450,6 +450,14 @@ pub async fn poll_until_state_search_msg(msg_cid: &str) -> anyhow::Result<()> { .await } +/// The JSON-RPC error object behind `err`, if the node answered with one. +pub fn rpc_call_err(err: &anyhow::Error) -> Option<&jsonrpsee::types::ErrorObjectOwned> { + match err.downcast_ref::() { + Some(ClientError::Call(obj)) => Some(obj), + _ => None, + } +} + /// Forest and Lotus both refuse a wait for a message they have never seen, rather than waiting /// for one to arrive. fn is_unseen_message_error(e: &ClientError) -> bool { diff --git a/src/rpc/methods/eth.rs b/src/rpc/methods/eth.rs index 8ed00a157d8..7cb8eacec3b 100644 --- a/src/rpc/methods/eth.rs +++ b/src/rpc/methods/eth.rs @@ -56,7 +56,7 @@ use crate::shim::actors::{eam, is_evm_actor, system}; use crate::shim::address::{Address as FilecoinAddress, Protocol}; use crate::shim::crypto::Signature; use crate::shim::econ::{BLOCK_GAS_LIMIT, TokenAmount}; -use crate::shim::executor::Receipt; +use crate::shim::executor::{ApplyRet, Receipt}; use crate::shim::fvm_shared_latest::MethodNum; use crate::shim::fvm_shared_latest::address::{Address as VmAddress, DelegatedAddress}; use crate::shim::fvm_shared_latest::error::ExitCode; @@ -1903,6 +1903,7 @@ async fn eth_estimate_gas( tx: EthCallMessage, tipset: Tipset, ) -> Result { + let gas_cap = tx.gas_cap(); let msg = Message::try_from(tx)?; // Set the gas limit to the zero sentinel value, which makes // gas estimation actually run. @@ -1912,7 +1913,7 @@ async fn eth_estimate_gas( ctx.state_manager .get_actor(&msg.from(), *tipset.parent_state()), ) { - return eth_estimate_gas_skip_sender(ctx, msg, &tipset).await; + return eth_estimate_gas_skip_sender(ctx, msg, &tipset, gas_cap).await; } match gas::estimate_message_gas(ctx, msg.clone(), None, tipset.key().clone().into()).await { @@ -1921,7 +1922,10 @@ async fn eth_estimate_gas( err.downcast_ref(), Some(StateManagerError::SenderValidationFailed(_)) ) { - return eth_estimate_gas_skip_sender(ctx, msg, &tipset).await; + return eth_estimate_gas_skip_sender(ctx, msg, &tipset, gas_cap).await; + } + if let Some(e) = exceeds_gas_cap(&err, gas_cap) { + return Err(e.into()); } // Return reverts as-is to preserve the JSON-RPC error codec. @@ -1935,7 +1939,8 @@ async fn eth_estimate_gas( } Ok(gassed_msg) => { let expected_gas = - eth_gas_search(ctx, gassed_msg, &tipset, SenderValidation::Enforce).await?; + eth_gas_search(ctx, gassed_msg, &tipset, SenderValidation::Enforce, gas_cap) + .await?; Ok(expected_gas.into()) } } @@ -1950,6 +1955,7 @@ async fn eth_estimate_gas_skip_sender( ctx: &Ctx, msg: Message, tipset: &Tipset, + gas_cap: Option, ) -> Result { let gas_limit = match gas::GasEstimateGasLimit::estimate_gas_limit( ctx, @@ -1961,6 +1967,9 @@ async fn eth_estimate_gas_skip_sender( { Ok(gas_limit) => gas_limit, Err(estimate_err) => { + if let Some(e) = exceeds_gas_cap(&estimate_err, gas_cap) { + return Err(e.into()); + } return Err(recover_estimate_gas_error(ctx, msg, tipset, estimate_err).await); } }; @@ -1971,10 +1980,17 @@ async fn eth_estimate_gas_skip_sender( .gas_limit(gas_limit.min(BLOCK_GAS_LIMIT)) .build(); - let expected_gas = eth_gas_search(ctx, msg, tipset, SenderValidation::Skip).await?; + let expected_gas = eth_gas_search(ctx, msg, tipset, SenderValidation::Skip, gas_cap).await?; Ok(expected_gas.into()) } +/// A call out of gas even at the block gas limit cannot fit in a caller's cap either. +fn exceeds_gas_cap(err: &anyhow::Error, gas_cap: Option) -> Option { + let gas_limit = gas_cap?; + matches!(err.downcast_ref(), Some(EthErrors::OutOfGas)) + .then_some(EthErrors::InsufficientGasLimit { gas_limit }) +} + /// Re-execute to recover an `ExecutionReverted` from a failed gas estimate. async fn recover_estimate_gas_error( ctx: &Ctx, @@ -2062,14 +2078,38 @@ async fn apply_message( Ok(invoc_res) } +/// The error for a message that failed within the caller's cap. Execution failures are blamed on the cap, since the estimate already succeeded under the block gas limit. +fn gas_cap_error(apply_ret: &ApplyRet, gas_limit: u64) -> EthErrors { + let exit_code = apply_ret.exit_code(); + // Preflight rejects without executing anything, so it charges no gas. + let preflight = apply_ret.gas_used() == 0; + if preflight && exit_code == ExitCode::SYS_OUT_OF_GAS { + EthErrors::GasRequiredExceedsAllowance { gas_limit } + } else if preflight { + EthErrors::execution_reverted_from_apply_ret(apply_ret) + } else { + EthErrors::InsufficientGasLimit { gas_limit } + } +} + +/// Searches for the gas limit `msg` needs, never exceeding `gas_cap` (the caller-supplied limit) when given. pub async fn eth_gas_search( data: &Ctx, msg: Message, curr_ts: &Tipset, sender_validation: SenderValidation, + gas_cap: Option, ) -> 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. + // The call carries no gas price, so under a cap only gas counts, as in the measurement under the block gas limit. + let msg = match gas_cap { + Some(cap) => { + let gas_limit = msg.gas_limit().min(cap); + gas::without_fees(msg, gas_limit) + } + None => msg, + }; + // Probe the message at its own limit: the question is whether *that* 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(), @@ -2082,34 +2122,38 @@ pub async fn eth_gas_search( return Ok(msg.gas_limit()); } - // Only the trace tells "needs a higher limit" from "fails at any limit", and it is worth one - // re-execution here against the ~30 the search below would spend. The statement keeps the - // trace, one event per gas charge, from outliving the check. - let out_of_gas = data - .state_manager - .call_with_gas( - msg.clone(), - from.protocol(), - prior_messages.shallow_clone(), - Some(ts.shallow_clone()), - VMFlush::Skip, - VMTrace::Traced, - sender_validation, - ) - .await? - .0 - .trace_has_call_return_exit_code(ExitCode::SYS_OUT_OF_GAS); - if !out_of_gas { - // Match Lotus: a code-3 `ExecutionReverted` with the decoded revert data, so eth tooling - // gets the code and can ABI-decode the reason. - let vm_error = apply_ret.failure_info().unwrap_or_default(); - return Err(EthErrors::execution_reverted_from_result( - apply_ret.exit_code(), - apply_ret.return_data(), - &vm_error, - ) - .with_message_prefix("gas search failed") - .into()); + match gas_cap { + Some(cap) => { + let err = gas_cap_error(&apply_ret, cap); + // At the cap any failure is final; below it, only a non-gas preflight rejection is. + if msg.gas_limit() == cap || matches!(err, EthErrors::ExecutionReverted { .. }) { + return Err(err.into()); + } + } + None => { + // Only the trace tells "needs a higher limit" from "fails at any limit", and it is worth one + // re-execution here against the ~30 the search below would spend. The statement keeps the + // trace, one event per gas charge, from outliving the check. + let out_of_gas = data + .state_manager + .call_with_gas( + msg.clone(), + from.protocol(), + prior_messages.shallow_clone(), + Some(ts.shallow_clone()), + VMFlush::Skip, + VMTrace::Traced, + sender_validation, + ) + .await? + .0 + .trace_has_call_return_exit_code(ExitCode::SYS_OUT_OF_GAS); + if !out_of_gas { + // Match Lotus: a code-3 `ExecutionReverted` with the decoded revert data, so eth tooling + // gets the code and can ABI-decode the reason. + return Err(EthErrors::execution_reverted_from_apply_ret(&apply_ret).into()); + } + } } let ret = gas_search( @@ -2119,15 +2163,18 @@ pub async fn eth_gas_search( prior_messages, ts, sender_validation, + gas_cap, ) .await?; - Ok((ret as f64 * data.mpool.gas_limit_overestimation()) as u64) + let estimate = (ret as f64 * data.mpool.gas_limit_overestimation()) as u64; + Ok(gas_cap.map_or(estimate, |cap| estimate.min(cap))) } /// `gas_search` does an exponential search to find a gas value to execute the /// message with. It first finds a high gas limit that allows the message to execute /// by doubling the previous gas limit until it succeeds then does a binary -/// search till it gets within a range of 1% +/// search till it gets within a range of 1%. With a `gas_cap`, the search never +/// exceeds it and fails if the message does not fit. async fn gas_search( data: &Ctx, msg: &Message, @@ -2135,14 +2182,15 @@ async fn gas_search( prior_messages: Arc>, ts: Tipset, sender_validation: SenderValidation, + gas_cap: Option, ) -> anyhow::Result { + let max_gas = gas_cap.unwrap_or(BLOCK_GAS_LIMIT); // `max(1)` keeps the doubling below able to make progress. let mut high = msg.gas_limit().max(1); let mut low = high; - let can_succeed = async |limit: u64| { - let msg = msg.clone(); - let msg = msg.into_builder().gas_limit(limit).build(); + let apply = async |limit: u64| { + let msg = msg.clone().into_builder().gas_limit(limit).build(); let (apply_ret, ..) = data .state_manager .call_with_gas( @@ -2155,15 +2203,23 @@ async fn gas_search( sender_validation, ) .await?; - anyhow::Ok(apply_ret.exit_code().is_success()) + anyhow::Ok(apply_ret) }; + let can_succeed = async |limit: u64| anyhow::Ok(apply(limit).await?.exit_code().is_success()); - while high < BLOCK_GAS_LIMIT { + while high < max_gas { if can_succeed(high).await? { break; } low = high; - high = high.saturating_mul(2).min(BLOCK_GAS_LIMIT); + high = high.saturating_mul(2).min(max_gas); + } + // The doubling stops at the cap without trying it; the block gas limit was proven by the initial estimate, the cap was not. + if gas_cap.is_some() && high == max_gas { + let apply_ret = apply(max_gas).await?; + if !apply_ret.exit_code().is_success() { + return Err(gas_cap_error(&apply_ret, max_gas).into()); + } } let mut check_threshold = high / 100; @@ -4234,6 +4290,7 @@ mod test { use super::*; use crate::rpc::eth::EventEntry; use crate::rpc::state::{ExecutionTrace, MessageTrace, ReturnTrace}; + use crate::shim::actors::evm::EVM_CONTRACT_REVERTED; use crate::shim::fvm_shared_latest::event::Flags; use crate::shim::{econ::TokenAmount, error::ExitCode}; use crate::{ @@ -4260,7 +4317,7 @@ mod test { let return_data = RawBytes::new(fvm_ipld_encoding::to_vec(&RawBytes::new(payload.clone())).unwrap()); let receipt = Receipt::V4(fvm_shared4::receipt::Receipt { - exit_code: fvm_shared4::error::ExitCode::new(33), + exit_code: EVM_CONTRACT_REVERTED.into(), return_data, gas_used: 0, events_root: None, @@ -4326,6 +4383,44 @@ mod test { assert!(!needs_skip_sender(&Ok((ApiInvocResult::default(), None)))); } + #[rstest] + #[case::below_inclusion_cost(ExitCode::SYS_OUT_OF_GAS.value(), 0, errors::INVALID_INPUT_CODE)] + #[case::non_gas_preflight_rejection(ExitCode::SYS_INSUFFICIENT_FUNDS.value(), 0, errors::EXECUTION_REVERTED_CODE)] + #[case::out_of_gas_while_executing(ExitCode::SYS_OUT_OF_GAS.value(), 1, errors::TRANSACTION_REJECTED_CODE)] + #[case::revert_while_executing(EVM_CONTRACT_REVERTED.value(), 1, errors::TRANSACTION_REJECTED_CODE)] + fn gas_cap_error_tells_preflight_from_execution( + #[case] exit_code: u32, + #[case] gas_used: u64, + #[case] expected_code: i32, + ) { + use crate::shim::fvm_shared_latest; + let mut ret = crate::shim::fvm_latest::executor::ApplyRet::prevalidation_fail( + fvm_shared_latest::error::ExitCode::new(exit_code), + "", + fvm_shared_latest::econ::TokenAmount::default(), + ); + ret.msg_receipt.gas_used = gas_used; + let err: ServerError = gas_cap_error(&ApplyRet::V4(ret), 1000).into(); + assert_eq!(err.code(), expected_code); + } + + #[test] + fn exceeds_gas_cap_maps_only_out_of_gas_under_a_cap() { + let out_of_gas = anyhow::Error::from(EthErrors::OutOfGas); + assert!(exceeds_gas_cap(&out_of_gas, None).is_none()); + assert!(matches!( + exceeds_gas_cap(&out_of_gas, Some(1000)), + Some(EthErrors::InsufficientGasLimit { gas_limit: 1000 }) + )); + let reverted = anyhow::Error::from(EthErrors::execution_reverted( + EVM_CONTRACT_REVERTED, + "", + "", + &[], + )); + assert!(exceeds_gas_cap(&reverted, Some(1000)).is_none()); + } + #[test] fn only_an_evm_sender_skips_validation() { use crate::rpc::methods::eth::trace::test_helpers::{ diff --git a/src/rpc/methods/eth/errors.rs b/src/rpc/methods/eth/errors.rs index cea4e1bf2c8..483febf7f6c 100644 --- a/src/rpc/methods/eth/errors.rs +++ b/src/rpc/methods/eth/errors.rs @@ -5,6 +5,7 @@ use super::utils::decode_revert_reason; use crate::rpc::error::RpcErrorData; use crate::shim::clock::ChainEpoch; use crate::shim::error::ExitCode; +use crate::shim::executor::ApplyRet; use crate::utils::encoding::hex; use fvm_ipld_encoding::RawBytes; use serde::Serialize; @@ -20,6 +21,10 @@ pub const EXECUTION_REVERTED_CODE: i32 = 3; pub const LIMIT_EXCEEDED_CODE: i32 = -32005; /// Matches Lotus's `ENullRound` (`jsonrpc.FirstUserCode + 10` = `12`). pub const NULL_ROUND_CODE: i32 = 12; +/// "Invalid input" in [EIP-1474](https://github.com/ethereum/EIPs/blob/ac912ca6a9685590345dd8e5736cda75976d0131/EIPS/eip-1474.md#L43). +pub const INVALID_INPUT_CODE: i32 = -32000; +/// "Transaction rejected" in [EIP-1474](https://github.com/ethereum/EIPs/blob/ac912ca6a9685590345dd8e5736cda75976d0131/EIPS/eip-1474.md#L46). +pub const TRANSACTION_REJECTED_CODE: i32 = -32003; #[derive(Clone, Debug, Error, Serialize)] pub enum EthErrors { @@ -37,6 +42,12 @@ pub enum EthErrors { EventsNotYetAvailable, #[error("requested epoch was a null round ({epoch})")] NullRound { epoch: ChainEpoch }, + /// The caller-supplied gas limit is below the message inclusion cost. + #[error("gas required exceeds allowance ({gas_limit})")] + GasRequiredExceedsAllowance { gas_limit: u64 }, + /// The call runs out of gas, or fails, within the caller-supplied gas limit but succeeds with more. + #[error("out of gas: gas required exceeds: {gas_limit}")] + InsufficientGasLimit { gas_limit: u64 }, } impl EthErrors { @@ -67,17 +78,13 @@ impl EthErrors { Self::execution_reverted(exit_code.into(), &reason, vm_error, &data) } - /// Prepends `prefix` to the message, keeping the code and data. Needed because the RPC layer - /// rebuilds the wire message from the typed error alone, dropping any `anyhow` context. - #[must_use] - pub fn with_message_prefix(mut self, prefix: &str) -> Self { - match &mut self { - Self::ExecutionReverted { message, .. } | Self::BlockRangeExceeded { message, .. } => { - *message = format!("{prefix}: {message}"); - } - Self::OutOfGas | Self::EventsNotYetAvailable | Self::NullRound { .. } => {} - } - self + /// [`Self::execution_reverted_from_result`] for a failed [`ApplyRet`]. + pub fn execution_reverted_from_apply_ret(apply_ret: &ApplyRet) -> Self { + Self::execution_reverted_from_result( + apply_ret.exit_code(), + apply_ret.return_data(), + &apply_ret.failure_info().unwrap_or_default(), + ) } pub fn limit_exceeded(max_block_range: i64, given: i64) -> Self { @@ -102,6 +109,8 @@ impl RpcErrorData for EthErrors { EthErrors::BlockRangeExceeded { .. } => Some(LIMIT_EXCEEDED_CODE), EthErrors::EventsNotYetAvailable => None, EthErrors::NullRound { .. } => Some(NULL_ROUND_CODE), + EthErrors::GasRequiredExceedsAllowance { .. } => Some(INVALID_INPUT_CODE), + EthErrors::InsufficientGasLimit { .. } => Some(TRANSACTION_REJECTED_CODE), } } @@ -112,6 +121,8 @@ impl RpcErrorData for EthErrors { EthErrors::BlockRangeExceeded { message, .. } => Some(message.clone()), EthErrors::EventsNotYetAvailable => Some(self.to_string()), EthErrors::NullRound { .. } => Some(self.to_string()), + EthErrors::GasRequiredExceedsAllowance { .. } + | EthErrors::InsufficientGasLimit { .. } => Some(self.to_string()), } } @@ -122,7 +133,9 @@ impl RpcErrorData for EthErrors { } EthErrors::OutOfGas | EthErrors::BlockRangeExceeded { .. } - | EthErrors::EventsNotYetAvailable => None, + | EthErrors::EventsNotYetAvailable + | EthErrors::GasRequiredExceedsAllowance { .. } + | EthErrors::InsufficientGasLimit { .. } => None, // Lotus sends the epoch as a bare JSON number. EthErrors::NullRound { epoch } => Some(serde_json::Value::from(*epoch)), } @@ -201,39 +214,36 @@ mod tests { } #[test] - fn test_with_message_prefix_prepends_and_preserves_code_and_data() { - let err = EthErrors::execution_reverted( - ExitCode::from(33u32), - "boom", - "backtrace", - &[0xde, 0xad], - ) - .with_message_prefix("gas search failed"); + fn test_out_of_gas_converts_to_server_error_matching_lotus() { + let err = EthErrors::OutOfGas; let server_err: ServerError = err.into(); - assert_eq!(server_err.code(), EXECUTION_REVERTED_CODE); - assert_eq!( - server_err.message(), - "gas search failed: message execution failed (exit=[33], revert reason=[boom], vm error=[backtrace])" - ); - assert_eq!( - server_err.data().map(|d| d.to_string()), - Some("\"0xdead\"".to_string()) - ); + assert_eq!(server_err.code(), OUT_OF_GAS_CODE); + assert_eq!(server_err.message(), "call ran out of gas"); } #[test] - fn test_with_message_prefix_is_a_noop_for_messageless_variants() { - let err = EthErrors::null_round(7).with_message_prefix("ignored"); - assert_eq!(err.to_string(), "requested epoch was a null round (7)"); + fn test_gas_required_exceeds_allowance_converts_to_server_error() { + let server_err: ServerError = + EthErrors::GasRequiredExceedsAllowance { gas_limit: 1000 }.into(); + + assert_eq!(server_err.code(), INVALID_INPUT_CODE); + assert_eq!( + server_err.message(), + "gas required exceeds allowance (1000)" + ); + assert!(server_err.data().is_none()); } #[test] - fn test_out_of_gas_converts_to_server_error_matching_lotus() { - let err = EthErrors::OutOfGas; - let server_err: ServerError = err.into(); + fn test_insufficient_gas_limit_converts_to_server_error() { + let server_err: ServerError = EthErrors::InsufficientGasLimit { gas_limit: 25000 }.into(); - assert_eq!(server_err.code(), OUT_OF_GAS_CODE); - assert_eq!(server_err.message(), "call ran out of gas"); + assert_eq!(server_err.code(), TRANSACTION_REJECTED_CODE); + assert_eq!( + server_err.message(), + "out of gas: gas required exceeds: 25000" + ); + assert!(server_err.data().is_none()); } } diff --git a/src/rpc/methods/eth/types.rs b/src/rpc/methods/eth/types.rs index e2a869a3794..27535b9a2db 100644 --- a/src/rpc/methods/eth/types.rs +++ b/src/rpc/methods/eth/types.rs @@ -359,6 +359,14 @@ impl EthCallMessage { self.input.as_ref().or(self.data.as_ref()) } + /// Returns the caller-supplied gas limit capped at the block gas limit, treating `0` as unset like Lotus. + pub fn gas_cap(&self) -> Option { + match self.gas { + Some(EthUint64(gas)) if gas > 0 => Some(gas.min(BLOCK_GAS_LIMIT)), + _ => None, + } + } + pub fn convert_data_to_message_params(data: EthBytes) -> anyhow::Result { if data.0.is_empty() { Ok(RawBytes::new(data.0)) @@ -404,17 +412,13 @@ impl TryFrom for Message { EAMMethod::CreateExternal as MethodNum, ) }; - let gas_limit = match tx.gas { - Some(EthUint64(gas)) if gas > 0 => gas.min(BLOCK_GAS_LIMIT), - _ => BLOCK_GAS_LIMIT, - }; Ok(Message::builder() .from(from) .to(to) .value(tx.value.unwrap_or_default().into()) .method_num(method_num) .params(params) - .gas_limit(gas_limit) + .gas_limit(tx.gas_cap().unwrap_or(BLOCK_GAS_LIMIT)) .build()) } } @@ -667,6 +671,20 @@ mod tests { assert_eq!(gas_limit(Some(u64::MAX)), BLOCK_GAS_LIMIT); } + #[rstest::rstest] + #[case(None, None)] + #[case(Some(0), None)] + #[case(Some(1000), Some(1000))] + #[case(Some(BLOCK_GAS_LIMIT + 1), Some(BLOCK_GAS_LIMIT))] + #[case(Some(u64::MAX), Some(BLOCK_GAS_LIMIT))] + fn eth_call_message_gas_cap(#[case] gas: Option, #[case] expected: Option) { + let msg = EthCallMessage { + gas: gas.map(EthUint64), + ..Default::default() + }; + assert_eq!(msg.gas_cap(), expected); + } + #[test] fn test_eth_address_from_uncompressed_public_key() { // Uncompressed pub key secp256k1) diff --git a/src/rpc/methods/gas.rs b/src/rpc/methods/gas.rs index 8e23fe5b518..ac76627cdc4 100644 --- a/src/rpc/methods/gas.rs +++ b/src/rpc/methods/gas.rs @@ -214,12 +214,7 @@ impl GasEstimateGasLimit { curr_ts: &Tipset, sender_validation: SenderValidation, ) -> anyhow::Result<(ApplyRet, Arc>, Tipset, Address)> { - let msg = msg - .into_builder() - .gas_limit(BLOCK_GAS_LIMIT) - .gas_fee_cap(TokenAmount::from_atto(0)) - .gas_premium(TokenAmount::from_atto(0)) - .build(); + let msg = without_fees(msg, BLOCK_GAS_LIMIT); Self::probe_as_specified(data, msg, curr_ts, VMTrace::NotTraced, sender_validation).await } @@ -296,16 +291,19 @@ impl GasEstimateGasLimit { )) .into()); } - let vm_error = apply_ret.failure_info().unwrap_or_default(); - Err(EthErrors::execution_reverted_from_result( - exit_code, - apply_ret.return_data(), - &vm_error, - ) - .into()) + Err(EthErrors::execution_reverted_from_apply_ret(&apply_ret).into()) } } +/// `msg` at `gas_limit` with zero fees, so a run judges gas alone and not the sender's funds. +pub fn without_fees(msg: Message, gas_limit: u64) -> Message { + msg.into_builder() + .gas_limit(gas_limit) + .gas_fee_cap(TokenAmount::from_atto(0)) + .gas_premium(TokenAmount::from_atto(0)) + .build() +} + /// Estimates the gas parameters for a given message pub enum GasEstimateMessageGas {} impl RpcMethod<3> for GasEstimateMessageGas {