feat(l1): implement noopTracer for debug trace endpoints#6694
feat(l1): implement noopTracer for debug trace endpoints#6694azteca1998 wants to merge 7 commits into
Conversation
|
🤖 Kimi Code ReviewThe PR adds a crates/networking/rpc/tracing.rs
Security & Correctness
Verdict Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt |
🤖 Codex Code Review
I didn’t find other issues in the small diff, but this one is significant enough that I would not merge as-is. There’s also no coverage for the new tracer path, so once the replay semantics are fixed, add RPC tests for success and failure cases. Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewHere is the review: PR Review:
|
Lines of code reportTotal lines added: Detailed view |
Greptile SummaryThis PR adds a
Confidence Score: 5/5Safe to merge. Both new code paths correctly validate inputs, delegate to the EVM for execution, and return the expected empty-object responses. Transaction existence is validated before returning a result, the EVM is executed to match geth's benchmarking intent, and integration tests confirm both the happy path and the error path for an unknown transaction hash. No files require special attention.
|
| Filename | Overview |
|---|---|
| crates/blockchain/tracing.rs | Adds noop_trace_transaction and noop_trace_block; both correctly validate existence, rebuild parent state, and rerun via the EVM with no tracer attached. |
| crates/networking/rpc/tracing.rs | Adds NoopTracer enum variant and match arms for both trace endpoints; delegates to blockchain layer, returns {} per tx. Unit tests for parsing and deserialization added inline. |
| test/tests/rpc/debug_trace_tests.rs | New integration tests cover noopTracer happy path for both transaction and block tracing, plus the unknown-hash error case. |
| test/tests/rpc/mod.rs | Trivial module registration of the new debug_trace_tests file. |
Sequence Diagram
sequenceDiagram
participant Client
participant RPC as RPC Handler
participant BC as Blockchain
participant EVM as EVM (rerun_block)
participant Storage
Client->>RPC: debug_traceTransaction
RPC->>BC: noop_trace_transaction(txHash, reexec, timeout)
BC->>Storage: get_transaction_location(txHash)
Storage-->>BC: location or None
alt tx not found
BC-->>RPC: Err(Transaction not Found)
RPC-->>Client: RpcErr
end
BC->>BC: rebuild_parent_state
BC->>EVM: rerun_block(block, Some(tx_index+1))
EVM-->>BC: Ok(())
BC-->>RPC: Ok(())
RPC-->>Client: "{}"
Client->>RPC: debug_traceBlockByNumber
RPC->>BC: noop_trace_block(block, reexec, timeout)
BC->>BC: rebuild_parent_state
BC->>EVM: rerun_block(block, None)
EVM-->>BC: Ok(())
BC-->>RPC: Ok([hashes])
RPC-->>Client: "[{txHash, result:{}}]"
Reviews (2): Last reviewed commit: "fix(rpc): noopTracer replays through EVM..." | Re-trigger Greptile
🤖 Kimi Code ReviewThe PR introduces Minor Issues:
Correctness Verification:
Testing: The integration tests comprehensively cover:
Recommendation: Address the error message capitalization and consider the crate layering concern, otherwise LGTM. Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt |
🤖 Codex Code Review
No other correctness or security issues stood out in the diff. The transaction path looks consistent with the existing prestate/call/opcode replay model, and the RPC shape matches the intended I couldn’t run the tests here because Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewPR Review:
|
The handler iterated the entire state trie synchronously inside an async
RPC method via `iter_accounts`, which opens four long-lived locked DB
transactions (see `BackendTrieDBLocked`) and walks the trie node by
node. On any non-trivial state this stalls the tokio worker and pins DB
locks for the duration. Snap-sync already wraps the same call in
`spawn_blocking` for exactly this reason.
Move the traversal onto a blocking thread and add geth-style pagination:
`debug_dumpBlock` now accepts an optional `{start, maxResults}` config
object and emits a `next` cursor when the result is truncated. The
default `maxResults` is 100_000 — a divergence from geth's unbounded
default, documented on the type, that protects the RPC server from
mainnet-scale dumps. Also documents that `code`/`storage` are not
emitted and that `iter_accounts` silently truncates on decode errors.
Test setup helpers move out of the single test file into a shared
`rpc::helpers` module so #6694 and any future endpoint can reuse them
instead of duplicating ~140 lines per feature. The misnamed
`debug_trace_tests.rs` becomes `debug_dump_block_tests.rs` and gains
coverage for: block-not-found, `latest` tag resolution, sender appears
in the dump with expected nonce/balance, and end-to-end pagination
across two pages via `start`/`maxResults`/`next`.
The previous implementation was structurally wrong for two of the three
flat-frame variants:
- `create` actions emitted `input` and a result of `{gasUsed, output}`.
geth's flatCallTracer (and Parity's spec) require `init` in the action
and `{address, code, gasUsed}` in the result so callers can recover the
deployed contract's address and runtime bytecode.
- `suicide` (SELFDESTRUCT) frames emitted the call-shape action and a
bogus `{gasUsed, output}` result. The Parity shape is
`{address, balance, refundAddress}` with no result at all.
Split `FlatCallAction` / `FlatCallResult` into per-variant enums (untagged
so the outer `type` field continues to discriminate), map the LEVM call
tracer's SELFDESTRUCT frame fields onto the suicide shape (`from` →
destructed contract, `to` → refund target, `value` → balance), and emit
`init`/`address`/`code` for create frames. Use `&'static str` for the
static label fields to avoid per-frame allocations.
Unit tests now assert against the serialized JSON projection — the wire
shape clients actually receive — and cover create/create2, selfdestruct,
and failed-frame `result` omission. Integration tests gain a CREATE
end-to-end (deploys a tiny init code and verifies the action/result
shape), a `debug_traceBlockByNumber` test, and a missing-tx failure case
that Codex's review flagged as a gap.
Test setup boilerplate moves out of the misnamed `debug_trace_tests.rs`
into a shared `rpc::helpers` module so #6694 and #6701 can rebase to
re-use it instead of duplicating ~140 lines per feature. The file is
renamed to `debug_flat_call_tracer_tests.rs`.
Three correctness bugs versus geth's built-in 4byteTracer (eth/tracers/native/4byte.go), each of which would cause clients matching against geth output to mismatch every entry: - The reported size was `len(calldata)` instead of `len(calldata) - 4`. Geth's key shape is `"0xSELECTOR-N"` where N is the argument-bytes length excluding the selector itself. - The top-level transaction call was being counted. Geth skips depth 0 and only records nested invocations. - Every CallType was counted, including STATICCALL, CALLCODE, CREATE, CREATE2, and SELFDESTRUCT. Geth filters on the opcode and only counts CALL and DELEGATECALL — for create variants and selfdestruct the input isn't a function selector, and the geth tracer doesn't surface STATICCALL or CALLCODE either. Also skip precompile-targeted invocations (addresses 0x01..=0x11 plus P256VERIFY at 0x100) — matching geth's per-fork `isPrecompiled` check fork-agnostically, since any precompile address is a precompile once its fork activates. Unit tests now lock in each rule: top-frame skip, short-input skip, call-type filter, precompile skip, arg-size key, duplicate counting. Integration tests gain block-level coverage and a missing-tx failure case. Variant doc clarifies the size semantics and skipped categories. Test setup boilerplate moves out of the misnamed `debug_trace_tests.rs` into a shared `rpc::helpers` module so #6694, #6701, #6705, #6704 can all rebase to drop their duplicate copies; the file is renamed to `debug_four_byte_tracer_tests.rs` to fit its contents.
Add noopTracer variant that returns an empty JSON object {} per
transaction. Useful for benchmarking execution overhead without
tracing cost. Supported in both debug_traceTransaction and
debug_traceBlockByNumber.
Part of #6572
Trace a real executed transaction with noopTracer and assert the response is an empty JSON object.
noopTracer was returning {} without validating that the transaction
exists or re-executing it. This diverged from geth, which still runs
the EVM with no-op hooks. Now we call trace_transaction_calls /
trace_block_calls and discard the results, matching geth semantics
and properly erroring on invalid tx hashes.
Routing noopTracer through trace_transaction_calls / trace_block_calls still constructed a LevmCallTracer that allocates a top-level callframe and runs exit-context bookkeeping, so the PR's stated "benchmarking raw execution overhead without tracing cost" goal was not actually met. Add noop_trace_transaction / noop_trace_block on Blockchain that rebuild parent state and use rerun_block with no tracer (LevmCallTracer::disabled short-circuits every hook), and wire the RPC NoopTracer arms to call them. Note in the NoopTracer doc that tracerConfig is ignored. Cover missing-hash failure and block-level happy path with integration tests.
96f8269 to
28da6c4
Compare
ElFantasma
left a comment
There was a problem hiding this comment.
Mechanism differs from geth's noopTracer, which matters for the stated benchmarking goal. geth's noopTracer is a real tracer with empty hook implementations — the EVM runs with the tracer attached, so every opcode still dispatches into the (empty) hook. This PR instead runs rerun_block with no tracer at all, skipping hook dispatch entirely. The JSON output matches geth ({} per tx), but the measurement doesn't: geth's noopTracer number includes per-opcode hook-dispatch overhead, while ethrex's measures pure execution. For "benchmarking raw execution overhead without tracing cost" the no-hook approach is arguably the cleaner number — but anyone comparing ethrex-noop vs geth-noop side by side would be comparing different things. Worth a one-line doc clarification (inline).
Duplicate of #6657 — and one structural idea worth porting from it before this lands. There's an existing open PR for the exact same feature: #6657 (noopTracer, opened 2026-05-14 by @ElFantasma) implements the same TracerType::NoopTracer wired through the same two RPC methods. This PR is the stronger of the two — it ships real tests where #6657 ships none, and the whole-block rerun_block(None) path here is both simpler and a cleaner benchmark (no per-tx setup_env+VM::new reconstruction polluting the measurement). So the recommendation is to land this one and close #6657.
The one thing #6657 does that's worth salvaging: its single-tx path mirrors the sibling tracers (trace_tx_prestate / trace_tx_opcodes) by replaying [0, N-1] and then executing the target tx through a dedicated trace_tx_noop step, instead of folding the target into rerun_block(Some(N+1)). For a noop the observable result is identical, but matching the established replay-then-trace-target shape keeps the tracing module uniform and makes the next tracer that does collect per-tx data drop in without a special case. See the inline suggestion on noop_trace_transaction. (Non-blocking — adopt if you value the consistency; the current form is correct.)
Issue linkage: the description says "Closes part of #6572". #6572 is the umbrella debug-RPC tracker; the granular sub-issue for this exact feature is #6645 ("Implement noopTracer for debug_trace*"). Please change the body to Closes #6645 so GitHub auto-closes the specific sub-issue on merge — a bare "Closes part of #6572" closes nothing and leaves the umbrella issue to be reconciled by hand later.
Coordination: @azteca1998 — since #6657 is the duplicate and this PR supersedes it, suggest closing #6657 once this is finalized (Esteve to action). Flagging so two reviewers don't sink time into both.
| /// `structLogger` wrapper shape (`{failed, gas, returnValue, structLogs}`). | ||
| /// Selected via `"tracer": "opcodeTracer"`. | ||
| OpcodeTracer, | ||
| /// No-op tracer that re-executes the transaction(s) through the EVM with no tracer |
There was a problem hiding this comment.
nit: "matching geth's noopTracer" is true for the output shape but not the mechanism. geth's noopTracer attaches a tracer with empty hooks, so the EVM still dispatches into each (no-op) hook per opcode; this implementation runs with no tracer attached at all (rerun_block with no tracer), skipping hook dispatch entirely.
For the benchmarking goal this is arguably better (it isolates pure execution), but the doc could be precise so future readers don't assume the numbers are directly comparable to geth's noopTracer:
/// No-op tracer: re-executes the transaction(s) through the EVM with no tracer
/// attached and returns empty JSON objects. The output shape matches geth's
/// `noopTracer` (`{}` per tx), but note the mechanism differs — geth runs an
/// empty-hook tracer (paying per-opcode dispatch), whereas this skips tracer
/// hooks entirely, isolating raw execution cost. Any `tracerConfig` is ignored.
/// Selected via `"tracer": "noopTracer"`.
Non-blocking — just so a future benchmark comparison isn't misread.
| .await?; | ||
| // Run every tx up to AND including the target — no tracer attached. | ||
| let stop = tx_index.saturating_add(1); | ||
| timeout_trace_operation(timeout, move || vm.rerun_block(&block, Some(stop))).await |
There was a problem hiding this comment.
Optional consistency port from the duplicate #6657. Here the target tx is folded into the replay by stopping at tx_index + 1. The sibling tracers (trace_tx_prestate, trace_tx_opcodes) instead replay [0, tx_index) and then run the target tx through a dedicated step — and #6657's noop path follows that same shape (rerun_block(Some(tx_index)) + a separate trace_tx_noop(target)).
For a noop the result is identical either way, so this is purely about keeping the module uniform:
// replay preceding txs, then run the target through the same dedicated
// step the other tracers use — keeps the tracing module's shape uniform.
vm.rerun_block(&block, Some(tx_index))?;
timeout_trace_operation(timeout, move || vm.trace_tx_noop(&block, tx_index)).awaitNon-blocking; adopt only if you want parity with the prestate/opcode paths. The current Some(tx_index + 1) form is correct as-is.
The handler iterated the entire state trie synchronously inside an async
RPC method via `iter_accounts`, which opens four long-lived locked DB
transactions (see `BackendTrieDBLocked`) and walks the trie node by
node. On any non-trivial state this stalls the tokio worker and pins DB
locks for the duration. Snap-sync already wraps the same call in
`spawn_blocking` for exactly this reason.
Move the traversal onto a blocking thread and add geth-style pagination:
`debug_dumpBlock` now accepts an optional `{start, maxResults}` config
object and emits a `next` cursor when the result is truncated. The
default `maxResults` is 100_000 — a divergence from geth's unbounded
default, documented on the type, that protects the RPC server from
mainnet-scale dumps. Also documents that `code`/`storage` are not
emitted and that `iter_accounts` silently truncates on decode errors.
Test setup helpers move out of the single test file into a shared
`rpc::helpers` module so #6694 and any future endpoint can reuse them
instead of duplicating ~140 lines per feature. The misnamed
`debug_trace_tests.rs` becomes `debug_dump_block_tests.rs` and gains
coverage for: block-not-found, `latest` tag resolution, sender appears
in the dump with expected nonce/balance, and end-to-end pagination
across two pages via `start`/`maxResults`/`next`.
Three correctness bugs versus geth's built-in 4byteTracer (eth/tracers/native/4byte.go), each of which would cause clients matching against geth output to mismatch every entry: - The reported size was `len(calldata)` instead of `len(calldata) - 4`. Geth's key shape is `"0xSELECTOR-N"` where N is the argument-bytes length excluding the selector itself. - The top-level transaction call was being counted. Geth skips depth 0 and only records nested invocations. - Every CallType was counted, including STATICCALL, CALLCODE, CREATE, CREATE2, and SELFDESTRUCT. Geth filters on the opcode and only counts CALL and DELEGATECALL — for create variants and selfdestruct the input isn't a function selector, and the geth tracer doesn't surface STATICCALL or CALLCODE either. Also skip precompile-targeted invocations (addresses 0x01..=0x11 plus P256VERIFY at 0x100) — matching geth's per-fork `isPrecompiled` check fork-agnostically, since any precompile address is a precompile once its fork activates. Unit tests now lock in each rule: top-frame skip, short-input skip, call-type filter, precompile skip, arg-size key, duplicate counting. Integration tests gain block-level coverage and a missing-tx failure case. Variant doc clarifies the size semantics and skipped categories. Test setup boilerplate moves out of the misnamed `debug_trace_tests.rs` into a shared `rpc::helpers` module so #6694, #6701, #6705, #6704 can all rebase to drop their duplicate copies; the file is renamed to `debug_four_byte_tracer_tests.rs` to fit its contents.
The previous implementation was structurally wrong for two of the three
flat-frame variants:
- `create` actions emitted `input` and a result of `{gasUsed, output}`.
geth's flatCallTracer (and Parity's spec) require `init` in the action
and `{address, code, gasUsed}` in the result so callers can recover the
deployed contract's address and runtime bytecode.
- `suicide` (SELFDESTRUCT) frames emitted the call-shape action and a
bogus `{gasUsed, output}` result. The Parity shape is
`{address, balance, refundAddress}` with no result at all.
Split `FlatCallAction` / `FlatCallResult` into per-variant enums (untagged
so the outer `type` field continues to discriminate), map the LEVM call
tracer's SELFDESTRUCT frame fields onto the suicide shape (`from` →
destructed contract, `to` → refund target, `value` → balance), and emit
`init`/`address`/`code` for create frames. Use `&'static str` for the
static label fields to avoid per-frame allocations.
Unit tests now assert against the serialized JSON projection — the wire
shape clients actually receive — and cover create/create2, selfdestruct,
and failed-frame `result` omission. Integration tests gain a CREATE
end-to-end (deploys a tiny init code and verifies the action/result
shape), a `debug_traceBlockByNumber` test, and a missing-tx failure case
that Codex's review flagged as a gap.
Test setup boilerplate moves out of the misnamed `debug_trace_tests.rs`
into a shared `rpc::helpers` module so #6694 and #6701 can rebase to
re-use it instead of duplicating ~140 lines per feature. The file is
renamed to `debug_flat_call_tracer_tests.rs`.
The doc comment now explicitly notes that ethrex skips tracer hooks entirely rather than running empty-hook dispatch like geth does, so benchmark numbers are not directly comparable.
Summary
noopTracervariant to the tracer type enum{}fordebug_traceTransaction, and{txHash, result: {}}per tx fordebug_traceBlockByNumberCloses part of #6572
Test plan
debug_traceTransactionwith{"tracer": "noopTracer"}returns{}debug_traceBlockByNumberwith{"tracer": "noopTracer"}returns array of{txHash, result: {}}Closes #6645