Skip to content

EIP-8141: charge a frame's entry gas and its target's delegation access - #12856

Open
Marchhill wants to merge 2 commits into
eip8141-frame-txs-devnet7from
marc/frame-delegation-access-devnet7
Open

EIP-8141: charge a frame's entry gas and its target's delegation access#12856
Marchhill wants to merge 2 commits into
eip8141-frame-txs-devnet7from
marc/frame-delegation-access-devnet7

Conversation

@Marchhill

@Marchhill Marchhill commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Changes

Two consecutive gaps on the EIP-8141 frame execution path, both in create_evm_from_frame:

  • Frame entry gas (was pending branch-wide, and the code said so). Every frame now pays, from its own gas limit, cold or warm access for its resolved target, plus the EIP-8037 NEW_ACCOUNT state cost when a value transfer revives a dead target. EIP-2929 seeds the accessed set with every precompile, which the shared frame tracker does not hold, so a precompile target pays warm. A charge the frame cannot afford halts it exceptionally, consuming its whole gas limit; a caller that cannot fund the value transfer still reverts before the charge, consuming nothing.
  • Delegation access. A frame whose target is an EIP-7702 delegated EOA followed the designation for code but discarded the resolved address, so the delegation target's access was never charged or warmed. It is now charged on top of the target's own entry charge, warmed for later frames, and the designated code is read only once its access is paid for — keeping an unaffordable designation out of the block access list. EIP-7702 bars a precompile reached through a delegation from executing as one.

The cold account-access price is exposed on the gas policy so the frame path prices it exactly as the VM's account-access metering does.

The affordability check is split so the target's access charge is tested before the deadness query behind the value transfer. That query is itself a recorded read on the BAL-generating world state, and BAL reads survive the frame's failure, so testing the sum first would write an unaffordable frame's target into the block access list — a header-visible divergence. The CALL path is sequenced the same way.

Both follow the reference create_evm_from_frame / resolve_delegated_code_address, including the ordering: the target's access is charged and the target marked accessed before the designation is resolved, so a self-designation resolves warm.

Types of changes

What types of changes does your code introduce?

  • Bugfix (a non-breaking change that fixes an issue)
  • New feature (a non-breaking change that adds functionality)
  • Breaking change (a change that causes existing functionality not to work as expected)
  • Optimization
  • Refactoring
  • Documentation update
  • Build-related changes
  • Other: Description

Testing

Requires testing

  • Yes
  • No

If yes, did you write tests?

  • Yes
  • No

Notes on testing

FrameTxProcessorTests covers both charges: cold-vs-warm target entry access, a frame whose gas is below its entry charge, the designated address's access for a contract and a precompile designation, a frame that can pay the target access but not the designation, and the resulting block access list in both cases. Two frames sharing one target pin the cross-frame warmth and its rollback when the first frame reverts, a self-designating target pins that it halts on its own designator bytes, and a frame priced below its target's access pins that the target stays out of the block access list.

Each change was revert-checked separately and serialized. Reverting the delegation production code fails exactly the four discriminating delegation tests; reverting the entry charge fails exactly ten, matching the prediction in both cases. Frame suites are green across Core (110), Evm (178), TxPool (77) and Blockchain (29).

Existing expectations that pinned the absence of an entry charge were updated rather than removed: the precompile-target gas cases now read identityGas + WarmAccess, and the EIP-8272 empty-reference case is now standard-bound rather than floor-bound, since the entry charge lifts both envelopes over their calldata floors.

Documentation

Requires documentation update

  • Yes
  • No

Requires explanation in Release Notes

  • Yes
  • No

Remarks

The merged EIP-8141 fixtures cannot validate this on eip8141-frame-txs-devnet7 yet. Bogota here composes as Osaka + EIP-8141, while the fixtures are Amsterdam-composed, so the blocks are rejected at header validation (BlockLevelAccessListHashNotEnabled) before any gas is metered — the whole target_resolution suite scores the same with and without these changes. The unit tests are the working oracle on this branch because Eip8141Prototype derives from Amsterdam and therefore carries the block access list, EIP-8037 and EIP-8038.

Verified end to end against the fixtures on the devnet-8 composition, where the same two charges take all four delegation cases from failing to passing: test_delegated_target_entry_charge (cold and warm), test_delegated_to_precompile_target and test_verify_frame_delegated_to_precompile_target.

@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Marchhill's task in 9m 32s —— View job


Review: EIP-8141 frame entry gas + delegation access

Read the two reviewable commits (c661b5a, 6053609) against origin/eip8141-frame-txs-devnet7. SpecNameParser.cs, SpecNameParserTests.cs and JsonToEthereumTest.cs come from the stacked #12854 and I left them out of scope.

  • Read the PR diff against eip8141-frame-txs-devnet7
  • Review the frame entry gas charge
  • Review the delegation access charge
  • Check the gas policy surface change
  • Check tests
  • Post findings

Verdict

The charges themselves look right, and the delegation half in particular is a faithful port of EvmInstructions.Call.cs:161-197: followDelegation: false → charge → AddAccountRead → precompile-guarded GetCachedCodeInfoNoDelegation, one hop only, read placed before the snapshot so it survives a revert exactly as the VM's does. The IGasPolicy extraction is the correct way to keep the frame path and the VM on one cold-access price. Things I checked and found sound:

  • Eip8038Constants.WarmAccess == GasCostOf.WarmStateRead, so the warm leg matches ConsumeAccountAccessGas under both spec settings.
  • blockStateGas ≤ grossGas (the invariant line 431 documents) still holds: entryState ≤ entryCharge ≤ frameGasUsed.
  • Charging entryState out of execution gas is equivalent to ConsumeStateGas on the empty reservoir the frame rents, so the double-count across both block dimensions is the intended spill behaviour, not a bug.
  • delegation != resolvedTarget is value equality (Address.cs:268), not reference — a self-designation really does resolve warm.
  • The IsPrecompile(delegation) guard is load-bearing: GetCachedCodeInfoNoDelegation would otherwise return PrecompileCodeInfo.
  • No overflow or underflow reachable on the arithmetic; stateGasUsed is 0 on both entry-OOG returns.
  • RentTopLevel has no other frame-shaped caller, so the change is contained.

Two Medium findings, both about how the change is expressed and covered rather than about the gas numbers, so neither is a claim that the branch computes the wrong price today.

# Severity Finding
1 Medium Entry charge re-derives pricing TGasPolicy already owns — drops isTracingAccess, forces the self-designation special case, leaves StateGasSpill inconsistent, and is why the new interface member exists (comment)
2 Medium Self-designation-warm and cross-frame warm-target branches have no test (comment)
3 Low ShouldRevert || IsError ? 0 : entryState is unreachable — the caller already discards it (comment)
4 Low static abstract is a breaking member; static virtual matches the adjacent GetNewAccountStateCost (comment)
5 Low Two ordering assertions in the PR description would benefit from a spec pointer (below)

The one concrete behavioural divergence is inside #1: ConsumeAccountAccessGas warms the address first when isTracingAccess, so the VM prices it warm; the frame path prices it cold. eth_createAccessList over-reports a frame transaction by COLD − WARM per frame. RPC-only, not consensus — but it's a real difference and cheap to fix by threading tracer.IsTracingAccess through.

Finding 5 — two orderings worth a spec pointer

Neither is a bug I can demonstrate; both are places where the code's correctness rests entirely on the PR description's claim about the reference, and a pointer would make them reviewable.

  • ExecuteDefaultVerifyCode bypasses the entry charge (TransactionProcessorBase.FrameTx.cs:557-560). A VERIFY frame with a codeless target returns before the new charge, so it pays neither target access nor NEW_ACCOUNT, while every other frame does. That's the shape SelfVerifyFrame() produces in essentially every test. If the reference reaches default code through create_evm_from_frame rather than before it, every frame transaction is short by 100 gas (the sender is pre-warmed at line 205). Your devnet-8 fixture runs are good evidence against that — those gas totals would not match — and ExecuteDefaultVerifyCode's doc already commits to "No gas beyond the EIP-8250 surcharge". Worth recording in the comment at line 557 that the default-code branch sits deliberately outside create_evm_from_frame, so the asymmetry doesn't read as an oversight later.
  • The balance check precedes the entry charge (line 549). A caller that can fund neither the value nor the entry charge reverts consuming nothing, rather than halting for its whole gas limit. Gas-observable, and the opposite of the VM's CALL ordering, where the balance check comes after the charges. The description asserts this matches the reference; where in it?

Not verified

I could not build or run anything — dotnet is not in the approved command set for this job, so the ten-test and four-test revert predictions in the description are unverified here. Everything above is from reading the diff against EvmInstructions.Call.cs, TransactionProcessor.cs and EthereumGasPolicy.cs.
· branch marc/frame-delegation-access-devnet7

Comment thread src/Nethermind/Nethermind.Evm/GasPolicy/IGasPolicy.cs
Comment thread src/Nethermind/Nethermind.Evm.Test/FrameTxProcessorTests.cs
@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

EVM Opcode Benchmark Diff

Aggregated runs: base=1, pr=1

No significant regressions or improvements detected.

{
try { return ConvertToBlockchainTests(_serializer.Deserialize<Dictionary<string, BlockchainTestJson>>(json)); }
catch (Exception) { return ConvertToBlockchainTests(CoerceFromHalf(_serializer.Deserialize<Dictionary<string, HalfBlockchainTestJson>>(json))); }
Dictionary<string, BlockchainTestJson> tests;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

already resolved in #12854?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes — you were right, this branch was cut from #12854 so it carried all three of its files verbatim. I have rebased it straight onto eip8141-frame-txs-devnet7, dropping the fixture-loading commits; the tests here are plain FrameTxProcessorTests unit tests and never touch the fixture loader, so it did not need to stack on #12854. This PR is now just the entry charge, the delegation access and their tests. Leaving the thread open for you.

Every frame pays, within its own gas limit, to resolve its target the
way create_evm_from_frame does: cold or warm account access, plus the
EIP-8037 NEW_ACCOUNT state cost when a value transfer revives a dead
target. EIP-2929 seeds the accessed set with every precompile, which the
shared frame tracker does not hold, so a precompile target pays warm.

The state reservoir starts empty, so the entry state cost spills into
execution gas, and a charge exceeding the frame's gas halts the frame
exceptionally with all of it consumed. A caller that cannot fund the
value transfer still reverts before the charge, consuming nothing.

Expose the cold account-access price on the gas policy so the frame path
prices it the same way the VM's account-access metering does.
@Marchhill
Marchhill force-pushed the marc/frame-delegation-access-devnet7 branch from 6053609 to b5b268c Compare August 18, 2026 17:35
@Marchhill
Marchhill marked this pull request as ready for review August 18, 2026 18:13
@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Marchhill's task in 8m 58s —— View job


Re-review: EIP-8141 frame entry gas + delegation access

Rebased since my last pass — the three #12854 fixture-loader files are gone, so the diff is now exactly the two charges and their tests (b5b268c, ecfa182 vs origin/eip8141-frame-txs-devnet7).

  • Read the rebased diff
  • Re-check the five prior findings against the current code
  • Review the new tests
  • Look for anything the rebase or the new commits introduced
  • Post findings

Where the previous findings stand

# Was Now
1 Medium — entry charge re-derives policy pricing Accepted. Your isTracingAccess argument checks out: TransactionProcessor.cs:1229 really does pass isTracingAccess: false for the top-frame delegation charge, so the frame path is consistent with the existing processor-level charge rather than the odd one out. Keeping the explicit form to hold the devnet-8 chain identical is a defensible call on a spec-tracking branch.
2 Medium — untested branches Resolved. Execute_TwoFramesShareATarget_ChargesColdAccessAgainOnlyWhenTheFirstReverted covers the cross-frame gap, and your self-designation reasoning holds — GetCachedCodeInfoNoDelegation(target) returns the 0xef01… designator itself, so the frame halts on an invalid opcode and consumes its whole limit at either access price. Not gas-observable, and not BAL-observable either, since AddAccountRead(delegation) re-reads an address already read.
3 Low — unreachable ternary Fixed at line 658.
4 Low — static abstract Accepted. The point that a static virtual default is unreachable from inside EthereumGasPolicy is correct, and duplicating the EIP-8038 constant selection would be the worse hazard.
5 Low — two orderings want a spec pointer Still open; restated below.

The delegation half remains a faithful port of EvmInstructions.Call.cs:161-196, and I re-verified the pieces I could: Eip8038Constants.WarmAccess == GasCostOf.WarmStateRead so the warm leg matches ConsumeAccountAccessGas under both compositions; StackAccessTracker.IsCold is side-effect free (StackAccessTracker.cs:31); the blockStateGas ≤ grossGas reservoir invariant at line 431 still holds with entryState in both dimensions; no overflow or underflow is reachable on entryCharge; and the OOG-at-entry return needs no restore, since nothing has been snapshotted or debited yet.

Two new Medium findings, both from following the entry charge into paths the tests don't reach.

# Severity Finding
A Medium The deadness query runs before the affordability check, so a frame that cannot afford its target's access still writes the target into the block access list (comment)
B Medium The new-account charge has no pre-EIP-8037 form, so it is dead on Bogota — Osaka + EIP-8141, the branch's own composition (comment)
C Low Two orderings still want a spec pointer (below)
D Low The two new BAL tests re-duplicate the block build the new TracedProcessor() helper stopped short of (below)

A in one line: WorldState.IsDeadAccount(resolvedTarget) at line 572 is a BAL-recorded read (TracedAccessWorldState.cs:272-280) that BAL restore never evicts (BlockAccessListAtIndex.cs:283-286), and it fires before the line 577 check — so a frame with value = 1 and gasLimit < ColdAccountAccess puts its target in the block access list without ever reading it. EvmInstructions.Call.cs:161-175 charges access and bails first, exactly to avoid that. Gas is identical either way; only the header's BAL hash differs, so a regression test has to assert the BAL. Fix this →

B in one line: Bogota derives from Osaka (Bogota.cs:11,16), so IsEip8037Enabled is false there and entryState is always 0 — a frame on that composition pays its target's access but revives a dead target for free, where the equivalent CALL pays GasCostOf.NewAccount through ConsumeNewAccountCreation<TEip8037>. Either charge through that helper, or state that the 8037 gate is a composition invariant rather than a spec switch.

C — the two orderings from last time

Neither is a bug I can demonstrate; both are places where correctness rests on the PR description's claim about the reference, and a pointer would make them reviewable.

  • ExecuteDefaultVerifyCode bypasses the entry charge (line 559-562). A VERIFY frame with a codeless target pays neither target access nor NEW_ACCOUNT, while every other frame does — and that is the shape SelfVerifyFrame() produces in nearly every test. Your devnet-8 fixture run is good evidence the reference agrees (the totals would not match otherwise), and the method's doc already commits to "No gas beyond the EIP-8250 surcharge". One line at 557 recording that the default-code branch sits deliberately outside create_evm_from_frame keeps the asymmetry from reading as an oversight later.
  • The balance check precedes the entry charge (line 551). Pre-existing on this branch, but the new charge gives it a gas consequence: a caller that can fund neither the value nor the entry charge now consumes nothing, where the same frame with a funded caller would consume its whole limit. The opposite of the CALL ordering, where the balance check comes last (EvmInstructions.Call.cs:210) and pushes zero rather than reverting. Where in the reference is this pinned?

D — test duplication

TracedProcessor() (line 1675) extracts the traced-state setup, which is the right move — four pre-existing sites open-code it (lines 954, 994, 1788, 2192). But both new callers then re-duplicate the seven-line block build plus Execute plus TransactionExecuted assertion (1608-1619, 1637-1646). Since the helper is new, folding that in — ProcessTraced(tx) returning the BlockAccessListAtIndex — costs nothing now and gives the four older sites something to migrate to later. Per AGENTS.md's "factor those parts into helper methods".

Not verified

I could not build or test: dotnet build is outside the approved command set for this job, so the revert-check predictions in the description remain unverified here, and finding A's failure shape is reasoned from the code paths rather than executed. Everything above is from reading the diff against EvmInstructions.Call.cs, TransactionProcessor.cs, EthereumGasPolicy.cs, TracedAccessWorldState.cs, BlockAccessListAtIndex.cs and the fork definitions.
· branch marc/frame-delegation-access-devnet7

A frame whose target is an EIP-7702 delegated EOA followed the
designation for code but discarded the resolved address, so the
delegation target's access was neither charged nor warmed.

Mirror resolve_delegated_code_address: charge its cold or warm access on
top of the target's own entry charge, warm it for the frames that
follow, and fail the frame consuming its whole gas limit when it cannot
afford the charge. The target counts as accessed by then, so a
self-designation is warm, as is a designated precompile.

The designated code is read only after its access is paid for, keeping
an unaffordable designation out of the block access list. EIP-7702 bars
a precompile reached through a delegation from executing as one.
@Marchhill
Marchhill force-pushed the marc/frame-delegation-access-devnet7 branch from b5b268c to e63add7 Compare August 18, 2026 18:50
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.

1 participant