Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ Ref: https://keepachangelog.com/en/1.0.0/
* (ante) [#773](https://github.com/crypto-org-chain/ethermint/pull/773) fix: race condition in antecache
* (ante) [#788](https://github.com/crypto-org-chain/ethermint/pull/788) fix: add check on evm transaction tip
* (rpc) [#804](https://github.com/crypto-org-chain/ethermint/pull/804) fix: add allow-unprotected-txs config
* (rpc) [#810](https://github.com/crypto-org-chain/ethermint/pull/810) fix: estimate gas not accurate

## [v0.22.0] - 2025-08-12

Expand Down
3 changes: 3 additions & 0 deletions proto/ethermint/evm/v1/tx.proto
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,9 @@ message MsgEthereumTxResponse {
uint64 gas_used = 5;
// include the block hash for json-rpc to use
bytes block_hash = 6;
// execution_gas_used specifies the actual gas consumed during EVM execution,
// before the minGasMultiplier adjustment. This is used for gas estimation.
uint64 execution_gas_used = 7;
}

// MsgUpdateParams defines a Msg for updating the x/evm module parameters.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
pragma solidity ^0.8.10;

/**
* @title GasConsumerTryCatch
* @notice A contract to test try-catch behavior with high gas consumption using a single contract
*/
contract GasConsumerTryCatch {
mapping(uint256 => uint256) public data;
uint256 public totalWrites;
uint256 public lastResult;
uint256 public callCount;

event TrySuccess(uint256 result, uint256 gasUsed);
event TryCatchFailed(string reason, uint256 gasUsed);
event TryCatchFailedBytes(bytes reason, uint256 gasUsed);

error GasConsumerReverted(uint256 iterationsCompleted);

/**
* @notice Consumes gas by writing to storage.
* Must be external to be called via this.consumeGas() in try-catch.
* @param iterations Number of storage writes (~20,000 gas each)
* @param shouldRevert If true, reverts after consuming gas
* @return The total number of writes performed
*/
function consumeGas(uint256 iterations, bool shouldRevert) external returns (uint256) {
uint256 startValue = totalWrites;

// Each SSTORE costs ~20,000 gas for a new slot (cold access)
// To consume ~400,000 gas, we need about 20 iterations
for (uint256 i = 0; i < iterations; i++) {
data[startValue + i] = block.timestamp + i;
totalWrites++;
}

if (shouldRevert) {
revert GasConsumerReverted(iterations);
}

return totalWrites;
}

/**
* @notice Calls the gas-consuming function with try-catch
* @param iterations Number of storage write iterations
* @param shouldRevert If true, the try block will revert after consuming gas
*/
function callWithTryCatch(uint256 iterations, bool shouldRevert) external returns (bool success) {
uint256 gasBefore = gasleft();
callCount++;

// using "this" to make an external call, enabling try-catch
try this.consumeGas(iterations, shouldRevert) returns (uint256 result) {
uint256 gasUsed = gasBefore - gasleft();
lastResult = result;
emit TrySuccess(result, gasUsed);
return true;
} catch Error(string memory reason) {
uint256 gasUsed = gasBefore - gasleft();
emit TryCatchFailed(reason, gasUsed);
return false;
} catch (bytes memory reason) {
uint256 gasUsed = gasBefore - gasleft();
emit TryCatchFailedBytes(reason, gasUsed);
return false;
}
}
}
51 changes: 51 additions & 0 deletions tests/integration_tests/test_trycatch_gas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from concurrent.futures import ThreadPoolExecutor

import pytest

from .utils import ADDRS, CONTRACTS, deploy_contract, send_transaction

pytestmark = pytest.mark.filter


def test_trycatch_gas_estimation_underestimate(ethermint, geth):
def process(w3, name):
contract, _ = deploy_contract(w3, CONTRACTS["GasConsumerTryCatch"])
tx = contract.functions.callWithTryCatch(20, False).build_transaction(
{
"from": ADDRS["community"],
}
)

estimated_gas = w3.eth.estimate_gas(tx)
tx["gas"] = 1000000
receipt = send_transaction(w3, tx)
actual_gas = receipt["gasUsed"]

# Calculate the difference
gas_diff = actual_gas - estimated_gas

return {
"name": name,
"estimated_gas": estimated_gas,
"actual_gas": actual_gas,
"gas_diff": gas_diff,
}

with ThreadPoolExecutor(max_workers=2) as executor:
ethermint_future = executor.submit(process, ethermint.w3, "ethermint")
geth_future = executor.submit(process, geth.w3, "geth")
ethermint_result = ethermint_future.result()
geth_result = geth_future.result()

# Compare results from ethermint and geth
for result in (ethermint_result, geth_result):
assert result["gas_diff"] == 0, (

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assert gas_diff equals to 0 only works for a tx that doesn't trigger gas refunds.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, in this call there is no refund

f"Testing on {result['name']} "
f"Gas estimation is not accurate: "
f"{result['estimated_gas']} estimated vs "
f"{result['actual_gas']} actual "
f"({result['gas_diff']} difference)"
)

assert ethermint_result["estimated_gas"] == geth_result["estimated_gas"]
assert ethermint_result["actual_gas"] == geth_result["actual_gas"]
1 change: 1 addition & 0 deletions tests/integration_tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"FeeCollector": "FeeCollector.sol",
"SelfDestruct": "SelfDestruct.sol",
"BytecodeDeployer": "BytecodeDeployer.sol",
"GasConsumerTryCatch": "GasConsumerTryCatch.sol",
}


Expand Down
59 changes: 37 additions & 22 deletions x/evm/keeper/grpc_query.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"encoding/json"
"errors"
"fmt"
"math"
"math/big"
"time"

Expand Down Expand Up @@ -352,6 +353,12 @@ func (k Keeper) EstimateGas(c context.Context, req *types.EthCallRequest) (*type
} else {
gasCap = hi
}

// Cap hi to MaxInt64 since gas calculations use int64 internally
if hi > math.MaxInt64 {
hi = math.MaxInt64
}

cfg, err := k.EVMConfig(ctx, chainID, common.Hash{})
if err != nil {
return nil, status.Error(codes.Internal, "failed to load evm config")
Expand Down Expand Up @@ -383,35 +390,43 @@ func (k Keeper) EstimateGas(c context.Context, req *types.EthCallRequest) (*type
}
return true, nil, err // Bail out
}
return len(rsp.VmError) > 0, rsp, nil
return rsp.Failed(), rsp, nil
}

// Execute the binary search and hone in on an executable gas limit
hi, err = types.BinSearch(lo, hi, executable)
// We first execute the transaction at the highest allowable gas limit, since if this fails we
// can return error immediately.
failed, result, err := executable(hi)
if err != nil {
return nil, err
}

// Reject the transaction as invalid if it still fails at the highest allowance
if hi == gasCap {
failed, result, err := executable(hi)
if err != nil {
return nil, err
}

if failed {
if result != nil && result.VmError != vm.ErrOutOfGas.Error() {
if result.VmError == vm.ErrExecutionReverted.Error() {
return &types.EstimateGasResponse{
Ret: result.Ret,
VmError: result.VmError,
}, nil
}
return nil, errors.New(result.VmError)
if failed {
if result != nil && result.VmError != vm.ErrOutOfGas.Error() {
if result.VmError == vm.ErrExecutionReverted.Error() {
return &types.EstimateGasResponse{
Ret: result.Ret,
VmError: result.VmError,
}, nil
}
// Otherwise, the specified gas cap is too low
return nil, fmt.Errorf("gas required exceeds allowance (%d)", gasCap)
return nil, errors.New(result.VmError)
}
// Otherwise, the specified gas cap is too low
return nil, fmt.Errorf("gas required exceeds allowance (%d)", hi)
}

// For almost any transaction, the gas consumed by the unconstrained execution
// above lower-bounds the gas limit required for it to succeed. One exception
// is those that explicitly check gas remaining in order to execute within a
// given limit, but we probably don't want to return the lowest possible gas
// limit for these cases anyway.
// Use ExecutionGasUsed (actual gas before minGasMultiplier adjustment) for accurate estimation.
if result.ExecutionGasUsed > 0 {
lo = result.ExecutionGasUsed - 1

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is it -1?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the binary search will start with lo+1

}

// Execute the binary search and hone in on an executable gas limit
hi, err = types.BinSearch(lo, hi, executable)
if err != nil {
return nil, err
}
return &types.EstimateGasResponse{Gas: hi}, nil
}
Expand Down
13 changes: 7 additions & 6 deletions x/evm/keeper/state_transition.go
Original file line number Diff line number Diff line change
Expand Up @@ -505,11 +505,12 @@ func (k *Keeper) ApplyMessageWithConfig(
}

return &types.MsgEthereumTxResponse{
GasUsed: gasUsed,
VmError: vmError,
Ret: ret,
Logs: types.NewLogsFromEth(stateDB.Logs()),
Hash: cfg.TxConfig.TxHash.Hex(),
BlockHash: ctx.HeaderHash(),
GasUsed: gasUsed,
VmError: vmError,
Ret: ret,
Logs: types.NewLogsFromEth(stateDB.Logs()),
Hash: cfg.TxConfig.TxHash.Hex(),
BlockHash: ctx.HeaderHash(),
ExecutionGasUsed: temporaryGasUsed,
}, nil
}
Loading
Loading