Skip to content

Instantly share code, notes, and snippets.

@Aminechakr
Last active August 20, 2026 15:10
Show Gist options
  • Select an option

  • Save Aminechakr/a2ae25679f3d8e109d042d3fa2a74585 to your computer and use it in GitHub Desktop.

Select an option

Save Aminechakr/a2ae25679f3d8e109d042d3fa2a74585 to your computer and use it in GitHub Desktop.
Title: eth_simulateV1: calls[].logs is always empty for contract-emitted events (Transfer, Approval) — the LOG3 executes and is charged for
Labels: bug
Environment
besu/v26.2.0/linux-x86_64/oracle-java-21
Private QBFT network, chainId 0x539 (1337), 15s block period, zeroBaseFee: true
Prague active — eth_config.current.activationTime = 1787065200, forkId 0xde323179, BLS12 precompiles present in eth_config.current.precompiles
Measured on 26.2.0. Users report the same behaviour on 25.12, so this is probably not a recent regression — but we have not measured 25.12 ourselves.
Summary
eth_simulateV1 executes contract events and charges gas for them, but returns calls[].logs as an empty array. The simulated block's logsBloom is also all zeros while its receiptsRoot is populated, which suggests receipts are constructed but without their logs — so the logs are lost before receipt construction rather than at JSON serialization.
Synthetic ETH-transfer logs from traceTransfers are returned correctly, so the defect is specific to contract-emitted logs.
Steps to reproduce
Deploy or pick any standard ERC-20 with a funded holder.
Call eth_simulateV1 with a transfer() or approve() call from that holder (payloads below).
Observe status: 0x1, returnData: 0x…01 (true), a gasUsed consistent with the event having been emitted, and logs: [].
Run debug_traceCall on the identical call and observe one LOG3 opcode in structLogs.
Expected
result[].calls[].logs contains the Transfer / Approval event, as the corresponding mined transaction's receipt does.
Actual
logs: [], and the simulated block's logsBloom: 0x00…0.
Evidence — standard ERC-20 transfer(0x…dEaD, 20e18), one node, block latest
check result
eth_simulateV1 status: 0x1, returnData: 0x…01 (true), gasUsed: 0xc8d8, logs: [], logsBloom all zero, receiptsRoot populated
eth_call, identical call 0x…01 (true) — consistent
debug_traceCall, identical call 1 LOG3 opcode executed, failed: false, gasUsed: 0xc8d8
debug_traceCall + callTracer + withLog: true 200 OK, gasUsed: 0xc8d8, output: 0x…01, no logs field — see secondary finding
Gas accounting
0xc8d8 = 51,416, fully attributable:
item gas
transaction base 21,000
calldata (37 zero × 4 + 31 non-zero × 16) 644
cold SLOAD balances[from] 2,100
SSTORE balances[from] (non-zero → non-zero) 2,900
cold SLOAD balances[recipient] 2,100
SSTORE balances[recipient] (zero → non-zero) 20,000
LOG3 — 375 + 3 × 375 + 8 × 32 1,756
dispatch / decode / memory ~916
total 51,416
The LOG3 was charged for, so the instruction ran. The log is produced and then discarded.
Not limited to Transfer
approve() behaves identically — the Approval event is absent from logs in the same way. So this affects contract-emitted events generally, not one event signature.
Reproducers
transfer(0x…dEaD, 20e18):
{"jsonrpc":"2.0","id":1,"method":"eth_simulateV1","params":[{
"blockStateCalls":[{"blockOverrides":{"baseFeePerGas":"0x0","gasLimit":"0x1000000"},
"calls":[{"from":"<any funded holder>","to":"<any ERC-20>",
"data":"0xa9059cbb000000000000000000000000000000000000000000000000000000000000dead000000000000000000000000000000000000000000000001158e460913d00000"}]}],
"validation":false,"traceTransfers":true},"latest"]}
approve(0x22dde974…0040, 10):
{"jsonrpc":"2.0","id":1,"method":"eth_simulateV1","params":[{
"blockStateCalls":[{"blockOverrides":{"baseFeePerGas":"0x0","gasLimit":"0x1000000"},
"calls":[{"from":"<any funded holder>","to":"<any ERC-20>",
"data":"0x095ea7b300000000000000000000000022dde974c0962c48519639d73c1af2d68feb0040000000000000000000000000000000000000000000000000000000000000000a"}]}],
"validation":true,"traceTransfers":true},"latest"]}
Reproduces with validation true and false, with a self-transfer and a transfer to a distinct recipient, and with traceTransfers: true.
What is unaffected
status, gasUsed, returnData, and the simulated block header fields (requestsHash, excessBlobGas, withdrawalsRoot, receiptsRoot) are all correct. traceTransfers synthetic ETH-transfer logs are returned correctly — address: 0xeeee…eeee, well-formed topics / data / logIndex / blockHash / transactionHash.
Secondary finding
debug_traceCall with {"tracer":"callTracer","tracerConfig":{"withLog":true}} returns 200 OK but the call frame carries no logs field — the config appears to be silently ignored rather than rejected.
Combined with the primary issue, there is currently no supported way to obtain contract event logs from a simulated call on Besu; only raw structLogs expose them (topics on the stack, data in memory at the LOG3 frame).
Cross-client note
The same request shape against an Erigon node on a comparable but separate private network does return the Approval log. This is indicative rather than a controlled comparison — different token deployment, different chain config, different state — so we're flagging it as a hint, not proof. The controlled evidence is the within-node contradiction above: Besu executes and bills the LOG3, then reports no log.
Scope of what we tested
Tested: transfer() and approve() on one ERC-20; self-transfer and distinct-recipient; validation true and false; traceTransfers true; single call in a single blockStateCalls entry.
Not tested: multiple calls in one block, multiple blockStateCalls, stateOverrides, events with other topic counts (LOG0/LOG1/LOG2/LOG4), nested calls, contract creation.
Impact
We've had to disable the feature that depended on eth_simulateV1 for our private-network integration. Applications cannot pre-flight token operations and read the resulting events, which is the primary reason to use this method over eth_call.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment