Last active
August 20, 2026 15:10
-
-
Save Aminechakr/a2ae25679f3d8e109d042d3fa2a74585 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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