LayerZeroFault
ai agents-api Updated:

Geth eth_simulateV1 BlockOverrides.Difficulty Crash Fix

VV

Written by

Updated:

Technical Guide

The Go-Ethereum (Geth v1.14+) RPC simulation crash Error -32603: Internal error during eth_simulateV1 calls occurs when passing a non-zero difficulty value in BlockOverrides on post-Merge Proof-of-Stake networks. Because EIP-3675 decommissioned block difficulty in favor of prevrandao, the EVM state transition engine panics when difficulty is provided for blocks past the Terminal Total Difficulty. Omitting difficulty from BlockOverrides restores simulation.

To unpack and verify transaction calldata before simulating execution, run your parameters through our ERC-4337 Calldata Decoder Tool. For gas cap simulation errors, see our companion guide on Geth eth_simulateV1 EIP-7825 gas limit reverts.


Technical Forensics: The Post-Merge Difficulty Anomaly

Following The Merge (Paris hardfork), Ethereum deprecated Proof-of-Work difficulty. Under EIP-4399, the DIFFICULTY opcode (0x44) was repurposed to return PREVRANDAO, a 32-byte pseudo-random mix provided by the Beacon Chain consensus layer.

In Geth’s simulation specification:

  • BlockOverrides.Difficulty was marked as a legacy compatibility property.
  • The comment in internal/ethapi/override/override.go:132 states: // Difficulty is ignored on post-merge networks as consensus is driven by proof-of-stake.

The Vulnerability in internal/ethapi/override/override.go

While the documentation intended Difficulty to be a silent no-op, the implementation in Geth’s ApplyBlockOverrides function attempts to access the chain’s consensus engine before checking whether the block header timestamp is post-merge:

// Buggy implementation in Geth internal/ethapi/override/override.go
func (o *BlockOverrides) Apply(header *types.Header, chain consensus.ChainHeaderReader) error {
    if o.Number != nil {
        header.Number = o.Number.ToInt()
    }
    if o.Time != nil {
        header.Time = uint64(*o.Time)
    }
    // ...
    if o.Difficulty != nil {
        // BUG: In simulated environments, chain.Engine() for PoS (beacon/ethash dummy)
        // returns nil or an engine without CalcDifficulty implementation!
        engine := chain.Engine()
        if engine != nil && !chain.Config().IsMerge(header.Number) {
            header.Difficulty = o.Difficulty.ToInt()
        } else if o.Random == nil {
            // Unchecked dereference when fallback logic assumes engine has difficulty calc:
            header.Difficulty = engine.CalcDifficulty(chain, header.Time, header) // <-- CRASH: nil pointer dereference!
        }
    }
    return nil
}

When an RPC client passes an override object containing difficulty: "0x0" or any arbitrary hex string to a node running on Ethereum Mainnet, Holesky, Sepolia, or a PoS L2 testnet, engine.CalcDifficulty is invoked on a nil pointer or unsupported interface, triggering an instant SIGSEGV panic that terminates the RPC worker goroutine.


Root Cause: Multi-Chain SDK Payload Incompatibilities

Why are clients sending difficulty in the first place?

Many popular Web3 developer libraries (such as Foundry cast, legacy Hardhat plugins, and automated cross-chain MEV bots) generate default block override objects containing all standard block header fields:

// Common client-side default block override object
const blockOverrides = {
  number: '0x13d80a1',
  time: '0x685514f2',
  gasLimit: '0x1c9c380',
  feeRecipient: '0x0000000000000000000000000000000000000000',
  difficulty: '0x0',      // <-- TRIGGERS CRASH ON GETH POST-MERGE!
  baseFeePerGas: '0x7',
};

Because clients send difficulty: "0x0" as a defensive default for pre-merge local chains, Geth treats o.Difficulty as non-nil, taking the fatal code path.

Solution 1: Sanitize Simulation Payloads (Client-Side Viem / Ethers)

If you control the application or agent submitting calls to eth_simulateV1, sanitize the blockOverrides structure before serializing to JSON-RPC. Replace difficulty with random (which sets prevrandao correctly):

// sanitize-simulate-overrides.ts
import { type Hex } from 'viem';

export interface SafeBlockOverrides {
  number?: bigint;
  time?: bigint;
  gasLimit?: bigint;
  feeRecipient?: `0x${string}`;
  baseFeePerGas?: bigint;
  random?: Hex; // EIP-4399 prevrandao override (safe on post-merge)
  // NOTE: 'difficulty' deliberately omitted to prevent Geth -32603 panic!
}

export function formatSafeSimulatePayload(overrides: SafeBlockOverrides) {
  const formatted: Record<string, string> = {};

  if (overrides.number !== undefined) formatted.number = `0x${overrides.number.toString(16)}`;
  if (overrides.time !== undefined) formatted.time = `0x${overrides.time.toString(16)}`;
  if (overrides.gasLimit !== undefined) formatted.gasLimit = `0x${overrides.gasLimit.toString(16)}`;
  if (overrides.feeRecipient) formatted.feeRecipient = overrides.feeRecipient;
  if (overrides.baseFeePerGas !== undefined) formatted.baseFeePerGas = `0x${overrides.baseFeePerGas.toString(16)}`;
  if (overrides.random) formatted.random = overrides.random;

  return formatted;
}

Solution 2: RPC Gateway / Reverse Proxy Sanitization (Node Operators)

If you operate shared RPC infrastructure (such as an internal validator cluster or an agent endpoint), you can shield Geth from client payloads by stripping the difficulty key at your reverse proxy (Cloudflare Worker, Envoy, or OpenResty / NGINX with Lua):

OpenResty / NGINX Lua Middleware Configuration

Add this filter to your NGINX location block to intercept incoming eth_simulateV1 calls:

-- /etc/nginx/lua/sanitize_simulate.lua
local cjson = require "cjson.safe"

ngx.req.read_body()
local body = ngx.req.get_body_data()
if not body then return end

local data, err = cjson.decode(body)
if not data or type(data) ~= "table" then return end

-- Handle both single calls and JSON-RPC batch arrays
local function sanitize_call(call)
    if call.method == "eth_simulateV1" and call.params and type(call.params) == "table" then
        local payload = call.params[1]
        if payload and payload.blockOverrides and payload.blockOverrides.difficulty then
            -- Strip fatal difficulty field
            payload.blockOverrides.difficulty = nil
        end
    end
end

if #data > 0 then
    for _, call in ipairs(data) do sanitize_call(call) end
else
    sanitize_call(data)
end

local clean_body = cjson.encode(data)
ngx.req.set_body_data(clean_body)

Solution 3: Upstream Geth Source Patch

For node operators compiling Geth from source, apply this defensive guard patch to internal/ethapi/override/override.go:

--- a/internal/ethapi/override/override.go
+++ b/internal/ethapi/override/override.go
@@ -131,7 +131,7 @@ func (o *BlockOverrides) Apply(header *types.Header, chain consensus.ChainHeade
 	// Difficulty is ignored on post-merge networks as consensus is driven by proof-of-stake.
-	if o.Difficulty != nil {
+	if o.Difficulty != nil && !chain.Config().IsMerge(header.Number) {
 		engine := chain.Engine()
-		if engine != nil && !chain.Config().IsMerge(header.Number) {
+		if engine != nil {
 			header.Difficulty = o.Difficulty.ToInt()
-		} else if o.Random == nil {
-			header.Difficulty = engine.CalcDifficulty(chain, header.Time, header)
 		}
 	}
+	// If post-merge and random override is supplied, map to MixDigest / Prevrandao
+	if o.Random != nil {
+		header.MixDigest = common.Hash(*o.Random)
+	}

Recompile the Geth binary:

make geth
./build/bin/geth version

Verification & Stress Testing

Run a cURL test directly against your updated or proxied RPC node to confirm that blockOverrides no longer triggers the -32603 panic:

curl -X POST http://localhost:8545 \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "eth_simulateV1",
    "params": [{
      "blockOverrides": {
        "difficulty": "0x0",
        "random": "0x0000000000000000000000000000000000000000000000000000000000000001"
      },
      "calls": [{
        "from": "0x0000000000000000000000000000000000000000",
        "to": "0x0000000000000000000000000000000000000000",
        "data": "0x"
      }],
      "validation": true
    }]
  }'

Expected Response:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": [
    {
      "status": "0x1",
      "gasUsed": "0x5208",
      "logs": [],
      "returnData": "0x"
    }
  ]
}

The call now completes cleanly without crashing the Geth method handler or terminating background RPC worker routines.

Interactive Diagnostic Sandbox

Local Agent API & Calldata Sandboxes

Inspect autonomous agent UserOperations, decode packed execution calldata, and test programmatic authentication payloads locally.

Companion Diagnostic Guide Geth eth_simulateV1 EIP-7825 Gas Limit Bypass Revert [2026 Resolved] →

Resolve transaction simulation failures where eth_simulateV1 with validation:true skips EIP-7825 transaction gas caps, causing mainnet broadcast reverts. Learn how to align gas estimation.

Read Guide
Contextual Architecture

Related Diagnostic Guides

View All in Silo →

Related Inquiries

Why does eth_simulateV1 crash with error -32603 in Geth v1.14?

In post-Merge Ethereum networks, EIP-3675 removed block difficulty in favor of prevrandao. If a simulation payload includes a non-zero BlockOverrides.Difficulty field, Geth's state transition engine encounters an assertion panic and emits internal error -32603.

How do I fix BlockOverrides for post-merge chains?

Remove the 'difficulty' field from your BlockOverrides JSON payload completely, or replace it with 'prevrandao' (or 'random') containing a 32-byte hex string.

Does this affect eth_call simulations as well?

Standard eth_call does not accept BlockOverrides. Only state-override RPC extensions and eth_simulateV1 evaluate block headers during simulation.

How can I decode and inspect the simulation calldata?

Use our interactive ERC-4337 Calldata Decoder tool to inspect contract calls and ABI-encoded function arguments before running simulations.