Skip to content

Fixes #10115 - 10 : storage snapshot timing #10176

Open
sagarkhandagre998 wants to merge 4 commits intobesu-eth:mainfrom
sagarkhandagre998:issue-10115-10
Open

Fixes #10115 - 10 : storage snapshot timing #10176
sagarkhandagre998 wants to merge 4 commits intobesu-eth:mainfrom
sagarkhandagre998:issue-10115-10

Conversation

@sagarkhandagre998
Copy link
Copy Markdown

@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)

Fixes #10115

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?

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>
@sagarkhandagre998
Copy link
Copy Markdown
Author

@macfarla Kindly take a look at once.

Signed-off-by: Sagar Khandagre <sagar.khandagre998@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.

debug RPC endpoints standardization

1 participant