Skip to content

Witness generation fixes when comparing to Reth#3

Draft
jsign wants to merge 13 commits into
jsign-temp-1from
jsign-debug-witness
Draft

Witness generation fixes when comparing to Reth#3
jsign wants to merge 13 commits into
jsign-temp-1from
jsign-debug-witness

Conversation

@jsign
Copy link
Copy Markdown
Owner

@jsign jsign commented Feb 17, 2026

This PR contains fixes to the witness generation spec:

  • The code incorrectly tracked MPT nodes while mutating the MPT nodes in post-state root calculation. While this is correct to detect sibilings in branch compressions, is not a generally correct approach, since these passes will track transient MPT node states. Both read and writes storage-slots accessed must be walked before any post-state root mutation. The post-state root procedure should only focus on potential sibilings required for branch compressions.
  • Bytecode created during block execution was included in the witness, which is not correct. This happened since the state tracker tracks all used bytecode. This was solved by only including bytecodes from the state tracker that existed in the pre-state.
  • Add a dirty field into IncrementalMPT nodes to track which ones were created or edited during post-state root calculation. This is a subtle need to correctly add sibiling nodes during branch compressions. Before the fix, we always included these sibiling nodes. This is not correct, since we must only include sibiling nodes if and only if they existed in the pre-state trie. If they were created during post-state root execution (say, in a previous storage slot update) then they are “runtime created nodes” that the stateless execution will have thus not require in the witness.
  • Many EIP-7702 cases of code access didn’t track delegation as required bytecodes in the witness.
  • We were including account accesses for all withdrawals. This was changed to only do it if the amount is not zero (which is a border case).
  • For SELFDESTRUCT and 7702 cases, we need more aggressive short-circuiting of state access during gas charging logic. This is a quite subtle requirement, since we must avoid any unrequired state access for gas charging that might surface in OOG situations. During an OOG situation, any unrequired state access will surface as a bloated execution witness. Without execution witnesses involved, these details aren’t important for a spec implementation, but production ELs do short-circuiting since they are technically DoS vectors (i.e., you shouldn’t do state accesses without the guarantee they will be paid).

Apart from spec fixes, I added two new tests that are very simple but cover two corner cases that wasn’t detected (by pure chance) in the ~9k existing tests:

  • Add a minimal test that surfaces the situation where a branch compression can be avoided by applying the requested rule: “do insertions/updatings first and deletions after”. This surfaces if an EL is not satisfying this, since it will include more MPT nodes than needed (i.e. a sibling). This test surfaced that Reth wasn’t applying this rule.
  • Create another situation where a branch compression indeed applies when implementing the correct ordering for post-state root calculation. But the detail here is that the sibling during branch compression was created during block execution — this means this sibling must not be included in the witness, since it didn’t exist in the pre-state. This catches if ELs are not correctly considering this important detail.

I'm tempted to add more tests, but trying to keep that away from this PR and can keep stacking on top in separate ones.

Note: all these bugs were found by running the 9k tests with spec execution witness against Reth. Reth would run the test in stateful mode, generate its own witness and compare it against the fixture execution witness. Reth also had other bugs that the spec helped to find, but those live in other PRs.

Note: I keep this as a separate PR on top of #1 to help have a timeline on how things happened, and also keep #1 frozen since I know some people in STEEL already review it.

msg_data = tx.data
code = get_account(block_env.state, tx.to).code
track_bytecode_access(block_env.state, code)
track_bytecode_access(block_env.state, code, tx.to)
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

track_bytecode_access now checks if the provided address in the new parameter existed in the pre-state of the block. If it doesn't exist, this bytecode won't be in the witness since the bytecode was generated during block execution.

Probably with the official state tracker we might approach it differently -- just explaining the code change here.

(
disable_precompiles,
code_address,
code,
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This was the most tricky thing to fix in the spec, which is related to the point I mentioned in the PR description about avoiding bloating the execution witness in OOG situations.

The access_delegation function always did an account access and bytecode fetching before charging gas for a potential CALL/CALLCODE/DELEGATECALL/STATICCALL.

While this is nice to simplify things in the spec, revm is way more optimzed to avoid this unrequired state access before it is sure it can charge a potential COLD_ACCCESS. If you don't have enough gas for that charge, in theory you shouldn't have done the db access since is a DoS vector.

This means the spec must do the same, since if you still do it (apart that is a DoS vector) it will make the state tracker include it in the witness.

Most of this file changes are consequences of avoiding this access in access_delegation, and leaving that db access after the gas charging has passed -- done inside the generic_call which is "as late as possible" when the account data is needed (instead of "sooner than needed" which created this problem).

evm.accessed_addresses.add(beneficiary)
gas_cost += GAS_COLD_ACCOUNT_ACCESS

charge_gas(evm, gas_cost)
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

We need to charge gas in parts here. Because in L573 we do an account access. If you already didn't have gas to reach that point, that state access must be avoided to avoid bloating the witness.


charge_gas(evm, gas_cost)

if evm.message.is_static:
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

We must move this check as soon as possible too -- if you do SELFDESTRUCT in a STATICCALL context you must fail since if the value isn't zero, this isn't allowed.

This is also done to avoid the bloating that is_account_alive / get_account in L574 would do if we don't fail fast enough.

if authority_code and not is_valid_delegation(authority_code):
continue
if authority_code:
track_bytecode_access(state, authority_code, authority)
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This tracking of bytecode access in 7702 was missing.

)

modify_state(block_env.state, wd.address, increase_recipient_balance)
if wd.amount != 0:
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

If the withdrawal is for 0 ETH, then we avoid this call to avoid bloating the witness. The stateles validator can skip this withdrawal if won't modify the state, thus the witness data isn't required.

return state._witness_state is not None


def _debug_print_witness_state(ws: WitnessState) -> None:
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This is just a helper that I needed to debug all things -- we can remove it eventually.

Comment on lines +959 to +966
# 1. Traverse all accessed and dirty storage keys on the pre-state
# MPTs to capture pre-state trie nodes in the witness. This must
# happen before any writes since writes mutate the tree in-place.
all_storage_reads: Dict[Address, Set[Bytes32]] = {}
for address, keys in ws.accessed_storage.items():
all_storage_reads.setdefault(address, set()).update(keys)
for address, keys in ws.dirty_storage.items():
all_storage_reads.setdefault(address, set()).update(keys)
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This is related to the first bullet I mentioned in the PR description.

Any pre-state MPT proof should be captured before any state tree change. If we don't do this, we might capture "transient MPT nodes" that we edited while post-state root calculation.

value: Bytes
_hash: Optional[Bytes] = None # Cached hash, invalidated on change
_rlp: Optional[Bytes] = None # Cached RLP encoding
_dirty: bool = False # True if created during execution (not pre-state)
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

As mentioned in the PR description, this is a new boolena marker for all types of nodes in the MPT tree.

This is required, since whenever we capture potential sibilings in branch compressions we must be sure they existed in the pre-state tree. If they were edited or created during post-state root calculation, they must not be in the witness.

@@ -0,0 +1,132 @@
"""
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

I think these tests are a bit self describing.

@jsign jsign changed the title Jsign debug witness Witness generation fixes when comparing to Reth Feb 17, 2026

Returns
-------
delegation : `Tuple[bool, Address, Bytes, Uint]`
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

small nit: Tuple[bool, Address, Uint]

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.

2 participants