Skip to content

Align debug_trace* opcode (struct) tracer with execution-apis #762#12056

Open
manusw7 wants to merge 24 commits into
masterfrom
feature/debug-trace-opcode-tracer-762
Open

Align debug_trace* opcode (struct) tracer with execution-apis #762#12056
manusw7 wants to merge 24 commits into
masterfrom
feature/debug-trace-opcode-tracer-762

Conversation

@manusw7

@manusw7 manusw7 commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Fixes #12040

Changes

  • Omit error from struct logs when no error occurred (was always "error": null — the only client doing so).
  • Capture op-level storage at both SLOAD and SSTORE (previously never captured: the EVM gated these callbacks on IsTracingStorage instead of IsTracingOpLevelStorage), and emit storage only on those steps as a per-address, whole-tx cumulative snapshot.
  • 0x-prefix memory words (zero-padded to 32 bytes) and storage keys/values (bytes32).
  • Apply the fixes to both the streaming (GethLikeTxDirectStreamingTracer) and in-memory (GethLikeTxMemoryTracer) tracers; add a test pinning them to identical output.
  • Fix the JSON-lines file tracer to avoid a doubled 0x in memory now that words are individually prefixed.

Aligns the default opcode (struct) tracer with ethereum/execution-apis#762 and ethereum/go-ethereum#34093. refund and per-step returnData are tracked as a follow-up in #12057.

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: Description

Testing

Requires testing

  • Yes
  • No

If yes, did you write tests?

  • Yes
  • No

Notes on testing

Updated golden JSON in the Evm/JsonRpc tracer tests; added cross-tracer equivalence and file-tracer regression tests. Full Nethermind.Evm.Test and the debug_trace* JsonRpc suites pass.

Documentation

Requires documentation update

  • Yes
  • No

Requires explanation in Release Notes

  • Yes
  • No

debug_trace* struct-log output changes to conform to execution-apis #762: error omitted when empty; memory/storage 0x-prefixed; storage emitted only at SLOAD/SSTORE as a per-address cumulative snapshot.

@claude

claude Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Claude finished @manusw7's task in 7m 54s —— View job


PR Review: Align debug_trace* opcode (struct) tracer with execution-apis #762

Todo List

  • Load tools and setup
  • Read changed files and gather context
  • Review GethLikeTxDirectStreamingTracer changes
  • Review GethLikeTxMemoryTracer changes
  • Review storage/memory encoding changes
  • Review test coverage
  • Review JSON output conformance
  • Post findings with inline comments

Summary

The PR correctly aligns both the streaming and in-memory Geth-style struct-log tracers with execution-apis #762. The core root-cause fix — changing IsTracingStorage to IsTracingOpLevelStorage in EvmInstructions.Storage.cs — is accurate: GethLikeTxTracer sets IsTracingOpLevelStorage but never IsTracingStorage, so all four ops (SLOAD, SSTORE, TLOAD, TSTORE) were silently never reporting to the tracer before this PR.

The behavioral changes are all correct:

  • error field now omitted when null (correct; was the only client emitting "error": null unconditionally)
  • memory words now individually 0x-prefixed (correct; matches execution-apis)
  • storage emitted only at SLOAD/SSTORE as a per-address cumulative snapshot (correct; matches go-ethereum #34093)
  • storage keys/values now 0x-prefixed with 32-byte zero-padding (correct)
  • The file-tracer doubled-prefix bug is fixed (correct)
  • StoragesByDepth depth-stack removed from GethLikeTxTrace (correct cleanup; depth-based isolation is superseded by per-address cumulative semantics)
  • The streaming and in-memory tracers are pinned to identical output via a cross-tracer test (good)

The TraceMemory.ToHexWordList() partial-word zero-padding fix is a latent correctness improvement (EVM memory is always word-aligned in practice, but the fix is sound).


Findings

Medium

Missing SLOAD test — The gate change from IsTracingStorageIsTracingOpLevelStorage fixes SLOAD (and TLOAD/TSTORE), but the only struct-log storage tests exercise SSTORE. Since SLOAD was completely silent before, there's no golden test asserting that an SLOAD step produces a non-null storage entry with the cumulative snapshot. Given the fix is in a path the compiler can't prove correct, this warrants at least one targeted test. See inline comment on EvmInstructions.Storage.cs:59.

Low

  • _pendingStorageMap is a live reference into _storageByAddress[address], not a snapshot copy. Safe due to EVM single-threading, but worth noting. See inline on GethLikeTxDirectStreamingTracer.cs:194.
  • ZeroPaddedSpan has no explicit guard against value.Length > 32; invariant holds by EVM word-size constraint. See inline on GethLikeTxMemoryTracer.cs:70.
  • TraceMemory.ToHexWordList() partial-word path: stackalloc is correctly hoisted outside the loop; nit on the partial-word precondition. See inline on TraceMemory.cs:32.

Verdict

The correctness of the implementation is sound. The single Medium finding is a test-coverage gap for the newly-fixed SLOAD path, not a bug. Given SLOAD was entirely broken before and the fix touches the EVM hot path, a targeted test is strongly recommended before merge.


// If storage tracing is enabled, record the operation.
if (vm.TxTracer.IsTracingStorage)
if (vm.TxTracer.IsTracingOpLevelStorage)

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 — SLOAD storage tracking: correct fix, missing test

The gate change from IsTracingStorage (state-diff flag from IStorageTracer) to IsTracingOpLevelStorage (per-opcode flag from ITxTracer) is the right fix. GethLikeTxTracer sets IsTracingOpLevelStorage = !options.DisableStorage but never set IsTracingStorage, so LoadOperationStorage was never being called for TLOAD (same issue fixed identically for TSTORE, SLOAD, SSTORE below).

However, there's no test that exercises SLOAD and verifies the storage field appears in struct logs for the SLOAD step. Given SLOAD was completely silent before this fix (wrong flag = always false), a targeted test would catch any regression in this code path. The SSTORE path is tested (see DebugRpcModuleTests.TraceTransaction.cs), but SLOAD is not.

Fix this →

_storageByAddress[address] = contractStorage = new PooledDictionary<UInt256, UInt256>(InitialStorageMapCapacity);
}
contractStorage[storageIndex] = new UInt256(value, isBigEndian: true);
_pendingStorageMap = contractStorage;

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 — _pendingStorageMap semantics: single-address snapshot only

_pendingStorageMap is set to the cumulative map for the single address touched by this opcode. If a tx has both address X and address Y with storage ops at different steps, each SLOAD/SSTORE step shows only its own contract's cumulative storage — which is correct per execution-apis #762.

One subtle edge: _pendingStorageMap is a live reference into _storageByAddress[address]. Between RecordStorage being called and WriteStorageObjectIfPresent being called during FinalizePendingOpcode, no other storage callback can fire (EVM is single-threaded and operations are atomic), so there's no race. But it's worth knowing the reference is live rather than a snapshot at capture-time.


CurrentTraceEntry.Storage = new Dictionary<string, string>(previousTraceEntry.Storage);
}
contractStorage[bigEndian.ToHexString(true)] = new ZeroPaddedSpan(value, 32 - value.Length, PadDirection.Left)

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 — ZeroPaddedSpan with no length guard

32 - value.Length is negative if value.Length > 32. EVM storage values are bounded at 32 bytes (word size), so this can't happen in practice — SLOAD returns at most 32 bytes, and SSTORE strips leading zeros so the value is ≤32 bytes. No change needed, just documenting the implicit invariant.

memory[i] = bytes.ToHexString();
memory[i] = bytes.ToHexString(true);
}
else if (sizeAvailable > 0)

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 — stackalloc inside loop body

paddedWord is declared outside the loop (good), so the stackalloc does not repeat. The paddedWord.Clear() before each use is correct — it zeroes the padding bytes before copying a partial word. ✓

One nit: the else if (sizeAvailable > 0) case (partial word) can only happen when _memory.Length is not word-aligned — which shouldn't occur in normal EVM operation since memory is grown in 32-byte words. The fix still makes correctness more robust for any edge case. ✓

@manusw7

manusw7 commented Jun 18, 2026

Copy link
Copy Markdown
Contributor Author

@claude review again, I've added the SLOAD storage tracking missing test

@claude

claude Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Claude finished @manusw7's task in 1m 43s —— View job


Re-review: Align debug_trace* opcode (struct) tracer with execution-apis #762

Todo List

  • Load tools and setup
  • Read the updated test file for SLOAD tracking
  • Review the new test for correctness and completeness
  • Check if any other gaps remain
  • Post final verdict

SLOAD Test Assessment

The new Storage_snapshot_is_emitted_on_SLOAD test (lines 527–550 of GethLikeTxMemoryTracerTests.cs) correctly addresses the previously flagged Medium finding. Here's what it covers:

Scenario: SSTORE(slot 1 = 0x42) → SLOAD(slot 1) → STOP

In-memory tracer (GethLikeTxMemoryTracer):

  • Uses Single(e => e.Opcode == "SLOAD") — fails loudly if no SLOAD is in the trace.
  • Asserts Storage == {Word("1") → Word("42")} — correctly validates the 32-byte-padded cumulative snapshot on the SLOAD step.

Streaming tracer (GethLikeTxDirectStreamingTracer):

  • ToStorageDictionary calls entry.GetProperty("storage") which throws KeyNotFoundException if the storage key is absent from the JSON — so the test will fail loudly rather than silently pass if the streaming tracer doesn't emit storage for SLOAD.
  • Asserts the same snapshot as the in-memory tracer.

Cumulative snapshot correctness: After SSTORE, the address map has {slot_1 → 0x42}. The subsequent LoadOperationStorage call during SLOAD overwrites the same entry with the same value (the read-back value). The expected snapshot {Word("1") → Word("42")} is correct.

The test directly exercises the IsTracingStorageIsTracingOpLevelStorage gate fix in EvmInstructions.Storage.cs for the SLOAD path and pins both tracers to identical output.


Verdict

All findings from the first review are now resolved:

Severity Finding Status
Medium Missing SLOAD storage test ✅ Fixed — Storage_snapshot_is_emitted_on_SLOAD added
Low _pendingStorageMap is a live reference ✅ Documented invariant, EVM single-threaded
Low ZeroPaddedSpan no guard for value.Length > 32 ✅ Bounded by EVM word-size constraint
Low TraceMemory.ToHexWordList() partial-word note ✅ Latent correctness improvement, fine

The PR is mergeable. No Critical, High, or Medium findings remain.

@claude

claude Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Claude finished @manusw7's task in 6m 57s —— View job


PR Review: Align debug_trace* opcode (struct) tracer with execution-apis #762

Todo List

  • Load tools and setup
  • Read rules files (coding-style, robustness, test-infrastructure)
  • Read all changed files
  • Review EvmInstructions.Storage.cs (gate fix)
  • Review GethLikeTxDirectStreamingTracer.cs
  • Review GethLikeTxMemoryTracer.cs
  • Review TraceMemory.cs
  • Review GethLikeTxTrace.cs, GethTxTraceEntry.cs, GethTxMemoryTraceEntry.cs
  • Review GethLikeTxTraceJsonLinesConverter.cs
  • Review test coverage (GethLikeTxMemoryTracerTests, GethLikeTxBlockFileTracerTests, DebugRpcModuleTests)
  • Post findings

Summary

The PR correctly aligns both the streaming and in-memory Geth-style struct-log tracers with execution-apis #762. All five behavioral changes are correct and well-tested.

Core fix (EvmInstructions.Storage.cs): IsTracingStorageIsTracingOpLevelStorage in five places (TLOAD, TSTORE, SSTORE×2, SLOAD). GethLikeTxTracer sets IsTracingOpLevelStorage = !options.DisableStorage but never sets IsTracingStorage, so all four storage-class opcodes were silently suppressed before.

error omission (GethTxTraceEntry.cs): Removing [JsonIgnore(Condition = JsonIgnoreCondition.Never)] is the right mechanism — EthereumJsonSerializer already configures DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull globally, so the attribute was the only thing forcing null emission. Confirmed via test golden JSON in DebugRpcModuleTests.TraceTransaction.cs.

Storage semantics (both tracers): The old depth-stack (StoragesByDepth) is correctly replaced by a flat per-address map keyed by Address. This matches go-ethereum #34093: the snapshot is whole-tx cumulative, keyed by contract address, not call depth. The streaming tracer uses a live reference to _storageByAddress[address] (safe — single-threaded EVM, write finalized before FinalizePendingOpcode reads it).

Memory encoding (TraceMemory.cs): The ToHexWordList() fix — stackalloc hoisted outside loop, per-word 0x prefix, partial-word zero-pad path — is a latent correctness improvement that also enables the file-tracer's doubled-prefix fix.

File-tracer doubled-prefix (GethLikeTxTraceJsonLinesConverter.cs): .AsSpan(2) correctly strips the per-word 0x before appending to the "0x"-headed blob. Regression test added in GethLikeBlockFileTracerTests.Memory_field_is_a_single_0x_prefixed_blob.

Tests: Comprehensive. Both tracers pinned to identical output. SLOAD, SSTORE, cross-depth, cross-call accumulation all covered.


Findings

Low — GethLikeTxTraceConverterTests model data not updated

GethLikeTxTraceConverterTests.cs directly populates GethTxTraceEntry.Memory, .Stack, and .Storage with non-0x-prefixed strings (e.g. "5".PadLeft(64, '0')), while the production path now produces 0x-prefixed values from ToHexWordList(). The test is not wrong — the converter is agnostic to format and the roundtrip is correctly tested in isolation — but the expected JSON no longer matches what end-to-end tracing produces. Future readers may be misled about the wire format.

This is documentation-quality only; no test fails because the test bypasses ToHexWordList().

Low — Streaming tracer not exercised in the cross-depth address isolation test

Storage_is_cleared_and_restored_when_moving_between_call_levels (lines 297–367) validates the in-memory tracer but not the streaming tracer for the case where depth-1 writes to the sender's address and depth-2 writes to AddressC. The address-isolation property is implicitly covered by design (both tracers key by Address), and the streaming tracer IS tested in Storage_snapshot_accumulates_per_address_across_repeated_calls_to_same_contract, but there is no explicit cross-depth address-isolation pin for the streaming path.

Low — TLOAD/TSTORE transient storage not captured in struct logs (known limitation)

The EVM gate fix (IsTracingStorageIsTracingOpLevelStorage) now correctly invokes LoadOperationTransientStorage / SetOperationTransientStorage for TLOAD/TSTORE when IsTracingOpLevelStorage = true. However, neither GethLikeTxDirectStreamingTracer nor GethLikeTxMemoryTracer override these methods (the default is a no-op), so transient storage still doesn't appear in struct logs. This is consistent with the PR scope ("tracked as a follow-up in #12057") and is not a regression — the behavior was identical before the gate fix.


Verdict

All previously flagged findings (Medium — missing SLOAD test; three Low items) are resolved. The three new Low findings are documentation-quality gaps or explicitly out-of-scope limitations; none block correctness in production.

Severity Finding Status
Medium Missing SLOAD storage test ✅ Resolved — Storage_snapshot_is_emitted_on_SLOAD added
Low _pendingStorageMap live reference ✅ Safe — single-threaded EVM invariant
Low ZeroPaddedSpan no guard for value.Length > 32 ✅ Bounded by EVM word-size constraint
Low TraceMemory.ToHexWordList() partial-word nit ✅ Latent correctness fix, fine
Low GethLikeTxTraceConverterTests model data not 0x-prefixed New — doc-quality only, no test failure
Low Streaming tracer not tested in cross-depth scenario New — address isolation by design, low risk
Low TLOAD/TSTORE transient storage no-op New — explicit follow-up (#12057), not a regression

The PR is mergeable. No Critical, High, or Medium findings remain.

@AnkushinDaniil AnkushinDaniil left a comment

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.

👍

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.

The description says this pins the two tracers to identical output, but the test only compares the storage on SSTORE/SLOAD steps. Could you compare the full entry JSON here instead of just storage?

Comment thread src/Nethermind/Nethermind.Blockchain/Tracing/GethStyle/GethLikeTxMemoryTracer.cs Outdated
Comment thread src/Nethermind/Nethermind.Blockchain/Tracing/GethStyle/GethLikeTxMemoryTracer.cs Outdated
Comment thread src/Nethermind/Nethermind.Evm/Tracing/TraceMemory.cs Outdated
@manusw7

manusw7 commented Jun 20, 2026

Copy link
Copy Markdown
Contributor Author

@claude review again. Please verify that the latest commits correctly address all memory allocation and dictionary pooling issues raised by @LukaszRozmej in his changes-requested review.

@claude

claude Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Claude finished @manusw7's task in 4m 21s —— View job


Re-review: Allocation & Pooling Issues from @LukaszRozmej

Todo List

  • Load tools and setup
  • Read changed files addressing LukaszRozmej's concerns
  • Verify GethLikeTxDirectStreamingTracer.csAddressAsKey and dictionary pooling
  • Verify GethLikeTxMemoryTracer.cs — no string allocations
  • Verify GethLikeTxTraceJsonLinesConverter.cs — UTF-8/zero-alloc for memory and stack
  • Verify TraceMemory.cs — no string allocations
  • Check new MemoryHexConverter.cs, StackHexConverter.cs, StorageHexConverter.cs
  • Post findings

Summary

The latest commits address all of @LukaszRozmej's allocation and pooling concerns substantively. Here is the point-by-point verification:


@LukaszRozmej Finding Resolution

GethLikeTxDirectStreamingTracer.csAddressAsKey + dictionary pooling

✅ Addressed.

  • Line 57: Dictionary<AddressAsKey, PooledDictionary<UInt256, UInt256>> _storageByAddress — uses AddressAsKey (avoids per-lookup Address allocation) and PooledDictionary from Collections.Pooled (pools internal arrays).
  • Line 58: Stack<PooledDictionary<UInt256, UInt256>> _storageMapPool — instances are pooled across transactions. ResetForNextTx clears and returns each map to the pool; ReturnPooledBuffers disposes all of them on shutdown. This directly addresses the "maybe pool the dictionaries" suggestion.

GethLikeTxMemoryTracer.cs — "Don't allocate strings; use domain objects"

✅ Addressed.

  • _storageByAddress (line 17) is now Dictionary<AddressAsKey, Dictionary<UInt256, UInt256>> — keys and values are UInt256 structs, not hex strings.
  • RecordStorageSnapshot (line 65) stores new UInt256(value, isBigEndian: true) — no string allocation.
  • GethTxTraceEntry.Storage (line 41 of GethTxTraceEntry.cs) is now typed Dictionary<UInt256, UInt256>? with [JsonConverter(typeof(StorageHexConverter))] — hex formatting is deferred entirely to write time.

One remaining note: CurrentTraceEntry.Storage = new Dictionary<UInt256, UInt256>(contractStorage) (line 67) still creates a copy of the cumulative map on every SLOAD/SSTORE. This is semantically unavoidable for the in-memory tracer — each trace entry needs an immutable snapshot of the cumulative state at that point. The expensive part (hex string per entry) is gone; only the copy remains.

GethLikeTxMemoryTracer.cs — "VERY BAD CODE - allocates a ton!"

✅ Substantially addressed. The "very bad code" was the string-allocated storage representation. Domain objects (UInt256) replace all string allocations in the hot path.

GethLikeTxTraceJsonLinesConverter.cs — "Move to utf8-strings, without any allocations!" / "Avoid strings for stack too"

✅ Stack: addressed. Stack is now serialized via HexWriter.WriteUInt256HexRawValue directly to the Utf8JsonWriter — zero string allocation.

✅ Memory: addressed. WriteMemoryBlob uses ArrayPool<byte> for both the raw bytes buffer and the hex buffer — no string allocation.

⚠️ Low — gas/gasCost still use string interpolation. writer.WriteStringValue($"0x{value.Gas:x}") and writer.WriteStringValue($"0x{value.GasCost:x}") allocate one managed string each per opcode entry. LukaszRozmej asked for "no allocations" across this whole file. These two small allocations per entry are the only remaining ones. A stackalloc char[18] + TryFormat helper would eliminate them:

private static void WriteHexLong(Utf8JsonWriter writer, long v)
{
    Span<char> buf = stackalloc char[18]; // "0x" + 16 hex digits
    buf[0] = '0'; buf[1] = 'x';
    ((ulong)v).TryFormat(buf[2..], out int written, "x");
    writer.WriteStringValue(buf[..(2 + written)]);
}

This is a minor point — the critical allocations (memory blob, stack) are fixed. Up to you and @LukaszRozmej whether to address it before merge.

TraceMemory.cs — "Avoid allocating strings"

✅ Addressed. GethLikeTxTracer<TEntry>.SetOperationMemory now calls memoryTrace.ToWordArray() (returns UInt256[]) instead of ToHexWordList() (returns string[]). The Memory property is UInt256[]? with [JsonConverter(typeof(MemoryHexConverter))]. ToHexWordList() remains only for tests and StateTestTxTracer — not in the struct-log hot path.

New MemoryHexConverter.cs, StackHexConverter.cs, StorageHexConverter.cs

✅ Good design. All three converters defer hex encoding to serialization time, writing directly into the Utf8JsonWriter via HexWriter — zero per-entry string allocations at trace time.


Verdict

Concern Status
AddressAsKey for storage dictionary key ✅ Fixed
PooledDictionary + pool for streaming tracer ✅ Fixed
Domain objects instead of strings in GethLikeTxMemoryTracer ✅ Fixed
Domain objects instead of strings in memory/stack ✅ Fixed (UInt256[] + deferred converters)
File tracer memory blob — zero-allocation ✅ Fixed (ArrayPool)
File tracer stack — zero-allocation ✅ Fixed (HexWriter)
File tracer gas/gasCost — zero-allocation ⚠️ Low — 2 small strings/entry remain
TraceMemory.ToHexWordList() not called in hot path ✅ Fixed

No Critical, High, or Medium findings. The single Low finding (gas/gasCost string interpolation in the file tracer) is a minor residual that doesn't block merge.

The PR is mergeable.

@manusw7 manusw7 requested a review from LukaszRozmej June 21, 2026 12:42

@svlachakis svlachakis left a comment

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.

@claude check the following review

GethLikeTxMemoryTracer.RecordStorageSnapshot

This PR makes a per-step O(N²) storage copy go live on the default debug_trace* path.

This PR correctly flips the EVM gate to IsTracingOpLevelStorage (EvmInstructions.Storage.cs), which means op-level storage capture now actually fires for the struct tracer — on master it never did (IsTracingStorage is a stored flag that defaults to false and the geth tracer never set it, so the snapshot code was dead). Good fix.

The side effect: RecordStorageSnapshot now runs, and it does

CurrentTraceEntry.Storage = new Dictionary<UInt256, UInt256>(contractStorage);

i.e. it deep-copies the entire cumulative per-address map on every SLOAD/SSTORE. For a tx with N storage ops on one contract that's N copies of a map growing to N entries → O(N²) time and, worse, O(N²) retained peak memory (every entry holds its own full copy until the trace is serialized). With DisableStorage defaulting to false, this is the default debug_traceTransaction/debug_traceBlock behaviour on the in-memory (writer == null) path. That's a new OOM/CPU surface this PR introduces, not a pre-existing live cost.

The streaming tracer already does this the right way (one live PooledDictionary per address, written straight to the wire, O(N) memory, no per-step copy) — so the model exists in this PR; it's just the in-memory tracer that regresses.

Note the total output is irreducibly O(N²) bytes (geth emits the full cumulative map per storage step) — so the goal isn't to beat O(N²) work, it's to stop holding O(N²) in memory and stop allocating a dictionary per step.

Recommended fix (best perf/allocations, keeps the object model)

Don't snapshot per step. Keep one live cumulative map per address (exactly like the streaming tracer), record on each storage-touching entry only a cheap watermark into an append-only write-log, and materialize the snapshot lazily at serialization:

// tracer: single source of truth, O(1) zero-alloc capture
private readonly Dictionary<AddressAsKey, Dictionary<UInt256, UInt256>> _liveStorage = [];
private readonly List<(AddressAsKey addr, UInt256 key, UInt256 val)> _writeLog = [];

private void RecordStorageSnapshot(Address address, UInt256 index, ReadOnlySpan<byte> value)
{
    if (CurrentTraceEntry is null) return;

    UInt256 v = new(value, isBigEndian: true);
    _writeLog.Add((address, index, v));

    CurrentTraceEntry.SetStorageWatermark(address, _writeLog.Count); // address + int, no copy
}

Then GethTxTraceEntry.Storage becomes computed on demand (replay _writeLog for that address up to the watermark into a reused dict during serialization, write, discard).

Result:

  • Capture: O(1), zero allocation.
  • Peak memory: O(N²)O(N) retained (the write-log).
  • Serialization: still O(N²) bytes (intrinsic), but the per-step dictionaries are transient and GC'd immediately instead of all held at once.

This also removes the divergence between the two tracers' storage logic — the in-memory path ends up using the same single-live-map idea the streaming path already proves.

@svlachakis

Copy link
Copy Markdown
Contributor

@claude check my review above

@claude

claude Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

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


Review: @svlachakis's O(N²) Storage Copy Finding

Todo List

  • Load tools and setup
  • Read GethLikeTxMemoryTracer.cs — verify O(N²) copy claim
  • Read GethLikeTxDirectStreamingTracer.cs — understand the correct O(N) streaming model
  • Read GethTxTraceEntry.cs and StorageHexConverter.cs — understand serialization path
  • Assess whether the recommended write-log fix is complete and correct
  • Post findings

Verdict: Finding is correct — High severity, blocks merge

@svlachakis's analysis is accurate. Here is the verification:

The O(N²) root cause confirmed

GethLikeTxMemoryTracer.RecordStorageSnapshot (line 67):

CurrentTraceEntry.Storage = new Dictionary<UInt256, UInt256>(contractStorage);

For a transaction with N storage ops on one contract:

  • Op 1 → copy map of size 1 (1 entry allocated)
  • Op 2 → copy map of size 2 (3 entries total)
  • Op N → copy map of size N → N(N+1)/2 total entries

All N dictionary copies are held alive simultaneously in Trace.Entries[*].Storage until the full trace is serialized — O(N²) peak retained memory.

Concrete scale: A contract with 1,000 SSTOREs allocates ~500,500 UInt256 pairs → ~32 MB held in-memory per transaction trace. A debug_traceBlock multiplies this by transaction count. This is an OOM risk for storage-heavy transactions or blocks.

This is a new regression, not pre-existing

Before this PR, the EVM gate checked IsTracingStorage (which GethLikeTxTracer never set → always false) so RecordStorageSnapshot was dead code. The correct gate fix (IsTracingStorageIsTracingOpLevelStorage) in EvmInstructions.Storage.cs activates the snapshot path — but lands the O(N²) copy on the default hot path.

The streaming tracer already has the correct model

GethLikeTxDirectStreamingTracer.RecordStorage (lines 192–205) maintains one live PooledDictionary<UInt256, UInt256> per address and writes it straight to the wire in WriteStorageObjectIfPresent without any per-step copy — O(N) peak memory. The divergence between the two tracers' storage models is the bug.

Assessment of the recommended fix

@svlachakis's write-log + watermark approach is correct: O(1) capture per step, O(N) retained, O(N²) transient bytes only during serialization. A simpler alternative that avoids restructuring GethTxTraceEntry is to store only the per-step delta (the single (storageIndex, value) pair) per entry and lazily reconstruct the cumulative snapshot during serialization by scanning forward through entries. Either approach fixes the peak memory problem.


Severity Summary

Severity Finding Status
High GethLikeTxMemoryTracer deep-copies cumulative storage map per step → O(N²) peak memory on default debug_trace* path ❌ Unresolved — blocks merge

All previous findings (from prior review rounds) remain resolved. This High finding is new and was not flagged in earlier reviews. It should be addressed before merge.


CurrentTraceEntry.Storage = new Dictionary<string, string>(previousTraceEntry.Storage);
}
CurrentTraceEntry.Storage = new Dictionary<UInt256, UInt256>(contractStorage);

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.

High — O(N²) allocations and peak memory on the default debug_trace* path

@svlachakis's finding is correct. For a transaction with N storage ops on one contract:

Step Dictionary copy allocated Entries copied
1 size 1 1
2 size 2 1+2 = 3
N size N N(N+1)/2

All N copies are held alive simultaneously in Trace.Entries[*].Storage until the full trace is serialized — O(N²) peak retained memory. With DisableStorage defaulting to false, this is the default debug_traceTransaction/debug_traceBlock path.

Concrete scale example: a contract executing 1,000 SSTOREs → ~500K UInt256 pairs allocated → ~32 MB held in memory per transaction trace. A debug_traceBlock on a storage-heavy block multiplies this by transaction count. This is an OOM risk in production.

This is a new regression, not a pre-existing cost: before this PR the EVM gate checked IsTracingStorage (always false for GethLikeTxTracer), so RecordStorageSnapshot was effectively dead. The gate fix in EvmInstructions.Storage.cs correctly activates it, but this copy-on-every-step pattern now fires on the hot path.

The streaming tracer already demonstrates the correct model — one live PooledDictionary<UInt256, UInt256> per address, written straight to the wire in WriteStorageObjectIfPresent, O(N) peak memory. The same principle should apply here.

@svlachakis's recommended write-log + watermark fix is correct and aligns both tracers conceptually. A simpler alternative that achieves O(N) peak memory without restructuring GethTxTraceEntry is to store the per-entry delta (the single (storageIndex, value) pair that fired this step's snapshot) and reconstruct each entry's cumulative snapshot lazily during serialization by scanning forward through the entry list. Either approach avoids holding N separate dictionary copies simultaneously.

Fix this →

manusw7 and others added 3 commits June 22, 2026 14:51
…forward-pass serializer

During tracing each SLOAD/SSTORE records only a (address, key, value) delta on
the entry — no dict copy. GethLikeTxTraceConverter.WriteEntriesWithStorageForwardPass
maintains one running cumulative dict per address; at serialization it sets
entry.Storage transiently for the duration of JsonSerializer.Serialize, then clears it.
Only one dict per address is ever live at a time, reducing peak retained memory
from O(N²) to O(N) for the in-memory tracer path.
@manusw7

manusw7 commented Jun 22, 2026

Copy link
Copy Markdown
Contributor Author

@claude please review again, check the solution for the O(N²) Storage Copy Finding

@claude

claude Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Claude finished @manusw7's task in 3m 54s —— View job


Re-review: O(N²) Storage Copy Fix

Todo List

  • Load tools and setup
  • Read current GethLikeTxMemoryTracer.cs — verify O(N²) fix
  • Read GethTxTraceEntry.cs — check storage field type changes
  • Read GethLikeTxTraceConverter.cs — check lazy forward-pass serialization
  • Read StorageHexConverter.cs — check write-time formatting
  • Read updated tests — verify correctness
  • Post final verdict

O(N²) Fix — Verified Correct

The High finding raised by @svlachakis is resolved. Here's the full analysis:

What changed

GethLikeTxMemoryTracer.RecordStorageSnapshot (line 57) now stores only a lightweight delta tuple instead of a deep-copied dictionary:

// Before: deep-copy of growing cumulative map on every SLOAD/SSTORE → O(N²) retained memory
CurrentTraceEntry.Storage = new Dictionary<UInt256, UInt256>(contractStorage);

// After: O(1) capture, no allocation beyond the tuple
CurrentTraceEntry.StorageDelta = (address, storageIndex, new UInt256(value, isBigEndian: true));

GethLikeTxTraceConverter.WriteEntriesWithStorageForwardPass (new, lines 124–148) materializes the cumulative snapshot lazily during serialization via a single forward pass:

Dictionary<AddressAsKey, Dictionary<UInt256, UInt256>> runningByAddress = [];
foreach (GethTxTraceEntry entry in entries)
{
    if (entry.StorageDelta is { } delta)
    {
        // update the single live map per address
        map[delta.Key] = delta.Value;
        entry.Storage = map;       // borrow the live map temporarily
        JsonSerializer.Serialize(writer, entry, options);
        entry.Storage = null;      // release after writing
    }
    else { ... }
}

Memory complexity — confirmed O(N) retained

Phase Before After
Capture per step O(N) — full dict copy O(1) — tuple only
Peak retained memory O(N²) — all N copies alive simultaneously O(N) — N delta tuples + one live map per address
Serialization allocation N separate dicts held live One dict per address, mutated in-place, then dropped
Total bytes written O(N²) — unavoidable by spec O(N²) — same

This is exactly the approach @svlachakis recommended (write-log + lazy materialization), and it mirrors what the streaming tracer already did.

Correctness checks

  • Multiple serializations: re-entrant safe — the forward pass re-runs from scratch each time, entry.Storage = null cleanup prevents stale references. ✅
  • Multi-address scenario: runningByAddress tracks one cumulative map per contract address; each SLOAD/SSTORE step only exposes the touched contract's map, matching execution-apis Receipts are overwritten on reorgs #762. ✅
  • GethLikeTxTraceConverterTests: entries with .Storage set directly (no StorageDelta) fall through to the else branch and serialize correctly via StorageHexConverter. ✅
  • Direct entry.Storage access after tracing: is now always null outside of serialization. Tests have been correctly updated: Storage_is_cleared_and_restored_when_moving_between_call_levels asserts Is.Null on non-storage entries directly and verifies storage content for storage-touching entries via JSON round-trip. ✅
  • Storage_snapshot_is_emitted_on_SLOAD: defers to AssertStreamingMatchesInMemory, which compares full JSON entry-by-entry — will fail loudly if the in-memory tracer's SLOAD step lacks a storage field. ✅

One nit (non-blocking)

map?[delta.Key] = delta.Value on line 137 — the ?. is unreachable since map was just assigned [] in the branch above. Inline comment posted.


Verdict

Severity Finding Status
High O(N²) retained memory: deep-copy of cumulative storage map per SLOAD/SSTORE step Fixed — delta stored O(1), snapshot materialized lazily O(N) at serialization
Medium Missing SLOAD storage test ✅ Resolved (previous round)
Low _pendingStorageMap live reference in streaming tracer ✅ Safe — single-threaded EVM
Low ZeroPaddedSpan no guard for value.Length > 32 ✅ EVM word-size invariant
Low GethLikeTxTraceConverterTests model data format note ✅ Converter handles both StorageDelta and direct .Storage correctly
Low Streaming tracer not tested in cross-depth scenario ✅ Address isolation by design
Low TLOAD/TSTORE transient storage no-op ✅ Explicit follow-up (#12057)
Nit map? null check unreachable in WriteEntriesWithStorageForwardPass Inline comment posted

No Critical, High, or Medium findings remain. The PR is mergeable.

…eTxTraceConverter.cs

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
@manusw7 manusw7 requested a review from svlachakis June 22, 2026 13:13
Integrates PR #12081 (refund counter tracking) from master while
preserving the O(N) forward-pass storage serialiser (Solution B).

Key resolutions:
- GethTxTraceEntry: add Refund property, keep UInt256[] Stack
- GethLikeTxMemoryTracer: keep StorageDelta delta approach, add lean
  StartOperation override that captures Refund before each opcode
- GethLikeTxDirectStreamingTracer: reset _refund/_refundCheckpoints on
  MarkAsSuccess/MarkAsFailed; write refund field; keep error-only-when-
  not-null for JSON parity with the in-memory tracer
- GethLikeTxMemoryTracerTests: keep all tests from both branches
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.

Align debug_trace* opcode tracer with execution-apis#762 spec

4 participants