Skip to content

fix(eth_createAccessList): preserve reverted-frame accesses and exclude precompiles#11431

Merged
svlachakis merged 28 commits into
masterfrom
11209-eth_createaccesslist
May 12, 2026
Merged

fix(eth_createAccessList): preserve reverted-frame accesses and exclude precompiles#11431
svlachakis merged 28 commits into
masterfrom
11209-eth_createaccesslist

Conversation

@manusw7
Copy link
Copy Markdown
Contributor

@manusw7 manusw7 commented Apr 30, 2026

Fixes #11209

Changes

  • StackAccessTracker: added isTracingAccess flag; when set, Restore() skips rolling back AccessedAddresses/AccessedStorageCells on sub-frame revert — so all addresses and storage keys touched inside a reverted frame survive to the final access list report.
  • BlockchainBridge.BuildAddressesToOptimize: includes all active precompile addresses for the given spec, so precompiles with no storage keys are excluded from the returned access list.
  • Regression tests for:
    • Reverted outer frame: call target address preserved in access list.
    • Reverted sub-frame: storage keys accessed inside the reverted frame preserved.
    • Nested reverted frames: both committed and inner-reverted call targets captured.
    • Precompile filtering through BlockchainBridge.CreateAccessList (with and without storage keys).

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

  • src/Nethermind/Nethermind.Evm.Test/Tracing/AccessTxTracerTests.cs
  • src/Nethermind/Nethermind.Facade.Test/BlockchainBridgeTests.cs

Documentation

Requires documentation update

  • Yes
  • No

Requires explanation in Release Notes

  • Yes
  • No

Fixed eth_createAccessList to return complete access lists: addresses and storage cells first touched inside reverted sub-frames are now preserved, and active precompile addresses with no storage keys are excluded from the result.

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 30, 2026

Claude finished @manusw7's task in 9m 39s —— View job


PR Review

  • Gather context and read changed files
  • Load repo coding rules
  • Review correctness and edge cases
  • Review security implications
  • Review performance
  • Post inline comments for specific issues
  • Post final summary

Summary

The PR correctly fixes three distinct eth_createAccessList bugs:

  1. Pre-Berlin capture — by moving the tracing WarmUp call outside the UseHotAndColdStorage gate in ConsumeAccountAccessGas / ConsumeStorageAccessGas.
  2. Reverted sub-frame capture — by adding non-journaled AllAccessedAddresses / AllAccessedStorageCells sets that survive Restore() on sub-frame revert, and reporting from those sets instead of the journaled ones.
  3. Precompile exclusion — by passing the active precompile addresses into AccessTxTracer's addressesToOptimize list so precompiles with no storage keys are filtered out of the result.

The design is sound. Findings below:


Findings

# Severity Location Issue
1 Medium BlockchainBridge.cs:227 LINQ Select violates repo rule ("No LINQ when a simple for/foreach works"); Precompiles is FrozenSet<AddressAsKey> with a known Count — use a pre-sized array + foreach loop
2 Medium BlockchainBridgeTests.cs:316-339 No test for optimize=false + precompile exclusion; the new precompileAddresses path is wired for both modes but only optimize=true has coverage
3 Low EvmInstructions.Create.cs:193 Comment says "pre-Berlin" but the guard fires on any fork when IsTracingAccess=true
4 Low StackAccessTracker.cs:18-19 AllAccessedAddresses / AllAccessedStorageCells are public mutable HashSet<T>; expose as IReadOnlyCollection<T> since callers only need to enumerate them
5 Note EthereumGasPolicy.cs:139-154 Pre-existing double-WarmUp when isTracingAccess=true + Berlin+ means cold gas is never charged in access-list simulations (pre-existing, arguably correct — warm gas is the right estimate since the generated list pre-warms those addresses) — add a comment to explain intent

Correctness

The AllAccessedAddresses / AllAccessedStorageCells sets accumulate across the entire transaction, are never rolled back by StackAccessTracker.Restore(), and are cleared at Dispose() / pool-return. This correctly captures accesses from reverted sub-frames for access-list reporting while keeping execution semantics (gas charging) tied to the journaled AccessedAddresses / AccessedStorageCells sets.

The StackAccessTracker is constructed with isTracingAccess = tracer.IsTracingAccess, so the non-journaled sets are only populated when needed. The ReportAccess call is guarded by the same flag, so there's no risk of reporting empty sets for non-tracing executions.

EIP-7702 delegation address capture in ProcessDelegations now correctly feeds AllAccessedAddresses when tracing, which is an improvement. For pre-Berlin forks, delegated will always be null (no EIP-7702 pre-Berlin), so the guard change in ConsumeAccountAccessGasWithDelegation is safe.

Security / Performance

No security concerns. This is RPC-layer simulation code only — no hot-path execution path is affected. The FrozenSet iteration to build precompileAddresses on every CreateAccessList call is cold-path and acceptable, but the LINQ allocation is avoidable (finding #1).

@manusw7 manusw7 changed the title 11209 eth createaccesslist fix(eth_createAccessList): complete access list across pre-Berlin and reverted frames Apr 30, 2026
// Collect active precompile addresses so that they can
// be excluded from the access list if they carry no storage keys
IReleaseSpec releaseSpec = specProvider.GetSpec(header);
Address[] precompileAddresses = [.. releaseSpec.Precompiles.Select(static p => (Address)p)];
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 — LINQ violation: The repo rules in .agents/rules/coding-style.md explicitly prohibit LINQ when a simple for/foreach works. Precompiles is a FrozenSet<AddressAsKey> with a known Count, so this should be rewritten as a loop:

Address[] precompileAddresses = new Address[releaseSpec.Precompiles.Count];
int idx = 0;
foreach (AddressAsKey p in releaseSpec.Precompiles)
    precompileAddresses[idx++] = (Address)p;

Fix this →

Address contractAddress = typeof(TOpCreate) == typeof(OpCreate)
? ContractAddress.From(env.ExecutingAccount, state.GetNonce(env.ExecutingAccount))
: ContractAddress.From(env.ExecutingAccount, salt, initCode.Span);

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 — Misleading comment: The comment says "captures the new contract pre-Berlin" but this guard fires for any fork when IsTracingAccess=true, including Berlin+. Change to something like:

// Also runs under access-list tracing so eth_createAccessList captures the new contract address.

Comment on lines +18 to +19
public readonly HashSet<Address> AllAccessedAddresses => _trackingState.AllAccessedAddresses;
public readonly HashSet<StorageCell> AllAccessedStorageCells => _trackingState.AllAccessedStorageCells;
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 — Mutable public sets: AllAccessedAddresses and AllAccessedStorageCells are exposed as mutable HashSet<T>. Any caller can .Add() / .Remove() directly, bypassing the _isTracingAccess guard and journal invariants. Since TransactionProcessor only needs to iterate them for ReportAccess, consider exposing them as IReadOnlyCollection<T> (or even IEnumerable<T>) to communicate the intent and prevent accidental modification:

public readonly IReadOnlyCollection<Address> AllAccessedAddresses => _trackingState.AllAccessedAddresses;
public readonly IReadOnlyCollection<StorageCell> AllAccessedStorageCells => _trackingState.AllAccessedStorageCells;

HashSet<T> implements IReadOnlyCollection<T>, so this requires no cast at the call site.


callOutput.AccessList.Should().NotBeNull();
callOutput.AccessList!.Any(e => e.Address == PrecompiledAddresses.ECRecover).Should().BeFalse();
callOutput.AccessList.Any(e => e.Address == TestItem.AddressC).Should().BeTrue();
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 — Missing coverage for optimize=false: The new precompile-exclusion logic applies to both paths (optimize=true and optimize=false), but only the optimize=true case is tested here. Please add a parallel test with optimize=false to confirm precompiles with no storage keys are still excluded from the returned access list via the updated new([header.GasBeneficiary, .. precompileAddresses]) initializer.

Also worth adding: a case where the precompile does have a storage key (unusual but theoretically possible) to document that it is deliberately included in that situation.

Comment on lines +139 to +154
if (isTracingAccess)
{
if (isTracingAccess)
{
accessTracker.WarmUp(address);
}

if (!spec.IsPrecompile(address) && accessTracker.WarmUp(address))
{
result = UpdateGas(ref gas, GasCostOf.ColdAccountAccess);
}
else if (chargeForWarm)
{
result = UpdateGas(ref gas, GasCostOf.WarmStateRead);
}
accessTracker.WarmUp(address);
}

return result;
if (!spec.UseHotAndColdStorage)
return true;

if (!spec.IsPrecompile(address) && accessTracker.WarmUp(address))
{
return UpdateGas(ref gas, GasCostOf.ColdAccountAccess);
}
if (chargeForWarm)
{
return UpdateGas(ref gas, GasCostOf.WarmStateRead);
}
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.

Note — Pre-existing double WarmUp (no action needed, but awareness important)

When isTracingAccess=true AND spec.UseHotAndColdStorage=true (Berlin+), WarmUp(address) is now called twice: once at line 141 (tracing block, adds to AccessedAddresses returning true), and once at line 147 (gas block, returns false because the address is already warm). Cold-access gas (GasCostOf.ColdAccountAccess) is therefore never charged in eth_createAccessList simulations on Berlin+.

This was already the case in the pre-PR code (same double WarmUp existed), and is arguably correct semantics: the generated access list will pre-warm every address in the actual transaction, so warm-access gas is the right estimate. However it is worth a brief comment to prevent future readers from being confused by the apparent miss:

// Note: isTracingAccess pre-warms the address above, so this second WarmUp returns false
// (already warm). Cold gas is intentionally not charged here — the caller will include
// these addresses in an EIP-2930 access list, making them warm on actual execution.
if (!spec.IsPrecompile(address) && accessTracker.WarmUp(address))

@manusw7
Copy link
Copy Markdown
Contributor Author

manusw7 commented May 1, 2026

@claude review again

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 1, 2026

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


PR Review

  • Gather context and read changed files
  • Load repo coding rules
  • Review correctness and edge cases
  • Review security implications
  • Review performance
  • Post inline comments for specific issues
  • Post final summary

Summary

This re-review confirms all five findings from the previous review have been addressed.

# Severity Previous finding Status
1 Medium LINQ Select in BlockchainBridge.cs:227 ✅ Fixed — foreach loop with pre-sized array
2 Medium Missing optimize=false + precompile-exclusion test ✅ Fixed — 3 new tests: optimize=true, optimize=false, and precompile-with-storage-key
3 Low Misleading "pre-Berlin" comment in EvmInstructions.Create.cs:193 ✅ Fixed — comment now accurately describes both EIP-2929 warmup and access-list tracing cases
4 Low AllAccessedAddresses/AllAccessedStorageCells exposed as mutable HashSet<T> ✅ Fixed — now IReadOnlyCollection<T>; HashSet<T> implements this at no cost
5 Note Pre-existing double-WarmUp when isTracingAccess=true + Berlin+ (cold gas never charged) ✅ Documented — explanatory comment added at EthereumGasPolicy.cs:147

Fresh pass — no new findings

The implementation is correct and complete:

  • StackAccessTracker is now constructed with isTracingAccess = tracer.IsTracingAccess, so AllAccessed* sets are only populated for access-list simulations.
  • Restore() never touches AllAccessed*, so reverted sub-frame accesses correctly survive.
  • TrackingState.Clear() resets both sets on pool return — no leak across reused states.
  • WarmUp(AccessList?) also mirrors accesses to AllAccessed* when tracing, covering EIP-2930 pre-warm entries.
  • BuildExecutionEnvironment wiring (UseHotAndColdStorage || isTracingAccess) correctly extends access capture to pre-Berlin tracing.
  • The delegation warmup path in EvmInstructions.Call.cs (UseHotAndColdStorage || IsTracingAccess) is correct: though EIP-7702 implies Prague+, the defensive guard is cheap and safe.
  • ProcessDelegationsIsValidForExecutionaccessTracker.WarmUp(authority) — since the tracker is now initialized with isTracingAccess, authority addresses are captured for access lists too.

Tests

Three BlockchainBridgeTests cover the precompile exclusion path:

  1. optimize=true + precompile with no storage keys → excluded
  2. optimize=false + precompile with no storage keys → excluded
  3. optimize=false + precompile with storage key → kept

Three AccessTxTracerRevertedFrameTests cover the reverted-frame semantics correctly. The ContainEquivalentOf((Address, UInt256[])) assertions are consistent with the existing passing test at line 55 (BeEquivalentTo) — FluentAssertions handles StorageKeysEnumerableUInt256[] structural comparison correctly there.

Mergeable — no blocking findings.

@manusw7 manusw7 requested review from flcl42 May 1, 2026 15:29
Copy link
Copy Markdown
Member

@LukaszRozmej LukaszRozmej left a comment

Choose a reason for hiding this comment

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

IMO this feels off:

  1. eth_createAccessList for pre-berlin blocks doesn't have much sense. If there are tests for it we should consider convincing EF to just drop them.
  2. Having separate modes just for that sounds overkill
  3. This PR probably has negative performance effects

IMO not worth it at all

Comment thread src/Nethermind/Nethermind.Evm.Test/Tracing/AccessTxTracerTests.cs Outdated
@manusw7 manusw7 changed the title fix(eth_createAccessList): complete access list across pre-Berlin and reverted frames fix(eth_createAccessList): preserve reverted-frame accesses and exclude precompiles May 6, 2026
@manusw7 manusw7 marked this pull request as ready for review May 6, 2026 15:42
@manusw7 manusw7 requested a review from LukaszRozmej May 6, 2026 15:42
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 6, 2026

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


PR Review

  • Gather context and understand current state
  • Read changed files
  • Load repo coding rules
  • Check if pre-Berlin scope and HashSet approach were revised per reviewer feedback
  • Review correctness and edge cases
  • Review security and performance
  • Post inline comments for specific issues
  • Post final summary

Summary of changes vs. prior reviews

This is the third review pass. The PR has been substantially simplified since the 2nd review: the previous approach of two extra HashSet<T> fields on StackAccessTracker (one for addresses, one for storage cells) has been replaced by a single bool _isTracingAccess field and a conditional in Restore(). This directly addresses @LukaszRozmej's suggestion ("Or don't revert this one?") and eliminates the allocation concern from @svlachakis.

Reviewer requests status:

Request Status
Drop pre-Berlin scope (commit b9d4690) ✅ Done
Eliminate extra HashSet<T> allocations per tx ✅ Done — now just a bool + conditional
Move Restore() skip onto simpler mechanism ✅ Done — _isTracingAccess flag in StackAccessTracker
Move reverted-frame tests to separate file (@LukaszRozmej) ❌ Not done — still in AccessTxTracerTests.cs
LINQ fix (BlockchainBridge.cs:279) ❌ Regressed — LINQ Select is back

Findings

# Severity Location Issue
1 Medium BlockchainBridge.cs:279 LINQ Select regressed — was supposedly fixed in the 2nd review but commit 3dd6355 brought it back, along with using System.Linq; at line 22. Repo rule: no LINQ when a simple for/foreach works. Use new Address[spec.Precompiles.Count] + foreach loop.
2 Low BlockchainBridge.cs:295 Comment typo introduced by this PR: discovered(stray backtick) should bediscovered`.
3 Low AccessTxTracerTests.cs:116-205 @LukaszRozmej's CHANGES_REQUESTED comment explicitly asked to move tests to a separate file. The three reverted-frame tests are still appended to AccessTxTracerTests.cs.

Correctness

The revised approach is correct for eth_createAccessList purposes:

  • Restore() skips rolling back AccessedAddresses/AccessedStorageCells when _isTracingAccess=true. Reverted sub-frame accesses therefore survive into the final ReportAccess call.
  • AccessTxTracer.ReportAccess already filters addressesToOptimize (sender, recipient, beneficiary, precompiles) by dropping entries with empty storage key sets, so precompile-only addresses are correctly excluded.
  • Gas estimates in tracing mode reflect "cost with the generated access list pre-applied," not "cost without it." Since the generated list will pre-warm all those addresses in actual execution, warm gas is the correct estimate for each re-access — this is intentionally correct, not a bug.
  • Pre-Berlin scope has been dropped. EthereumGasPolicy.cs is unchanged by this PR.

Performance

The bool _isTracingAccess + single conditional in Restore() has no measurable impact on normal (non-tracing) execution. The performance concern from @svlachakis is resolved. The LINQ allocation at BuildAddressesToOptimize is cold-path but still violates the repo rule (finding #1).

Security

No security concerns. This is RPC simulation code; the hot-path EVM execution is unaffected when IsTracingAccess=false (the default for all non-access-list calls).


Mergeable: No@LukaszRozmej's CHANGES_REQUESTED is still open, and there is 1 Medium finding (LINQ regression) plus 2 Low findings that need to be addressed.

@manusw7
Copy link
Copy Markdown
Contributor Author

manusw7 commented May 6, 2026

@claude review again. Also note that change request was about PreBerlin tests (which were dropped), rn AccessTxTracerTests only have 3 new tests in the came class regarding reverted calls

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 6, 2026

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


PR Review (4th pass)

  • Gather context and understand current state
  • Read changed files
  • Load repo coding rules
  • Check outstanding findings from 3rd review
  • Review correctness and edge cases
  • Review security and performance
  • Post final summary

Status of 3rd-review findings

# Severity Finding Status
1 Medium LINQ Select regression in BlockchainBridge.cs ✅ Fixed — commit 7269443 uses pre-sized arrays + foreach; no using System.Linq remains
2 Low Comment typo (discovered`` stray backtick) ✅ Fixed — commit fc271ce
3 Low Move reverted-frame tests to a separate file ✅ Not a blocker — see note below

On finding #3: @LukaszRozmej's inline comment was attached to the test file at the time it contained both pre-Berlin tests and reverted-frame tests. The pre-Berlin tests were the primary driver of the CHANGES_REQUESTED review (concerns 1–3 in their top-level comment). The 3 remaining new tests cover reverted sub-frame behaviour — they're topically consistent with the existing Records_get_correct_accessed_* tests already in AccessTxTracerTests.cs. Moving them to a new file would add churn without meaningful benefit. @manusw7's read of the intent is correct.


Fresh pass — no new findings

StackAccessTracker.cs — The _isTracingAccess flag + conditional in Restore() is the minimal correct fix. DestroyList and Logs still revert on sub-frame revert (correct — SELFDESTRUCT and logs in reverted frames must not survive). Only AccessedAddresses/AccessedStorageCells are preserved, which is the intended semantics for access-list generation.

Journal growth is bounded: JournalSet<T> only records an entry when a new element is actually added (set semantics), so accumulation is O(unique addresses touched), not O(sub-frame depth). No unbounded growth risk. ✅

Pool reuse is safe: TrackingState.ResetAndReturn calls Clear() which clears both sets before re-queuing. ✅

TransactionProcessor.cs — Single-line change: new StackAccessTracker(tracer.IsTracingAccess). The flag is false for all normal (non-access-list) execution — zero impact on the hot path. ✅

BlockchainBridge.csBuildAddressesToOptimize now includes all active precompile addresses in both optimize=true and optimize=false branches using pre-sized arrays + foreach. No LINQ, no extra using. ✅

Gas-estimate semantics — With _isTracingAccess=true, addresses from reverted sub-frames remain warm for subsequent accesses in the same transaction. This is intentional: eth_createAccessList estimates gas "as if the generated list is pre-included," so warm-gas charges for those re-uses are correct. The convergence loop handles this naturally since the access list is monotonically non-decreasing across iterations. ✅

Tests — The 3 BlockchainBridgeTests (precompile exclusion with and without storage keys) and 3 AccessTxTracerTests (reverted frame semantics) give solid regression coverage for both fixes. ✅


Recommendation

Mergeable. All medium findings are resolved, no new findings. The implementation is correct, minimal, and has no hot-path impact.

The only process note: @LukaszRozmej has an open CHANGES_REQUESTED review. Given that all three of their primary concerns have been addressed (pre-Berlin scope dropped, extra-HashSet allocation replaced by a bool flag, and performance impact eliminated), it would be worth requesting a re-review from them so the PR can formally proceed.

Copy link
Copy Markdown
Member

@LukaszRozmej LukaszRozmej left a comment

Choose a reason for hiding this comment

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

Check if tests can be deduplicated more

Comment thread src/Nethermind/Nethermind.Evm.Test/Tracing/AccessTxTracerTests.cs
Comment thread src/Nethermind/Nethermind.Facade/BlockchainBridge.cs Outdated
Copy link
Copy Markdown
Contributor

@svlachakis svlachakis left a comment

Choose a reason for hiding this comment

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

LGTM, please check @LukaszRozmej's comments

Collapse the two precompile-filter tests differing only by `optimize`
into a single parameterized test, and extract the shared mock setup
into a helper used by both tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@svlachakis svlachakis merged commit a728e10 into master May 12, 2026
548 of 549 checks passed
@svlachakis svlachakis deleted the 11209-eth_createaccesslist branch May 12, 2026 09:23
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.

eth_createAccessList: incomplete or empty access list returned

4 participants