Search code examples
solidityrpchedera-hashgraphhedera

Why does `eth_estimateGas` on Hedera return an unexpectedly high value?


I am aware that eth_estimateGas is not intended to be exact, but currently, I'm getting actual gasUsed values that are approximately 6% of the value returned by eth_estimateGas.

In the following example, I invoke the same smart contract with the exact same inputs twice, with only 1 difference:

  • In the 1st invocation, gasLimit = eth_estimateGas
  • In the 2nd invocation, gasLimit = eth_estimateGas * 0.064
    • This value is a very small fraction of the estimate
    • This value was obtained through trial-and-error ... not through any calculations
    // with exact estimated amount of gas
    const estimatedGas2 = (await expendGasSc.estimateGas.updateState(1_000_000_123n)).toBigInt();
    console.log('estimatedGas2', estimatedGas2);
    const gasLimit2 = estimatedGas2 * 1n;
    console.log('gasLimit2', gasLimit2);
    const txResponse2 = await (await expendGasSc
        .updateState(
            1_000_000_123n,
            { gasLimit: gasLimit2 },
        ))
        .wait();
    const gasUsed2 = txResponse2.gasUsed.toBigInt();
    console.log('gasUsed2', gasUsed2);

    // with small fraction of estimated amount of gas
    const estimatedGas4 = (await expendGasSc.estimateGas.updateState(1_000_000_123n)).toBigInt();
    console.log('estimatedGas4', estimatedGas4);
    const gasLimit4 = estimatedGas4 * 64n / 1000n; // <--- 🚨🚨🚨 6.4% 🚨🚨🚨
    console.log('gasLimit4', gasLimit4);
    const txResponse4 = await (await expendGasSc
        .updateState(
            1_000_000_123n,
            { gasLimit: gasLimit4 },
        ))
        .wait();
    console.log('txResponse4', txResponse4);
    const gasUsed4 = txResponse4.gasUsed.toBigInt();
    console.log('gasUsed4', gasUsed4);

Here are the results:

  • When gasLimit is 400,000, gasUsed is 320,000
  • When gasLimit is 25,600, gasUsed is 23,816
    • This is greater than 80% of the specified gasLimit, indicating that HIP-185's gas over-reservation penalty has not kicked in
estimatedGas2 400000n
gasLimit2 400000n
gasUsed2 320000n
estimatedGas4 400000n
gasLimit4 25600n
gasUsed4 23816n

Therefore, I am expecting eth_estimateGas to return a value that is much closer to 23,816 than 400,000. Why is it returning such an unexpectedly high estimate compared to the actual?


Here's the smart contract:

// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.18;

contract ExpendSomeGasDemo {
    uint256 public state;

    function updateState(
        uint256 newState
    )
        public
        returns (uint256 updatedState)
    {
        state = newState;
        updatedState = newState;
    }
}

Note that this contract is deployed on Hedera Testnet: 0x9C58D0159495F7a8853A24574f2B8F348a72424c

Note that the Javascript example above is using ethers.js.

Note that this question is a follow up to my previous one: Large discrepancy in gasUsed values in near-identical transactions on Hedera - why?


Solution

  • You're querying eth_estimateGas via the Hedera JSON-RPC relay, which currently uses static values in lieu of actual gas estimation, this is what's responsible for the consistent estimations.

    Note, that this will soon be replaced with an actual gas estimation algorithm, when the implementation of HIP-584: Mirror EVM Archive Node is released. ​

    Specifically, you can see packages/relay/src/lib/constants.ts, to see the the gas-cost-related values defined as constants: ​

    {
        // ...
        TX_BASE_COST: 21_000,
        TX_HOLLOW_ACCOUNT_CREATION_GAS: 587_000,
        TX_DEFAULT_GAS_DEFAULT: 400_000,
        TX_CREATE_EXTRA: 32_000,
        TX_DATA_ZERO_COST: 4,
        // ...
    }
    

    ​ These are used by the estimateGas function in packages/relay/src/lib/eth.ts in their return values.

    Note, that no actual gas estimation algorithm is being run at the moment. This line - return this.defaultGas;, which maps to TX_DEFAULT_GAS_DEFAULT above - is also why you will get the same value of 400,000 anytime you perform eth_estimateGas for a smart contract invocation. Here's the code snippet below ​

      async estimateGas(transaction: any, _blockParam: string | null, requestId?: string) {
        const requestIdPrefix = formatRequestIdMessage(requestId);
        this.logger.trace(`${requestIdPrefix} estimateGas(transaction=${JSON.stringify(transaction)}, _blockParam=${_blockParam})`);
        // this checks whether this is a transfer transaction and not a contract function execution
        if (transaction && transaction.to && (!transaction.data || transaction.data === '0x')) {
          const value = Number(transaction.value);
          if (value > 0) {
            const accountCacheKey = `${constants.CACHE_KEY.ACCOUNT}_${transaction.to}`;
            let toAccount: object | null = this.cache.get(accountCacheKey);
            if (!toAccount) {
              toAccount = await this.mirrorNodeClient.getAccount(transaction.to, requestId);
            }
    ​
            // when account exists return default base gas, otherwise return the minimum amount of gas to create an account entity
            if (toAccount) {
              this.logger.trace(`${requestIdPrefix} caching ${accountCacheKey}:${JSON.stringify(toAccount)} for ${constants.CACHE_TTL.ONE_HOUR} ms`);
              this.cache.set(accountCacheKey, toAccount);
    ​
              return EthImpl.gasTxBaseCost;
            }
    ​
            return EthImpl.gasTxHollowAccountCreation;
          }
    ​
          return predefined.INVALID_PARAMETER(0, `Invalid 'value' field in transaction param. Value must be greater than 0`);
        } else {
          return this.defaultGas;
        }
      }
    ​