Skip to content

DebugOperationTracer #10115 - 10 : storage snapshot timing #10176

Merged
macfarla merged 8 commits into
besu-eth:mainfrom
sagarkhandagre998:issue-10115-10
May 12, 2026
Merged

DebugOperationTracer #10115 - 10 : storage snapshot timing #10176
macfarla merged 8 commits into
besu-eth:mainfrom
sagarkhandagre998:issue-10115-10

Conversation

@sagarkhandagre998
Copy link
Copy Markdown
Contributor

@sagarkhandagre998 sagarkhandagre998 commented Apr 3, 2026

PR description

Problem

The storage field in debug trace struct logs was being emitted on every opcode, not just storage-touching ones. The root cause was in DebugOperationTracer.captureStorage():

getUpdatedStorage()returns "all storage slots written so far in the transaction" — it accumulates across opcodes. So once anySSTORE` executed, every subsequent frame (ADD, PUSH, JUMP, etc.) included the full dirty-storage map. This diverges from geth and the execution-apis spec, as highlighted in the ethPandaOps trace comparison report.

Approach & Reasoning

The spec says: emit storage only for SLOAD and SSTORE, showing only the single slot touched by that operation.

For SSTORE this was straightforward — SStoreOperation already calls frame.storageWasUpdated(key, newValue) on success, which sets frame.getMaybeUpdatedStorage(). That's exactly the one slot we need. No extra tracking required.

For SLOAD it was trickier. SLoadOperation doesn't call storageWasUpdated (it's a read, not a write), so getMaybeUpdatedStorage() is always empty for it. Two pieces of data are needed: the slot key and the loaded value.

  • The key sits at the top of the stack before execution (SLOAD pops it). So it must be captured in tracePreExecution() before the opcode runs — introducing the preExecutionStorageKey field.
  • The loaded value is pushed to the top of the stack after execution, so frame.getStackItem(0) in tracePostExecution() gives it directly.

This approach deliberately avoids depending on options.traceStack() being enabled — the key is captured independently of stack tracing, so storage capture works correctly regardless of what other trace flags the caller set.

One edge case considered: if SLOAD halts (e.g. OOG), the value was never pushed, so the stack top would be wrong. This is guarded by checking operationResult.getHaltReason() == null before reading the stack — matching what geth does (no storage entry on a halted SLOAD).

Tests

All 21 tests in DebugOperationTracerTest pass, including the 3 new ones:

  • shouldRecordStorageForSstoreWhenEnabled — verifies single-slot map with correct key/value
  • shouldRecordStorageForSloadWhenEnabled — verifies key captured pre-execution, value captured post-execution
  • shouldNotRecordStorageForNonStorageOpcodeWhenEnabled — verifies MUL (and any non-storage opcode) emits empty storage even when `traceStorage=true

Fixed Issue(s)

Refs #10115 - 10 : storage snapshot timing

Thanks for sending a pull request! Have you done the following?

  • Checked out our contribution guidelines?
  • Considered documentation and added the doc-change-required label to this PR if updates are required.
  • Considered the changelog and included an update if required.
  • For database changes (e.g. KeyValueSegmentIdentifier) considered compatibility and performed forwards and backwards compatibility tests

Locally, you can run these tests to catch failures early:

  • spotless: ./gradlew spotlessApply
  • unit tests: ./gradlew build
  • acceptance tests: ./gradlew acceptanceTest
  • integration tests: ./gradlew integrationTest
  • reference tests: ./gradlew ethereum:referenceTests:referenceTests
  • hive tests: Engine or other RPCs modified?

@sagarkhandagre998
Copy link
Copy Markdown
Contributor Author

@macfarla Kindly take a look at once.

@sagarkhandagre998
Copy link
Copy Markdown
Contributor Author

@jframe

@macfarla macfarla changed the title Fixes #10115 - 10 : storage snapshot timing DebugOperationTracer #10115 - 10 : storage snapshot timing Apr 21, 2026
Copy link
Copy Markdown
Contributor

@macfarla macfarla left a comment

Choose a reason for hiding this comment

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

Critical Bug: Same pc/depth = 0 Regression as PR #10173

This PR has the same structural problem. It overrides tracePreExecution directly without calling super, and adds private fields that shadow the parent's protected ones:

// Added in child — shadows parent's protected fields:
private Optional<Bytes[]> preExecutionStack;
private long gasRemaining;

The parent's tracePreExecution (which is now bypassed) is the only place that sets pc and depth:
// AbstractDebugOperationTracer — never called anymore:
pc = frame.getPC();
depth = frame.getDepth();

The child's tracePreExecution sets preExecutionStack, gasRemaining, and inputData — but not pc or depth. Result: every trace frame reports pc=0 and depth=0. The test shouldRecordProgramCounter would catch this.

The fix is the same as for #10173: keep overriding capturePreExecutionState for inputData capture, and add a separate tracePreExecution override that calls super first then adds the SLOAD key capture:

@OverRide
public void tracePreExecution(final MessageFrame frame) {
super.tracePreExecution(frame); // sets pc, depth, traceOpcode, preExecutionStack, gasRemaining
if (traceOpcode && options.traceStorage()
&& "SLOAD".equals(frame.getCurrentOperation().getName())
&& frame.stackSize() > 0) {
preExecutionStorageKey = Optional.of(UInt256.fromBytes(frame.getStackItem(0)));
} else {
preExecutionStorageKey = Optional.empty();
}
}

@OverRide
protected void capturePreExecutionState(final MessageFrame frame) {
if (lastFrame != null && frame.getDepth() > lastFrame.getDepth())
inputData = frame.getInputData().copy();
else inputData = frame.getInputData();
}

@macfarla macfarla marked this pull request as draft April 21, 2026 23:23
@sagarkhandagre998 sagarkhandagre998 force-pushed the issue-10115-10 branch 2 times, most recently from a2d6fe8 to 26a4826 Compare April 22, 2026 04:02
@sagarkhandagre998 sagarkhandagre998 marked this pull request as ready for review April 22, 2026 04:03
@sagarkhandagre998
Copy link
Copy Markdown
Contributor Author

@macfarla please take a look .

Copy link
Copy Markdown
Contributor

@macfarla macfarla left a comment

Choose a reason for hiding this comment

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

added some comments.

preExecutionStorageKey not reset in reset()

private Optional preExecutionStorageKey = Optional.empty();

This field is not cleared in reset(). If a tracer instance is reused across transactions and tracePreExecution isn't called before captureStorage (e.g. due to an opcode filter dropping the SLOAD pre-execution call), a stale key from the previous transaction could bleed in. Add preExecutionStorageKey = Optional.empty(); to reset().

Copy link
Copy Markdown
Contributor

@macfarla macfarla left a comment

Choose a reason for hiding this comment

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

ok @sagarkhandagre998 this is looking pretty good now. can you add a changelog entry and then we can run the final checks

@macfarla macfarla dismissed their stale review May 1, 2026 01:02

the critical bug has been resolved

@sagarkhandagre998
Copy link
Copy Markdown
Contributor Author

ok @sagarkhandagre998 this is looking pretty good now. can you add a changelog entry and then we can run the final checks

Done

Copy link
Copy Markdown
Contributor

@macfarla macfarla left a comment

Choose a reason for hiding this comment

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

changelog entry is for a different PR

Comment thread CHANGELOG.md Outdated
Copy link
Copy Markdown
Contributor

@macfarla macfarla left a comment

Choose a reason for hiding this comment

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

changelog entry needs adjusting

Comment thread CHANGELOG.md Outdated
@sagarkhandagre998
Copy link
Copy Markdown
Contributor Author

@macfarla Done , we can run the final checks

Copy link
Copy Markdown
Contributor

@macfarla macfarla left a comment

Choose a reason for hiding this comment

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

@sagarkhandagre998 there's a related integration test failing. Please confirm you have run these tests locally BEFORE you ask for review.

TraceTransactionIntegrationTest > shouldTraceSStoreOperation() FAILED
java.util.NoSuchElementException at TraceTransactionIntegrationTest.java:288

Per execution-apis spec (PR besu-eth#762), the storage field in debug trace
struct logs must only be emitted for SLOAD and SSTORE opcodes, showing
only the single slot touched by that operation.

Previously, captureStorage() was called on every opcode and returned
the full accumulated dirty-storage map from getUpdatedStorage(), so
every frame after the first SSTORE would include all previously written
slots regardless of opcode.

Changes:
- captureStorage() now returns Optional.empty() for all opcodes except
  SLOAD and SSTORE
- SSTORE: uses frame.getMaybeUpdatedStorage() (set by SStoreOperation
  via frame.storageWasUpdated()) to return only the written slot
- SLOAD: captures the slot key in tracePreExecution() before the opcode
  pops it off the stack, then reads the loaded value from the stack top
  in tracePostExecution()
- Removed ModificationNotAllowedException import (no longer used)

Tests:
- shouldRecordStorageWhenEnabled -> split into three focused tests:
  shouldRecordStorageForSstoreWhenEnabled,
  shouldRecordStorageForSloadWhenEnabled,
  shouldNotRecordStorageForNonStorageOpcodeWhenEnabled
- shouldCaptureFrameWhenExceptionalHaltOccurs: storage is now empty for
  a non-storage opcode halt (MUL), assertion updated accordingly

Signed-off-by: Sagar Khandagre <sagar.khandagre998@gmail.com>
Per execution-apis spec (PR besu-eth#762), the storage field is only populated
for SLOAD and SSTORE opcodes and contains a single-entry map for the
slot touched. Update the getter and builder setter Javadoc accordingly.

Signed-off-by: Sagar Khandagre <sagar.khandagre998@gmail.com>
…raceFrame

Signed-off-by: Sagar Khandagre <sagar.khandagre998@gmail.com>
Signed-off-by: Sagar Khandagre <sagar.khandagre998@gmail.com>
Signed-off-by: Sagar Khandagre <sagar.khandagre998@gmail.com>
Signed-off-by: Sagar Khandagre <sagar.khandagre998@gmail.com>
Signed-off-by: Sagar Khandagre <sagar.khandagre998@gmail.com>
@sagarkhandagre998
Copy link
Copy Markdown
Contributor Author

@sagarkhandagre998 there's a related integration test failing. Please confirm you have run these tests locally BEFORE you ask for review.

TraceTransactionIntegrationTest > shouldTraceSStoreOperation() FAILED java.util.NoSuchElementException at TraceTransactionIntegrationTest.java:288

@sagarkhandagre998 there's a related integration test failing. Please confirm you have run these tests locally BEFORE you ask for review.

TraceTransactionIntegrationTest > shouldTraceSStoreOperation() FAILED java.util.NoSuchElementException at TraceTransactionIntegrationTest.java:288

Sorry for that, I didn't expect the integration failure.

…E-only semantics

TraceTransactionIntegrationTest expected storage to persist across
non-storage frames (PUSH2, DUP6), which matched the old broken behaviour
captureStorage() fixed in fa1c647.

Update assertions so DUP6 and PUSH2 frames assert storage is empty, and
only the SSTORE frame asserts the single touched slot.

Signed-off-by: Sagar Khandagre <sagar.khandagre998@gmail.com>
@sagarkhandagre998
Copy link
Copy Markdown
Contributor Author

Both build passes locally

./gradlew integrationTest
./gradlew build

@macfarla macfarla dismissed their stale review May 12, 2026 07:46

PR is now passing checks

Copy link
Copy Markdown
Contributor

@macfarla macfarla left a comment

Choose a reason for hiding this comment

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

thanks for your contribution, this is the last piece for #10115

@macfarla macfarla merged commit 3ad7bb5 into besu-eth:main May 12, 2026
34 checks passed
@sagarkhandagre998
Copy link
Copy Markdown
Contributor Author

thanks for your contribution, this is the last piece for #10115

You're Welcome

abhay-dev2901 pushed a commit to abhay-dev2901/besu that referenced this pull request May 14, 2026
…esu-eth#10176)

* Fix storage snapshot timing in DebugOperationTracer

Per execution-apis spec (PR besu-eth#762), the storage field in debug trace
struct logs must only be emitted for SLOAD and SSTORE opcodes, showing
only the single slot touched by that operation.

Previously, captureStorage() was called on every opcode and returned
the full accumulated dirty-storage map from getUpdatedStorage(), so
every frame after the first SSTORE would include all previously written
slots regardless of opcode.

Changes:
- captureStorage() now returns Optional.empty() for all opcodes except
  SLOAD and SSTORE
- SSTORE: uses frame.getMaybeUpdatedStorage() (set by SStoreOperation
  via frame.storageWasUpdated()) to return only the written slot
- SLOAD: captures the slot key in tracePreExecution() before the opcode
  pops it off the stack, then reads the loaded value from the stack top
  in tracePostExecution()
- Removed ModificationNotAllowedException import (no longer used)

Tests:
- shouldRecordStorageWhenEnabled -> split into three focused tests:
  shouldRecordStorageForSstoreWhenEnabled,
  shouldRecordStorageForSloadWhenEnabled,
  shouldNotRecordStorageForNonStorageOpcodeWhenEnabled
- shouldCaptureFrameWhenExceptionalHaltOccurs: storage is now empty for
  a non-storage opcode halt (MUL), assertion updated accordingly

Signed-off-by: Sagar Khandagre <sagar.khandagre998@gmail.com>

* Update TraceFrame storage Javadoc to reflect SLOAD/SSTORE-only semantics

Per execution-apis spec (PR besu-eth#762), the storage field is only populated
for SLOAD and SSTORE opcodes and contains a single-entry map for the
slot touched. Update the getter and builder setter Javadoc accordingly.

Signed-off-by: Sagar Khandagre <sagar.khandagre998@gmail.com>

---------

Signed-off-by: Sagar Khandagre <sagar.khandagre998@gmail.com>
Signed-off-by: abhay-dev2901 <abhaytp1998@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants