Fix: Geth eth_simulateV1 BlockOverrides.Difficulty Crash on Post-Merge Chains (-32603)
The introduction of eth_simulateV1 into the Ethereum Execution API gave developers, MEV searchers, and autonomous trading bots the ability to perform atomic multi-transaction bundle simulations with fine-grained state and block header overrides.
However, node operators and Web3 backend engineers running recent Go-Ethereum (Geth) versions frequently experience catastrophic RPC failures when running integration suites:
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32603,
"message": "method handler crashed"
}
}
Looking inside the node console reveals an unhandled Go runtime panic:
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x12a84b3]
goroutine 4921 [running]:
github.com/ethereum/go-ethereum/internal/ethapi/override.ApplyBlockOverrides(...)
internal/ethapi/override/override.go:132
github.com/ethereum/go-ethereum/internal/ethapi.(*SimulateV1API).SimulateV1(...)
internal/ethapi/api_simulate.go:88
If your simulations are also failing due to EIP-7825 gas caps, see our companion analysis on Fixing Geth eth_simulateV1 EIP-7825 Gas Limit Bypass Revert.
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.Difficultywas marked as a legacy compatibility property.- The comment in
internal/ethapi/override/override.go:132states:// 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.