Skip to content

fix(state): honour SELFDESTRUCT marker in PerContractState.StorageRoot#11424

Closed
stdevMac wants to merge 1 commit into
release/1.37.1from
fix/selfdestruct-create-collision-same-block
Closed

fix(state): honour SELFDESTRUCT marker in PerContractState.StorageRoot#11424
stdevMac wants to merge 1 commit into
release/1.37.1from
fix/selfdestruct-create-collision-same-block

Conversation

@stdevMac
Copy link
Copy Markdown
Member

Changes

  • PerContractState.StorageRoot now short-circuits to Keccak.EmptyTreeHash when the same-block SELFDESTRUCT marker (BlockChange.HasClear) is set — mirroring the existing short-circuit in PerContractState.IsEmpty. One added line in Nethermind.State/PersistentStorageProvider.cs.
  • New unit test SelfDestructPrewarmerStateTests.IsNonZeroAccount_via_IWorldState_default_returns_false_after_destroy_and_value_transfer reproduces the bug at the state layer (fails on parent, passes with this fix).
  • Two supporting tests in SelfDestructPrewarmerStateTests and two block-level integration tests in SelfDestructCreate2SameBlockTests lock in correctness for the same-block SELFDESTRUCT-then-redeploy pattern with both two-tx and three-tx (with intermediate value transfer) variants.

Symptom

Sepolia archive node running 1.37.1 deterministically rejects block 4913057 with HeaderGasUsedMismatch: Expected 9922769, got 9916180 (Δ = 6589 gas, exactly one EIP-684 collision revert with refund), and stops processing further blocks. The failing transaction is the metamorphic-contract redeploy at index 0x3a in the block:

to:    0x030a5714a5cb1ea7d98b4b978e1379c2b8805d25  (0age metamorphic factory)
data:  deployMetamorphicContractWithConstructor(salt, initCode)

debug_traceCall reproduces the failure when run against parent state (i.e. without applying tx 0x2d's SELFDESTRUCT first), proving the EVM is correct in isolation and that block-level state propagation between the destroy and the redeploy is broken.

Root cause

The EVM CREATE collision check is state.IsNonZeroAccount(X). ParallelWorldState (the production decorator over WorldState, registered at BlockProcessingModule.cs:56-57) does not override IsNonZeroAccount, so the default interface method on IWorldState runs. That default calls IsStorageEmpty(X), also unoverridden in ParallelWorldState, so it routes to the default in IAccountStateProvider:

TryGetAccount(address, out AccountStruct account);
return account.IsStorageEmpty;

TryGetAccount lands on WorldState.IAccountStateProvider.TryGetAccount, which builds the AccountStruct via:

account = _stateProvider.GetAccount(address)
    .WithChangedStorageRoot(_persistentStorageProvider.GetStorageRoot(address))
    .ToStruct();

PerContractState.StorageRoot calls EnsureStorageTreeCreateStorageTree_provider._currentScope.CreateStorageTree(_address)LookupStorageTreeGet(X)?.StorageRoot. Get(X) is TrieStoreWorldStateBackendScope.Get, which serves from _loadedAccounts. That cache was populated when the EVM first read X earlier in the block (e.g. to load X's code for tx 0x2d's SELFDESTRUCT call), and it holds the parent (pre-destroy) account with a non-empty StorageRoot.

Result: GetStorageRoot returns the parent's non-empty StorageRoot, WithChangedStorageRoot stuffs it into the AccountStruct, account.IsStorageEmpty is _storageRoot == EmptyTreeHash so returns false, IsNonZeroAccount short-circuits to true, the inner CREATE inside the metamorphic transient hits a phantom EIP-684/EIP-7610 collision, the factory's require(deployed != 0) reverts, and the block ends with HeaderGasUsedMismatch.

PerContractState.IsEmpty already short-circuits on BlockChange.HasClear. PerContractState.StorageRoot did not. This PR adds the same short-circuit, so any consumer (including the IAccountStateProvider.TryGetAccount path) sees EmptyTreeHash while the SELFDESTRUCT marker is in effect.

The fix is a one-line short-circuit at the boundary that matches the existing IsEmpty invariant; no behavioural change for code paths that don't go through TryGetAccount/StorageRoot.

Types of changes

  • Bugfix (a non-breaking change that fixes an issue)

Testing

Requires testing

  • Yes

If yes, did you write tests?

  • Yes

Notes on testing

  • New regression test: SelfDestructPrewarmerStateTests.IsNonZeroAccount_via_IWorldState_default_returns_false_after_destroy_and_value_transfer — exercises ParallelWorldState wrapping WorldState, primes _loadedAccounts the way the EVM does, then performs the collision check via the IWorldState default interface method. Fails on the parent commit and passes with this fix.
  • Supporting tests: Main_scope_sees_destroyed_account_even_with_prewarmed_cache and Main_scope_sees_recreated_account_as_collision_free_after_value_transfer pin cache-layer correctness.
  • Block-level: SelfDestructCreate2SameBlockTests (two test cases) drives the full BlockProcessor + BranchProcessor + prewarming flow through BasicTestBlockchain for both two-tx and three-tx (with intermediate value transfer) variants.

Existing suites still pass with the fix:

Suite Total Failed
Nethermind.State.Test 707 0
Nethermind.Blockchain.Test 1426 0
Nethermind.Evm.Test (SELFDESTRUCT/CREATE/Eip7708 selectors) 32 0

Verification on an affected node

Confirmed on a live Sepolia archive node running 1.37.1 + this fix: block 4913057 validates and sync resumes from there.

Documentation

Requires documentation update

  • No

Requires explanation in Release Notes

  • Yes

Fixes a determinism bug that caused Sepolia archive nodes to reject block 4913057 with HeaderGasUsedMismatch when a contract was SELFDESTRUCTed and then redeployed at the same address (via the 0age metamorphic-contract pattern) within the same block. PerContractState.StorageRoot now correctly returns EmptyTreeHash while a same-block SELFDESTRUCT marker is in effect, so the EIP-684/EIP-7610 CREATE collision check sees the destroyed account as empty and lets the redeploy proceed.

Remarks

The same defect class is likely worth checking on master, although Nethermind.State has been substantially restructured there (cross-block caches, sharded NodeStorageCache, etc.) — the symbol and call path may be different. Worth a quick verification but out of scope for this 1.37.1 backport.

## Bug

Same-block SELFDESTRUCT followed by CREATE-at-the-same-address (the 0age
metamorphic-contract pattern) deterministically reverts on Sepolia
block 4913057. Prewarming-disabled runs reproduce the failure
identically, so the bug is in core state — not the cache layer.

## Root cause

The EVM CREATE collision check is `state.IsNonZeroAccount(X)`.
`ParallelWorldState` (the production decorator over `WorldState`,
registered at `BlockProcessingModule.cs:56-57`) does not override
`IsNonZeroAccount`, so the default interface method on `IWorldState`
runs. That default calls `IsStorageEmpty(X)`, which is also
unoverridden in `ParallelWorldState`, so it routes to the default in
`IAccountStateProvider`:

    TryGetAccount(address, out AccountStruct account);
    return account.IsStorageEmpty;

`TryGetAccount` lands on `WorldState.IAccountStateProvider.TryGetAccount`,
which builds the `AccountStruct` via:

    account = _stateProvider.GetAccount(address)
        .WithChangedStorageRoot(_persistentStorageProvider.GetStorageRoot(address))
        .ToStruct();

`PerContractState.StorageRoot` calls `EnsureStorageTree` →
`CreateStorageTree` → `_provider._currentScope.CreateStorageTree(_address)`
→ `LookupStorageTree` → `Get(X)?.StorageRoot`. `Get(X)` is
`TrieStoreWorldStateBackendScope.Get`, which serves from
`_loadedAccounts`. That cache was populated when the EVM first read X
earlier in the block (e.g. to load X's code for tx 0x2d's
SELFDESTRUCT call), and it holds the parent (pre-destroy) account
with a non-empty `StorageRoot`.

Result: `GetStorageRoot` returns the parent's non-empty `StorageRoot`,
`WithChangedStorageRoot` stuffs it into the `AccountStruct`,
`account.IsStorageEmpty` is `_storageRoot == EmptyTreeHash` and so
returns false, `IsNonZeroAccount` short-circuits to true, the inner
CREATE inside the metamorphic transient hits a phantom
EIP-684/EIP-7610 collision, the factory's
`require(deployed != 0)` reverts, and the block ends with
`HeaderGasUsedMismatch`.

`PerContractState.IsEmpty` already short-circuits on
`BlockChange.HasClear` (the same-block SELFDESTRUCT marker), but
`PerContractState.StorageRoot` did not.

## Fix

Add the same `BlockChange.HasClear` short-circuit to
`PerContractState.StorageRoot`, matching `IsEmpty`'s contract: while
a HasClear marker is in effect, the storage tree is logically empty
regardless of what cached parent state any consumer has.

## Tests

* `SelfDestructPrewarmerStateTests
  .IsNonZeroAccount_via_IWorldState_default_returns_false_after_destroy_and_value_transfer`
  exercises `ParallelWorldState` wrapping `WorldState`, primes
  `_loadedAccounts` the way the EVM does, runs destroy + value
  transfer, then performs the collision check via the `IWorldState`
  default interface method. The test fails on the parent commit and
  passes with this fix.
* `SelfDestructPrewarmerStateTests.Main_scope_sees_destroyed_account_even_with_prewarmed_cache`
  and `Main_scope_sees_recreated_account_as_collision_free_after_value_transfer`
  pin the cache-layer correctness for completeness.
* `SelfDestructCreate2SameBlockTests` runs the full
  block-production + validation flow (TestBlockchain, prewarming
  enabled by default) for two-tx and three-tx variants of the
  destroy-then-redeploy pattern with different senders.

Existing suites still pass after the fix:
- Nethermind.State.Test: 707 passed
- Nethermind.Blockchain.Test: 1426 passed
- Nethermind.Evm.Test SELFDESTRUCT/CREATE/Eip7708: 32 passed

## Verification on the affected node

Confirmed by the reporter on a Sepolia archive node running
1.37.1 + this fix: block 4913057 validates and sync resumes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@LukaszRozmej
Copy link
Copy Markdown
Member

@claude review

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 30, 2026

Claude finished @LukaszRozmej's task in 5m 43s —— View job


PR Review

  • Gathering context and reading relevant files
  • Reviewing core fix in PersistentStorageProvider.cs
  • Reviewing unit tests in SelfDestructPrewarmerStateTests.cs
  • Reviewing integration tests in SelfDestructCreate2SameBlockTests.cs
  • Checking adherence to repo rules and coding guidelines
  • Posting final review

Summary

The fix is correct, minimal, and well-motivated. The bug is subtle — PerContractState.StorageRoot did not honour the same-block SELFDESTRUCT marker (BlockChange.HasClear), causing the TryGetAccount → WithChangedStorageRoot path (used by ParallelWorldState via the IAccountStateProvider default interface method) to return the parent state's non-empty StorageRoot, producing a phantom EIP-684/7610 collision on metamorphic-contract redeployment in the same block.

Call-path trace (confirmed against source)

IWorldState.IsNonZeroAccount (default DIM)
  └─ IsStorageEmpty (default IAccountStateProvider DIM)
       └─ TryGetAccount (WrappedWorldState → WorldState.TryGetAccount)
            └─ _persistentStorageProvider.GetStorageRoot(address)
                 └─ PerContractState.StorageRoot     ← BUG / FIX HERE
                      before: EnsureStorageTree() → _backend.RootHash (stale parent root)
                      after:  if (HasClear) return EmptyTreeHash ✓

WorldState.IsNonZeroAccount (the concrete override at WorldState.cs:313) already takes the correct path through _persistentStorageProvider.IsStorageEmptyPerContractState.IsEmpty, which already checks HasClear. The bug only manifests when IsNonZeroAccount is called on a ParallelWorldState/WrappedWorldState reference, because those classes do not override IsNonZeroAccount and therefore fall through to the default DIM.

Production fix — PersistentStorageProvider.cs

No functional objection. See inline note on comment verbosity (Low).

Tests

File Pattern Notes
SelfDestructPrewarmerStateTests Direct state-layer Correctly exercises the ParallelWorldState DIM path; IsNonZeroAccount_via_IWorldState_default_returns_false_after_destroy_and_value_transfer is the regression test.
SelfDestructCreate2SameBlockTests BasicTestBlockchain + DI Correct DI pattern; two-tx and three-tx variants cover both the minimal case and the exact Sepolia block layout.

Findings

Severity Location Finding
Low PersistentStorageProvider.cs:476–483 8-line comment for a 1-line fix; could be reduced to 2–3 lines per coding-style guidelines (comment should explain why, not re-derive the entire call graph).
Low SelfDestructPrewarmerStateTests.cs:143 IsNonZeroAccount_via_IWorldState_default_returns_false_after_destroy_and_value_transfer duplicates the TrieStore/WorldState construction that BuildSharedCacheWorldStates() already provides; a small refactor would avoid the repetition. Note: manual construction is consistent with existing State tests in this project.
Low SelfDestructCreate2SameBlockTests.cs:1 Missing #nullable enable (present in the companion file).

No Critical, High, or Medium findings.

Correctness note on HasClear + subsequent writes

If ClearStorage is followed by new storage writes in the same tx commit window (exotic re-entrancy), StorageRoot would still return EmptyTreeHash. This is identical to the pre-existing IsEmpty behaviour and is not introduced by this PR.

// (used by ParallelWorldState because it does not provide its own override) flag
// a phantom EIP-684/7610 collision when a metamorphic factory does
// CREATE-inside-CREATE2 at the destroyed address in the same block.
if (BlockChange.HasClear) return Keccak.EmptyTreeHash;
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 fix is correct and precisely mirrors the existing IsEmpty short-circuit six lines below. A couple of small observations:

  1. Comment verbosity: The 8-line block is thorough, but per the coding style guidelines, comments should be concise and explain the why in one short line. The critical "why" is: without this, TryGetAccount → GetStorageRoot bypasses the IsEmpty invariant and leaks the parent trie root through _loadedAccounts, producing a phantom EIP-684/7610 collision. Something like:

    // Without this, TryGetAccount → GetStorageRoot leaks the parent trie root from
    // _loadedAccounts, causing a phantom EIP-684/7610 collision on same-block redeploy.
    if (BlockChange.HasClear) return Keccak.EmptyTreeHash;

    would be sufficient. The full root-cause narrative belongs in the commit message or PR description (where it already lives).

  2. Invariant consistency: The fix restores the invariant StorageRoot == EmptyTreeHash ⟺ IsEmpty under HasClear, which is exactly correct. No correctness concerns.

  3. Post-clear writes: If storage slots are written after ClearStorage within the same tx commit window (e.g. a re-entrancy that destroys and re-populates in one tx), StorageRoot would still return EmptyTreeHash — but IsEmpty has the same behaviour. This is pre-existing and outside this PR's scope.

{
// Use ParallelWorldState (production decorator over WorldState) so we exercise the
// default-interface-method dispatch path that the EVM actually hits in production.
IDbProvider dbProvider = TestMemDbProvider.Init();
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.

This test manually constructs TrieStore / NodeStorage / TrieStoreScopeProvider / WorldState / ParallelWorldState. The test-infrastructure rule says to prefer ContainerBuilder + production modules and avoid manual construction (see .agents/rules/test-infrastructure.md).

In practice, this pattern is already used throughout the existing Nethermind.State.Test suite (StorageProviderTests, StateProviderTests, ScopeProviderTests, etc.), so it is at least consistent with the surrounding test style. But it is still worth noting for future refactoring: a shared helper that accepts a lambda to customise the WorldState wrapper (e.g. to swap in ParallelWorldState) would remove the duplication between this test and BuildSharedCacheWorldStates().

@@ -0,0 +1,196 @@
// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
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.

Missing #nullable enable at the top of this file. SelfDestructPrewarmerStateTests.cs (the companion file in this PR) opts in; it is worth being consistent, and the project generally enables nullable analysis. Fix this →

@LukaszRozmej
Copy link
Copy Markdown
Member

Although maybe better fix is to just not register ParallelWorldState? @claude

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 30, 2026

Claude finished @LukaszRozmej's task in 2m 35s —— View job


Analysis: remove ParallelWorldState vs. patch PerContractState.StorageRoot

  • Read BlockProcessingModule.csParallelWorldState registered as decorator at line 57
  • Read ParallelWorldState — implements IBlockAccessListBuilder + IPreBlockCaches
  • Assessed tradeoffs — see below

Short answer: Removing the registration is a larger, riskier change that would disable EIP-7928 / parallel-execution access-list infrastructure. The one-line fix in this PR is the right approach. There is also a complementary fix worth considering.


What ParallelWorldState actually does

Beyond being a decorator, ParallelWorldState implements two interfaces that WorldState itself does not:

  • IBlockAccessListBuilder — tracks every state change per-tx (GeneratedBlockAccessList) and:

    • populates the BlockAccessListHash in the block header (SetBlockAccessList)
    • validates a pre-computed block-level access list supplied by the parallel-execution pre-runner (ValidateBlockAccessList)

    BlockValidationTransactionsExecutor casts the world-state as IBlockAccessListBuilder? and only skips these calls when the cast yields null. Removing ParallelWorldState would make the cast yield null unconditionally.

  • IPreBlockCaches — forwards the prewarmed-cache state from the inner WorldState so the prewarmer can prime account/code/storage caches before tx execution.

Both capabilities are guarded by spec.BlockLevelAccessListsEnabled (i.e. spec.IsEip7928Enabled) in the places that matter, so on current Sepolia/mainnet specs they are no-ops today. But removing ParallelWorldState would delete the infrastructure entirely — every future upgrade that touches EIP-7928 or parallel execution would have to be re-wired.

Why the PR's fix is more fundamental

The bug is a genuine invariant violation in PerContractState.StorageRoot: under a same-block SELFDESTRUCT marker (HasClear), StorageRoot must return EmptyTreeHash, just as IsEmpty already does.

The DIM path that hit the bug (IWorldState.IsNonZeroAccount → IAccountStateProvider.IsStorageEmpty → TryGetAccount → WithChangedStorageRoot(GetStorageRoot(...))) is just one consumer. Any code that calls StorageRoot directly while HasClear is set would face the same stale-root problem. The PR fixes the invariant at the source.

A complementary option worth noting

Overriding IsNonZeroAccount in WrappedWorldState as a virtual pass-through:

public virtual bool IsNonZeroAccount(Address address, out bool accountExists)
    => _innerWorldState.IsNonZeroAccount(address, out accountExists);

would route all decorator subclasses through WorldState's concrete override (which correctly calls _persistentStorageProvider.IsStorageEmptyPerContractState.IsEmpty) instead of the DIM. That's a useful defence-in-depth addition — but it still wouldn't fix PerContractState.StorageRoot for any caller that reads StorageRoot directly. The PR's fix is the correct root-cause repair; this would be a nice belt-and-suspenders addition on top.

Verdict

Removing ParallelWorldState is not a better fix for this backport:

Approach Blast radius Fixes root cause Preserves EIP-7928 infra
Remove ParallelWorldState High — removes BAL tracking No — StorageRoot invariant still broken for direct callers No
Override IsNonZeroAccount in WrappedWorldState Minimal Partially — only the DIM path Yes
This PR: short-circuit in PerContractState.StorageRoot Minimal — 1 line Yes — invariant correct for all callers Yes

The PR is correct as-is. The WrappedWorldState override is optional complementary hardening.

@LukaszRozmej
Copy link
Copy Markdown
Member

@claude we don't need BAL tracking in the release. In master this is already fixed in different way. We don't need BAL's here at all

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 30, 2026

Claude finished @LukaszRozmej's task in 3m 11s —— View job


Updated Analysis: Remove ParallelWorldState for release/1.37.1

  • Gathering context
  • Read BlockProcessingModule.cs and ParallelWorldState in the release branch
  • Assessed scope of removing ParallelWorldState registration
  • Posted findings

You're right. My previous analysis overstated the risk of removing ParallelWorldState. Here's the corrected picture:

What ParallelWorldState actually does in 1.37.1

BAL tracking (IBlockAccessListBuilder): BlockProcessor.ProcessOne gates TracingEnabled = true behind spec.BlockLevelAccessListsEnabled (EIP-7928 / Amsterdam fork). Amsterdam is not active on Sepolia or mainnet in 1.37.1, so ParallelWorldState never records any changes and ValidateBlockAccessList immediately returns (_suggestedBlockAccessList is null). Dead code in this release.

Pre-warming pass-through (IPreBlockCaches): ParallelWorldState.Caches just forwards to (_innerWorldState as IPreBlockCaches)?.Caches. WorldState does not implement IPreBlockCaches, so this always returns null. Prewarming itself goes through IWorldStateScopeProvider decoration (PrewarmerScopeProvider) and direct PreBlockCaches injection — it does not depend on IWorldState being cast to IPreBlockCaches. Removing ParallelWorldState does not affect prewarming.

Snapshot tracking for BAL restore: ParallelWorldState.TakeSnapshot adds a blockAccessListSnapshot index. With TracingEnabled = false, the list is always empty — the snapshot field is a no-op.

Why removing the decorator fixes the bug

Without ParallelWorldState in the DI chain, IWorldState resolves directly to WorldState. WorldState.IsNonZeroAccount is the concrete override that routes through _persistentStorageProvider.IsStorageEmpty → PerContractState.IsEmpty, which already checks HasClear. The DIM path (IsNonZeroAccount → TryGetAccount → WithChangedStorageRoot(GetStorageRoot(...))) that exposed the stale root bug is never taken.

Proposed change to this PR

Remove the single decorator line in BlockProcessingModule.cs:57:

// Remove:
.AddDecorator<IWorldState, ParallelWorldState>()

This is a one-line fix at the correct layer (DI wiring rather than adding a StorageRoot guard). It does not disable prewarming, does not break any currently-active spec, and is strictly smaller than the existing fix.

What to do with the PerContractState.StorageRoot fix

The StorageRoot guard (if (BlockChange.HasClear) return Keccak.EmptyTreeHash) is still a valid invariant fix and is safe to keep as belt-and-suspenders. However, if ParallelWorldState is removed, the TryGetAccount path that triggered the bug no longer exists, so the guard is defensive rather than load-bearing.

Verdict

Approach Root cause Lines changed EIP-7928 infra
Current PR (one-line in StorageRoot) Fixes invariant violation 1 Preserved
Remove AddDecorator<IWorldState, ParallelWorldState>() Removes the faulty DIM dispatch 1 Removed (unneeded in 1.37.1)
Both Belt-and-suspenders 2 Removed

Removing the decorator is correct and simpler for this release. Up to you whether to also keep the StorageRoot guard as a defensive invariant fix.

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.

3 participants