Skip to content

feat(debug): emit struct-log returnData in geth opcode tracers#12101

Open
0xDevNinja wants to merge 2 commits into
NethermindEth:masterfrom
0xDevNinja:feat/12057-debug-trace-returndata
Open

feat(debug): emit struct-log returnData in geth opcode tracers#12101
0xDevNinja wants to merge 2 commits into
NethermindEth:masterfrom
0xDevNinja:feat/12057-debug-trace-returndata

Conversation

@0xDevNinja

Copy link
Copy Markdown
Contributor

Changes

The default debug_trace* opcode (struct) logger never exposed the per-step return-data buffer, unlike geth/Besu/Erigon/Reth, which emit returnData when the enableReturnData flag is set. This completes #12057 (the refund counter landed in #12081).

  • Add the EnableReturnData option to GethTraceOptions (mirrors geth's enableReturnData tracer config key; off by default).
  • Add a gated op-level tracer hook ITxTracer.SetOperationReturnData (with IsTracingReturnData), fed from the VM's return-data buffer in StartInstructionTrace. Wired through the TxTracer base and the forwarding tracers (Cancellation, Composite, BlockReceipts, AlwaysCancel).
  • Emit returnData as a hex string (only when non-empty) from both the in-memory (GethLikeTxMemoryTracer) and streaming (GethLikeTxDirectStreamingTracer) geth tracers; the streaming path copies the bytes into a pooled scratch buffer since the source is reused across opcodes.

Because the flag defaults to off, the hook is never invoked and existing traces are byte-for-byte unchanged.

Types of changes

What types of changes does your code introduce?

  • Bugfix (a non-breaking change that fixes an issue)
  • New feature (a non-breaking change that adds functionality)
  • Breaking change (a change that causes existing functionality not to work as expected)
  • Optimization
  • Refactoring
  • Documentation update
  • Build-related changes
  • Other:

Testing

Requires testing

  • Yes
  • No

If yes, did you write tests?

  • Yes
  • No

Notes on testing

Added Can_trace_returndata_when_enabled / ReturnData_is_omitted_when_not_enabled (in-memory) and Streams_returndata_when_enabled / Omits_returndata_when_not_enabled (streaming), asserting the buffer is captured after an inner call returns and stays absent by default. Verified no regressions across the geth tracing (403), debug_trace* JSON-RPC (116), and streaming blockchain-test runner suites.

Documentation

Requires documentation update

  • Yes
  • No

Requires explanation in Release Notes

  • Yes
  • No

When enableReturnData is set, the debug_traceTransaction / debug_traceBlock* default (struct) tracer now includes the returnData field (hex, when non-empty) on each step, aligning the opcode trace output with geth/Besu/Erigon/Reth.

The default debug_trace* opcode (struct) logger never exposed the
per-step return-data buffer, unlike geth/Besu/Erigon/Reth, which emit
`returnData` when the `enableReturnData` flag is set.

Add the `EnableReturnData` geth trace option and a gated op-level
`SetOperationReturnData` tracer hook fed from the VM's return-data
buffer, and emit `returnData` (hex, when non-empty) from both the
in-memory and streaming geth tracers. The flag is off by default, so
existing traces are unchanged.

Together with the refund counter, this completes the struct-log fields.

Closes NethermindEth#12057
@svlachakis

Copy link
Copy Markdown
Contributor

@claude review

@claude

claude Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @svlachakis's task in 5m 11s —— View job


Review: feat(debug): emit struct-log returnData in geth opcode tracers

  • Gather context and understand the request
  • Read relevant rules files
  • Read changed files
  • Analyze correctness, edge cases, regressions
  • Analyze security/performance
  • Post inline comments for specific findings
  • Write final review summary

Summary

The feature is well-scoped and correctly wired end-to-end. The opt-in flag (EnableReturnData defaulting to false) ensures zero impact on existing traces. The streaming tracer correctly copies the return-data buffer before the source is reused, and the memory tracer correctly creates a new string per entry. The forwarding tracers (Cancellation, Composite, BlockReceipts, AlwaysCancel) are all updated. Tests cover the enabled/disabled cases for both tracer implementations.

One Medium correctness gap and two Low issues found:


Medium

IsTracingReturnData missing from both IsTracing computations

ITxTracer.IsTracing (the default interface property) and TxTracer.IsTracing (the cached base-class property) enumerate every other gated tracing flag but omit IsTracingReturnData. This is the only new flag added to the interface that isn't reflected in IsTracing.

Practically, the concrete Geth tracers in this PR are not affected because they always have IsTracingInstructions = true, which already makes IsTracing = true. However, any future custom tracer that sets only IsTracingReturnData = true (with all other flags false) would have IsTracing return false, and the VM would skip tracing entirely for it. This breaks the contract established by all other flags.

See inline comment on TxTracer.cs:41.


Low

String allocation in the streaming tracer's returnData hex write (GethLikeTxDirectStreamingTracer.cs:301)

ToHexString(true) allocates a string before writing. The rest of the streaming tracer uses HexWriter.WriteFixed32HexRawValue / HexWriter.WriteUInt256HexRawValue specifically to avoid per-opcode allocations. Return data is variable-length, so an allocation proportional to the data size occurs on every opcode following an inner call when EnableReturnData = true. Gated by opt-in, so no regression today — but inconsistent with the tracer's zero-alloc design.

See inline comment on GethLikeTxDirectStreamingTracer.cs:301.

GethLikeTxTracer.IsTracing override doesn't cover IsTracingReturnData (GethLikeTxTracer.cs:23)

IsTracing = IsTracing || IsTracingFullMemory exists to keep IsTracing = true when only IsTracingFullMemory is set (since that flag isn't in the interface). The parallel concern for IsTracingReturnData isn't handled here, though it's benign today since IsTracingInstructions is always true in this class. The right fix is to add IsTracingReturnData to the interface-level IsTracing (the Medium above), which would make this workaround unnecessary.

See inline comment on GethLikeTxTracer.cs:23.


No issues found in

  • VirtualMachine.cs hook placement (StartInstructionTrace) — correct position, matches geth's pre-opcode snapshot semantics
  • Buffer lifetime in streaming tracer — bytes are copied into the tracer's own pooled scratch buffer, not held as a reference to the VM's reused buffer
  • ReturnPooledBuffers — all three pooled buffers (_stackBuffer, _memoryBuffer, _returnDataBuffer) are properly null-guarded and returned
  • ResetForNextTx_returnDataByteCount = 0 correctly clears stale state between transactions
  • Hex format — 0x-prefixed hex matches geth's output in both tracers
  • GethTxTraceEntry.ReturnDataJsonIgnore(WhenWritingNull) correctly omits the field when absent

@svlachakis svlachakis requested a review from manusw7 June 23, 2026 07:48
if (IsTracingFullMemory) WriteMemoryArrayIfPresent();
if (IsTracingOpLevelStorage) WriteStorageObjectIfPresent();
if (IsTracingReturnData && _returnDataByteCount > 0)
_writer.WriteString("returnData"u8, _returnDataBuffer.AsSpan(0, _returnDataByteCount).ToHexString(true));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low – string allocation in the zero-alloc streaming path.

ToHexString(true) allocates a new string before writing. The rest of the streaming tracer intentionally avoids per-opcode allocations, using HexWriter.WriteFixed32HexRawValue and HexWriter.WriteUInt256HexRawValue instead. Return data can be arbitrarily large (up to the full call output), so this creates a proportional allocation on every traced opcode after an inner call returns.

A zero-copy alternative would write the 0x prefix and then the hex bytes directly into the Utf8JsonWriter without materialising an intermediate string. If HexWriter doesn't already have a variable-length variant, one could be added there; or JsonWriterHelper.WriteHexEncodedPropertyValue (if available) could be used instead.

This is gated by the opt-in EnableReturnData flag so it has no impact on the default path.

IsTracingStack = !options.DisableStack;
IsTracingFullMemory = options.EnableMemory;
IsTracingReturnData = options.EnableReturnData;
IsTracing = IsTracing || IsTracingFullMemory;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low – IsTracing override doesn't cover IsTracingReturnData.

IsTracing = IsTracing || IsTracingFullMemory was added so that IsTracingFullMemory (which isn't a standard ITxTracer flag) still keeps IsTracing true. IsTracingReturnData is a standard interface flag, so it should instead be fixed in the ITxTracer.IsTracing default (see comment there). As a belt-and-suspenders measure, consider also adding || IsTracingReturnData here — or, better, fix the source of truth in the interface.

In practice this is benign today because IsTracingInstructions returns true unconditionally from GethLikeTxTracer, ensuring IsTracing is always true regardless.

public virtual bool IsTracingMemory { get; protected set; }
public virtual bool IsTracingInstructions { get; protected set; }
public virtual bool IsTracingRefunds { get; protected set; }
public virtual bool IsTracingReturnData { get; protected set; }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium – IsTracingReturnData must also be included in IsTracing.

Both TxTracer.IsTracing (the lazy-cached property at lines 19–30) and ITxTracer.IsTracing (the default interface property) enumerate every gated flag, but neither includes IsTracingReturnData. This is the only new tracing flag missing from both IsTracing computations.

Impact: any custom tracer that sets only IsTracingReturnData = true (all other flags false) will have IsTracing return false, and the VM skips all tracing for it entirely. In practice today's Geth tracers are unaffected (they always have IsTracingInstructions = true), but the interface contract is broken.

// TxTracer.IsTracing should include:
get => _isTracing ??= ... || IsTracingReturnData || ...;

The same fix is needed in ITxTracer.cs in the bool IsTracing => default property.

Fix this →

/// </summary>
/// <param name="returnData">The current return-data buffer.</param>
/// <remarks>Depends on <see cref="IsTracingReturnData"/></remarks>
void SetOperationReturnData(ReadOnlyMemory<byte> returnData);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does a plain dotnet build Debug still pass? DebugTracer is #if DEBUG and implements ITxTracer directly, so it looks like it'd need the two new members forwarded too - and since CI only builds Release, it'd slip through green

- Forward IsTracingReturnData / SetOperationReturnData in DebugTracer
  (guarded by #if DEBUG, so the missing members were not caught by the
  Release-only CI build).
- Include IsTracingReturnData in both the ITxTracer.IsTracing default
  and TxTracer.IsTracing, so a tracer that enables only return-data
  tracing is not skipped entirely by the VM.
- Stream returnData through a pooled hex scratch buffer instead of
  allocating a string per opcode, matching the streaming tracer's
  zero-allocation design.
@0xDevNinja

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review — addressed in 194ba69:

  • DebugTracer (#if DEBUG) — good catch, it implements ITxTracer directly and the Release-only CI build wouldn't flag it. Both IsTracingReturnData and SetOperationReturnData are now forwarded to the inner tracer; confirmed with a local -c debug build.
  • IsTracing missing the new flag — added IsTracingReturnData to both ITxTracer.IsTracing and TxTracer.IsTracing, so a tracer enabling only return-data tracing is no longer skipped entirely by the VM.
  • String allocation in the streaming path — the hex is now encoded straight into a pooled scratch buffer and emitted via WriteRawValue, so there's no per-opcode string allocation, matching the rest of the streaming tracer.

@svlachakis

Copy link
Copy Markdown
Contributor

@claude review again

@claude

claude Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Claude finished @svlachakis's task in 2m 20s —— View job


Re-review: feat(debug): emit struct-log returnData in geth opcode tracers

  • Gather context and understand the request
  • Read relevant rules files
  • Verify previous Medium fix: IsTracingReturnData in IsTracing computations
  • Verify previous Low fix: streaming tracer hex allocation
  • Verify DebugTracer forwarding (raised by AnkushinDaniil)
  • Check for new issues in fix commit 194ba69

All three issues from the first review are cleanly resolved in 194ba69. No new issues found.

Verified fixes

Medium — IsTracingReturnData missing from IsTracing
ITxTracer.IsTracing (line 155) and TxTracer.IsTracing (line 25) both now include || IsTracingReturnData in their computations. A custom tracer enabling only this flag will no longer be silently skipped by the VM.

Low — String allocation in streaming tracer
WriteReturnDataValue() now encodes the "0x"-prefixed hex directly into a pooled _returnDataHexBuffer scratch buffer and emits it via _writer.WriteRawValue(token, skipInputValidation: true). No intermediate string is allocated. The new buffer is correctly null-guarded and returned in ReturnPooledBuffers() alongside the three existing ones.

Low (AnkushinDaniil's inline) — DebugTracer missing the new members
DebugTracer.cs:45 forwards IsTracingReturnData => InnerTracer.IsTracingReturnData, and line 219 forwards SetOperationReturnData(ReadOnlyMemory<byte> returnData) => InnerTracer.SetOperationReturnData(returnData). The #if DEBUG-guarded class compiles correctly.

No new issues

  • _returnDataHexBuffer is not zeroed in ResetForNextTx, but this is safe: _returnDataByteCount = 0 prevents WriteReturnDataValue from being called, so stale bytes are never emitted.
  • OutputBytesToByteHex uses "x2" format (lowercase), matching the "0x"-prefixed lowercase output of ToHexString(true) in the memory tracer — both formats are consistent.
  • The IsTracing = IsTracing || IsTracingFullMemory pattern in GethLikeTxTracer (the old secondary Low) is now harmless: IsTracingReturnData is included in the lazily-computed IsTracing before that setter fires, so the assignment cannot accidentally shadow it.

Mergeable. Ready to land.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants