Skip to content

fix(evm): charge a frame for resolving its target's delegation - #12786

Draft
Marchhill wants to merge 7 commits into
frames-devnet8-integrationfrom
marc/frame-delegation-entry-charge
Draft

fix(evm): charge a frame for resolving its target's delegation#12786
Marchhill wants to merge 7 commits into
frames-devnet8-integrationfrom
marc/frame-delegation-entry-charge

Conversation

@Marchhill

@Marchhill Marchhill commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Changes

  • Charge a frame the access of its target's EIP-7702 designated address at frame entry.

The frame entry charge covered the target's own access and the EIP-8037 NEW_ACCOUNT state cost, but not the access that resolving a designation performs. create_evm_from_frame charges it alongside the target's: resolve_delegated_code_address reads the target's code and, when it is a designation, charges warm or cold for the designated address and warms it. A frame targeting a delegated account was therefore following the designation for free.

The designated code is also read only after that charge succeeds, mirroring the top-level path: create_evm_from_frame resolves the designation before it loads the code, so a frame that cannot afford the access must leave the designated account untouched — and out of the block access list. Following the designation now goes through the same IsPrecompile guard the CALL path uses, which is required once the code is fetched separately, since the repository would otherwise dispatch a precompile through a designation.

Direction: gas moves up for a frame whose target is delegated — by COLD_ACCOUNT_ACCESS when the designated address is cold, or WARM_ACCESS when an earlier frame already touched it (a designation pointing at a precompile is warm, since EIP-2929 seeds the precompiles). Every other frame is unaffected. The charge is applied after the target's own, so a frame that cannot afford it fails with its gas limit consumed, matching the order the reference implementation charges in; the designated address is warmed into the frame's tracker so a reverting frame discards it with the rest of its accesses.

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

Three cases in FrameTxProcessorTests, all failing before the change:

  • Execute_FrameTargetingDelegatedAccount_PaysTheDelegateAccess compares a frame targeting a delegated account against one targeting the designated contract directly, parameterised over a contract designation (3000, was 0) and a precompile designation (100, was 0 — pinning the warm disjunct).
  • Execute_FrameGasCoveringOnlyTheTargetAccess_FailsOnTheDelegateAccess gives the frame exactly the target's own cold access, the window the second entry check opens: the frame now fails with its whole limit consumed, where before it succeeded.
  • Execute_FrameGasCoveringOnlyTheTargetAccess_LeavesTheDesignatedAccountOutOfTheBal covers the read-ordering half of the same case, which status and gas cannot see: the designated account is absent from the block access list, where reading it before the charge recorded it.

Nethermind.Evm.Test: 5125 passed, 8 skipped, 0 failed. dotnet format whitespace clean, and a build with the lint workflow's NoWarn set reports no IDE/CA warnings.

Also verified against fixtures: with this change, the frame transaction suite's delegated-target entry-charge cases (cold and warm designated address) and the designation-to-precompile case pass, where all three failed before. No regression in the rest of the suite.

Documentation

Requires documentation update

  • Yes
  • No

Requires explanation in Release Notes

  • Yes
  • No

A frame whose target carries an EIP-7702 designation followed it for
free: the entry charge covered the target's own access but not the
access of the designated address that create_evm_from_frame charges
alongside it.
@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Marchhill's task in 7m 3s —— View job


Review: charge a frame for resolving its target's delegation

  • Read the diff
  • Compared against the VM's own delegation charge (EvmInstructions.Call.cs:160-196, EthereumGasPolicy.ConsumeAccountAccessGas)
  • Enumerated warm/cold/self-designation/precompile combinations
  • Checked EIP-7928 block-access-list parity for the designated address
  • Checked the test for confounds (intrinsic gas, floor gas, code path symmetry)
  • Posted findings

Verdict: no Critical/High/Medium findings — looks correct and mergeable. Two Low notes, both posted inline.

What I checked

The charge reproduces EvmInstructions.Call.cs:166-169 (ConsumeAccountAccessGas(delegated) after the target's own), and the arithmetic agrees with the VM in every combination:

target designation VM (CALL) this PR
cold cold, different 3000 + 3000 3000 + 3000
warm cold, different 100 + 3000 100 + 3000
cold == target 3000 + 100 3000 + 100
warm == target 100 + 100 100 + 100
any precompile 100 100
precompile target n/a (CodeInfoRepository.cs:62 returns early, delegation stays null) 100 100

The subtle case is the third row, and the delegation != resolvedTarget guard is exactly what makes it match: ConsumeAccountAccessGas warms the target inside its own charge, so a self-designation is already warm when the VM charges it, whereas this path defers WarmUp to line 560 so a reverting frame can discard it. Getting that right is the non-obvious part of the change.

Other things that hold up:

  • Ordering vs. the reference. The delegation charge lands after entryExecution + entryState, and the extra entryCharge > frame.GasLimit check is a strict equivalent of one combined check (both branches return OutOfGas with gasUsed = frame.GasLimit), so the split introduces no observable difference while still short-circuiting before ExecutionEnvironment.Rent.
  • Revert semantics. frameTracker.WarmUp(delegation) sits after TakeSnapshot(), so frameTracker.Restore() on line 583 discards it with the target's — matching the spec line quoted on 553-555. On the entry-OOG return the designation is never warmed, which is the same treatment the target already gets.
  • Gas accounting downstream. entryExecution is dead after line 491, so folding the charge straight into entryCharge is safe: the VM is rented with frame.GasLimit - entryCharge (line 566) and stateGasUsed stays keyed to entryState only, which is correct — the designation access is execution gas, not EIP-8037 state gas.
  • spec.UseHotAndColdStorage guard matches the VM's own guard on line 166, and Eip8038Constants.WarmAccess == GasCostOf.WarmStateRead, so the hardcoded warm constant does not diverge from GasCostOf on non-8038 specs.
  • EIP-7928. No BAL gap: followDelegation: true routes through InternalGetCodeInfo(delegationAddress)TracedAccessWorldState.GetCodeHashRecordReadAndGetChanges, so the designated account read is recorded. The explicit state.AddAccountRead(delegated) the CALL path needs (Call.cs:190) is not required here.
  • Test is confound-free. Frame-tx intrinsic gas counts only frame.Data tokens (FrameTxValidation.cs:397), not the target address, so the differing target addresses cannot skew the delta; both legs execute the same non-codeless STOP, so the 3000 delta isolates the designation access.
  • Performance: one null check plus one IsPrecompile on a path that only runs for delegated targets. Nothing measurable.

Findings

Low — duplicated access-cost expression (TransactionProcessorBase.FrameTx.cs:525) — same warm/cold selection as line 486, and EthereumGasPolicy.ConsumeAccountAccessGas is a third copy of the rule; a local helper collapses the two in this method. Inline · Fix this →

Low — the new OOG-at-entry branch is untested (FrameTxProcessorTests.cs:344-363) — lines 528-532 add consensus-relevant control flow (a frame that could afford entry before the fix and cannot after) with no unit coverage; a case whose gasLimit sits between the target's access and target + designation access would pin it. Warm-designation and designation-to-precompile are one [TestCase] away, and this test duplicates the shape of Execute_FrameTargetingPrecompile_PaysWarmEntryAccess above it. Inline · Fix this →

Informational — pre-existing, not introduced here. CALL guards a designation pointing at a precompile with spec.IsPrecompile(delegated) ? CodeInfo.Empty : … (Call.cs:193), citing "a precompile MUST NOT execute via delegation". The frame path relies on followDelegation: true, whose InternalGetCodeInfo(precompileAddress) yields the state code at that address — empty on any real chain, so behaviour matches, but it would diverge from CALL on a contrived pre-state that plants code at a precompile address. Out of scope for this PR; flagging only because the change sits on that call.

Not verified

dotnet test and dotnet build both require approval in this environment, so I could not reproduce the reported run (5123 passed) or the fixture results — everything above is static analysis against the VM's own implementation. Nothing in the review depends on that.
· branch marc/frame-delegation-entry-charge

Comment thread src/Nethermind/Nethermind.Evm.Test/FrameTxProcessorTests.cs
Cover the frame gas window between the target's own access and the
designation's, a designated precompile's warm access, and share the
entry-gas delta between the two entry-charge tests.
@Marchhill

Copy link
Copy Markdown
Contributor Author

@claude review

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Marchhill's task in 6m 26s —— View job


Re-review: charge a frame for resolving its target's delegation

  • Re-read the diff against origin/frames-devnet8-integration
  • Verified both rebuttals from the previous round
  • Checked the new tests (EntryGasDelta, OOG boundary, precompile designation)
  • Re-checked entry-charge arithmetic, revert semantics, EIP-7928 parity
  • Posted findings

Verdict: one new Medium finding — everything from the previous round is resolved.

Previous round: both rebuttals hold, tests address the coverage gap

  • Local helper can't compile. Correct. accessTracker is in StackAccessTracker and C# forbids a local function from capturing a ref/in parameter (CS8175). A static local function taking it as an extra in parameter would compile, but that is longer than the ternary it replaces — the over-extraction point stands, and the added comment on lines 521-522 names the self-designation reason, which was the part worth writing down.
  • ConsumeAccountAccessGas warms as it charges. Correct, and it is the reason this path cannot reuse it: the entry charge is computed before frameTracker.TakeSnapshot() (line 557), so charging through the policy would leave the target and the designation warm after a reverting frame. Worth keeping as-is.
  • New tests. Execute_FrameGasCoveringOnlyTheTargetAccess_FailsOnTheDelegateAccess pins exactly the window the second entryCharge > frame.GasLimit check opens, and asserts both halves of the outcome (StatusFailure, whole limit consumed) — a frame at gasLimit == ColdAccountAccess succeeded before the change. The precompile-designation [TestCase] pins the warm disjunct (100), and both legs of EntryGasDelta execute the same STOP under the same intrinsic gas (frame target addresses never enter CalculateTokensInCallData), so the delta isolates the entry charge. FrameReceiptTracer inherits IsTracingReceipt => true from CallOutputTracer, so ReportFrameTxReceipt is reached at line 442.

Findings

Medium — the designated account is read before the charge that gates it (TransactionProcessorBase.FrameTx.cs:520) — line 520 uses the followDelegation: true overload, which runs InternalGetCodeInfo(delegation)GetCodeHashTracedAccessWorldState.RecordReadAndGetChanges → an EIP-7928 BAL account entry, before the new entryCharge > frame.GasLimit bail-out on 528. The top-level path does the reverse under an explicit EIP-8037 rationale (TransactionProcessor.cs:1216-1244): followDelegation: !spec.IsEip8037Enabled, charge, and only then AddAccountRead(delegationAddress) + load — on OOG the designated account is never touched. So a transaction that cannot pay the designation access leaves no BAL entry while a frame in the same position leaves one. Before this PR the frame path could not fail there, so this is newly reachable, and Execute_FrameGasCoveringOnlyTheTargetAccess_FailsOnTheDelegateAccess is the transaction that reaches it. If the reference gates the load on the charge the way EIP-8037 does for the top frame, such a block gets one extra BAL account → hash mismatch against other clients. Mirroring the top-level shape fixes it and picks up the missing spec.IsPrecompile(delegation) ? CodeInfo.Empty guard at the same time. A fixture that already covers frame-OOG-on-designation under a BAL-enabled fork would settle it the other way. Inline · Fix this →

Re-confirmed from the previous round (unchanged code)

The charge itself still agrees with EvmInstructions.Call.cs:166-169 in every warm/cold/self-designation/precompile combination; the delegation != resolvedTarget guard is what makes the self-designation row match, since ConsumeAccountAccessGas warms the target inside its own charge while this path defers WarmUp to line 561. entryExecution is dead after line 491 so folding into entryCharge is safe, stateGasUsed stays keyed to entryState only (the designation access is execution gas, not EIP-8037 state gas), the extra > frame.GasLimit check is a strict equivalent of one combined check, and frameTracker.WarmUp(delegation) after TakeSnapshot() gives a reverting frame the discard the spec requires. Eip8038Constants.WarmAccess == GasCostOf.WarmStateRead, so the hardcoded constant does not diverge on non-8038 specs. Performance impact is one null check plus one IsPrecompile on delegated targets only.

Not verified

dotnet build / dotnet test require approval in this environment, so I could not reproduce the reported 5125 passed run or the fixture results — the above is static analysis. The Medium finding in particular is settled by a fixture, not by reasoning, and I could not run one.
· branch marc/frame-delegation-entry-charge

create_evm_from_frame resolves the designation only once its access is
charged, so a frame that cannot afford it must leave the designated
account untouched and out of the block access list.
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

EVM Opcode Benchmark Diff

Aggregated runs: base=1, pr=1

No significant regressions or improvements detected.

@Marchhill

Copy link
Copy Markdown
Contributor Author

@claude review

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

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


Re-review: charge a frame for resolving its target's delegation

  • Read the diff against origin/frames-devnet8-integration
  • Verified the fix for the previous round's Medium (read-before-charge / BAL ordering)
  • Checked the new GetCachedCodeInfoNoDelegation + IsPrecompile path against CALL and the top-level path
  • Re-checked entry-charge arithmetic, warming, revert semantics
  • Reviewed the tests
  • Posted findings

Verdict: the previous Medium is fixed. No Critical/High/Medium findings — one Low.

The Medium from the last round is resolved

422e82e mirrors the top-level shape exactly, and now also matches CALL step for step:

step EvmInstructions.Call.cs TransactionProcessor.cs:1216 this PR (FrameTx.cs:521)
fetch target code followDelegation: false (163) followDelegation: !IsEip8037 followDelegation: false
charge designation access ConsumeAccountAccessGas(delegated) (167) same entryCharge +=
bail before touching designee goto OutOfGas codeInfo = CodeInfo.Empty return OutOfGas
record read state.AddAccountRead(delegated) (190) same WorldState.AddAccountRead
precompile guard IsPrecompile(delegated) ? CodeInfo.Empty (193) same same ✅
load designated code GetCachedCodeInfoNoDelegation (195) followDelegation: false GetCachedCodeInfoNoDelegation

So the BAL divergence is gone: a frame that cannot pay the designation access no longer reads the designated account. AddAccountRead before the IsPrecompile branch (so a designated precompile is recorded) matches both existing sites — deliberate consistency, not an oversight.

The IsPrecompile guard is indeed required rather than cosmetic, and the reasoning in Marc's reply is right: CodeInfoRepository.GetCachedCodeInfo (line 62) returns PrecompileCodeInfo for a precompile codeSource regardless of followDelegation, so GetCachedCodeInfoNoDelegation(delegation) would hand the VM a precompile CodeInfo and VirtualMachine.ExecuteTransaction would dispatch it (_currentState.IsPrecompile, line 184). The old followDelegation: true call could not, because InternalGetCodeInfo bypasses the precompile table. Genuinely a new requirement introduced by fetching the code separately.

Everything else re-confirmed

  • Gas. The charge still agrees with ConsumeAccountAccessGas in every combination (cold/warm target × cold/warm/self/precompile designation); delegation != resolvedTarget is what makes the self-designation row match, since the VM warms the target inside its own charge while this path defers WarmUp to line 572. entryCharge cannot overflow, and frame.GasLimit - entryCharge at 577 is guarded by the check above it.
  • Ordering vs. CALL. CALL charges the designation access before NEW_ACCOUNT; this path charges it after (entryState folded into entryCharge first). Unobservable: both orderings total the same, both failures return gasUsed = frame.GasLimit with stateGasUsed = 0, and IsDeadAccount(resolvedTarget) adds no BAL entry the GetCodeHash on line 475 has not already made. Moot anyway — a delegated account is never dead, so entryState is 0 whenever delegation is not null.
  • Revert / warming. WarmUp(delegation) after TakeSnapshot() (568) so frameTracker.Restore() discards it; on the entry-OOG return neither the target nor the designee is warmed, which is what "if a frame reverts, warm/cold status reverts" requires. WarmUp is readonly, so the in accessTracker copy is fine.
  • !UseHotAndColdStorage. No charge but the designated code is still loaded — same as CALL, whose load is outside the UseHotAndColdStorage guard too.
  • Chained designations resolve one hop only, as on the CALL path (GetCachedCodeInfoNoDelegation returns raw code). Unchanged from followDelegation: true.
  • AddAccountRead is not redundant. CacheCodeInfoRepository still routes through InternalGetCodeInfoGetCodeHash, so the non-precompile branch would record anyway; the precompile branch (CodeInfo.Empty) would not. Keeping it unconditional matches the other two sites.
  • Performance. followDelegation: false + one null check on a path that already reads the target's code; the extra IsPrecompile/WarmUp only run for delegated targets.
  • Tests. EntryGasDelta legs are same-intrinsic and same-code (STOP both sides), so the 3000/100 deltas isolate the entry charge. The designatePrecompile: true case is a real guard for the IsPrecompile check, not just the warm disjunct: without it the identity precompile would run and add its 15-gas base cost, making the delta 115 instead of 100. DeployContract(Recipient, STOP) in the precompile test is a necessary side effect of sharing the helper (the baseline used to take the codeless path) and preserves the asserted 2900.

Findings

Low — the new test pins the gas half of the fix, not the BAL half (FrameTxProcessorTests.cs:367-375) — Execute_FrameGasCoveringOnlyTheTargetAccess_FailsOnTheDelegateAccess asserts Status/GasUsed, both of which were already correct after a4e1a4a; it would still pass with line 521 reverted to followDelegation: true, so nothing local guards what 422e82e changed. The fixture already has the pattern twice — Execute_Secp256k1SignatureOnly_DoesNotRecordP256PrecompileInBal (line 889) is the same "must not appear in the BAL" shape — so bal.GetAccountChanges(Recipient) is null on this transaction is a few lines away. Acknowledged as covered upstream, hence Low. Inline

Informational — pre-existing, and the reason the precompile test reads cleanly. A frame targeting a precompile directly never executes it: codeless on line 475 is GetCodeHash(target) == Keccak.OfAnEmptyString, which is true for every precompile address, so the frame takes the DEFAULT default-code path at 499 and spends only the 100-gas entry access. That is why Execute_FrameTargetingPrecompile_PaysWarmEntryAccess sees exactly 2900 with no identity base cost. Untouched by this PR and possibly intended for frames, but if create_evm_from_frame builds an EVM for a precompile target it is a divergence worth a separate look — the designation path is now careful about precompiles while the direct path silently skips them.

Not verified

dotnet build / dotnet test require approval in this environment, so I could not reproduce the reported 5125 passed or the fixture results; the above is static analysis against CALL and the top-level path. The gas arithmetic in the precompile-designation test (100 vs 115) is derived, not measured.
· branch marc/frame-delegation-entry-charge

Comment thread src/Nethermind/Nethermind.Evm.Test/FrameTxProcessorTests.cs
The frame gas boundary case asserts status and gas, both already correct
before the read was moved behind its charge; the block access list is
what the move changed.
@Marchhill

Copy link
Copy Markdown
Contributor Author

@claude review

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Marchhill's task in 4m 45s —— View job


Re-review: charge a frame for resolving its target's delegation

  • Read the diff against origin/frames-devnet8-integration
  • Verified the new BAL test (8571050) pins what it claims
  • Re-checked entry-charge arithmetic and ordering vs CALL / top-level
  • Checked GetCachedCodeInfoNoDelegation + IsPrecompile semantics down to the repository
  • Checked warming / revert semantics
  • Posted findings

Verdict: no Critical/High/Medium findings. Two Low notes, both posted inline. Mergeable.

The last round's Low is addressed

8571050 adds Execute_FrameGasCoveringOnlyTheTargetAccess_LeavesTheDesignatedAccountOutOfTheBal, and it does pin the read-ordering commit rather than the gas commit. Traced it end to end: with line 521 back on followDelegation: true, CodeInfoRepository.GetCachedCodeInfo (line 71-77) runs InternalGetCodeInfo(delegation)_worldState.GetCodeHash(Recipient)TracedAccessWorldState.RecordReadAndGetChanges, so bal.GetAccountChanges(Recipient) is non-null and the new assertion fails — exactly the divergence, asserted directly. The Observer half is guaranteed by the GetCodeHash on line 475, and nothing else in the transaction touches Recipient (Sender/Observer/Recipient/Beneficiary are four distinct TestItem addresses, base fee 0, priority fee to Beneficiary).

Verified this round

I went a level deeper than the previous rounds on the repository plumbing, since 422e82e moved the code fetch:

  • followDelegation: false still yields the designation. CodeInfoRepository.cs:61-77 assigns delegationAddress before the followDelegation branch, so the new call site gets the address without following it. Confirmed on the concrete implementation, and CacheCodeInfoRepository / PrecompileCachedCodeInfoRepository both forward the four-arg overload unchanged.
  • The IsPrecompile guard is genuinely required. CodeInfoRepository.cs:62-67 returns PrecompileCodeInfo(codeSource) for a precompile codeSource regardless of followDelegation, so GetCachedCodeInfoNoDelegation(delegation) on a designated precompile would hand the VM a precompile CodeInfo. The old followDelegation: true call could not reach that, because InternalGetCodeInfo bypasses the precompile table — so this is a new requirement created by fetching the code separately, not a cosmetic addition.
  • Step-for-step parity with both existing sites. EvmInstructions.Call.cs:160-196 and TransactionProcessor.cs:1216-1244 now read the same as FrameTx.cs:521-544: fetch target with followDelegation: false → charge the designation access → bail before touching the designee → AddAccountReadIsPrecompile ? CodeInfo.Empty : GetCachedCodeInfoNoDelegation. AddAccountRead sitting before the IsPrecompile branch matches both.
  • CALL charges the designation before NEW_ACCOUNT, this path after. Unobservable, and moot regardless: a delegated account is never dead, so entryState is 0 whenever delegation is not null. Both failure shapes return gasUsed = frame.GasLimit, stateGasUsed = 0.
  • Gas arithmetic still agrees with EthereumGasPolicy.ConsumeAccountAccessGas (lines 231-252) in every cold/warm × self/precompile combination; delegation != resolvedTarget is what makes the self-designation row match, because the policy's WarmUp-then-test order warms the target inside its own charge while this path defers WarmUp to line 572. No overflow, and frame.GasLimit - entryCharge at 577 is guarded by the check at 529.
  • Revert semantics unchanged: WarmUp(delegation) after frameTracker.TakeSnapshot() (568), discarded by Restore() at 594; on the entry-OOG return neither address is warmed.
  • !UseHotAndColdStorage: no charge but the code is still loaded, same as CALL, whose load also sits outside that guard. Chained designations still resolve one hop, as on the CALL path.
  • The new comment on line 468 documents pre-existing behaviour (the funding check precedes the entry charge, so an underfunded frame pays nothing) and matches the code. Note it is the opposite of CALL, where the access charge lands before the balance check — fine if execute_frame orders it that way, and not something this PR changes.
  • Performance: followDelegation: false plus a null check on a path that already reads the target's code; the extra IsPrecompile / WarmUp / AddAccountRead only run for delegated targets.

Findings

Low — WorldState.AddAccountRead(delegation) is load-bearing only in the precompile branch, and that branch has no BAL assertion (TransactionProcessorBase.FrameTx.cs:540) — for a normal designation the read is recorded anyway by the GetCodeHash inside GetCachedCodeInfoNoDelegation; for a designated precompile the repository call is skipped entirely, so line 540 is the only thing putting that address in the BAL. Deleting it leaves every test green — the precompile designation [TestCase] asserts gas only — while dropping a BAL entry in production. Same class of hole 8571050 just closed, one branch over. The line itself is correct and matches Call.cs:190. Inline

Low — the traced-processor setup is now a third verbatim copy (FrameTxProcessorTests.cs:387-391, also 934-938 and 974-978) — five identical lines, which is what AGENTS.md asks to extract, and the same treatment this PR already applied via EntryGasDelta. The two older copies are outside the PR's scope, so leaving it is defensible. Inline

Not verified

dotnet build / dotnet test require approval in this environment, so I could not reproduce the reported 5125 passed or the fixture results. Everything above is static analysis against the repository implementations, CALL, and the top-level path; the branch-by-branch AddAccountRead reasoning in the first finding is derived from CodeInfoRepository.cs:62-99 and PrecompileCachedCodeInfoRepository.cs:34-41, not measured.
· branch marc/frame-delegation-entry-charge

Comment thread src/Nethermind/Nethermind.Evm.Test/FrameTxProcessorTests.cs Outdated
The precompile branch asks the repository for nothing, so the explicit
read is the only thing recording the designated account. Share the
traced-processor setup while adding its third caller.
@Marchhill

Copy link
Copy Markdown
Contributor Author

@claude review

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Marchhill's task in 5m 7s —— View job


Re-review: charge a frame for resolving its target's delegation

  • Read the diff against origin/frames-devnet8-integration
  • Verified 48c0e80 (designated-precompile BAL test) pins what it claims
  • Verified the TracedProcessor() extraction is behaviour-preserving at all four call sites
  • Re-checked entry-charge arithmetic, ordering, warming, revert semantics
  • Checked BAL semantics for precompiles against the rest of the repo
  • Posted findings

Verdict: no Critical/High/Medium findings. Every prior round's finding is resolved. One readability nit, posted inline. Mergeable.

The last round's Low is closed

48c0e80 adds Execute_FrameTargetDesignatesAPrecompile_RecordsThePrecompileInTheBal, and it does guard the branch that had nothing on it. Traced why the assertion is load-bearing: with a designated precompile, spec.IsPrecompile(delegation) short-circuits to CodeInfo.Empty (FrameTx.cs:541), so no repository call happens and neither CodeInfoRepository.GetCachedCodeInfo's own AddAccountRead (line 63, precompile fast path — reached only for a direct codeSource) nor InternalGetCodeInfoGetCodeHash runs. WorldState.AddAccountRead(delegation) on line 540 is the sole recorder, so deleting it turns exactly this test red and nothing else. The assertion is also non-vacuous: nothing else in the transaction touches 0x…04 (the SelfVerifyFrame sender-approval path never enters a precompile, and the frame executes empty code).

I also checked the assertion is the right expectation for this repo rather than an accident: there is no precompile filtering anywhere in TracedAccessWorldState or Nethermind.Core/BlockAccessLists, and CodeInfoRepository.cs:63 records a BAL read for every direct precompile call — so "accessed precompiles are BAL-included" is the repo-wide model, and Execute_Secp256k1SignatureOnly_DoesNotRecordP256PrecompileInBal (the unaccessed case) is its mirror image, not a contradiction.

The TracedProcessor() extraction

Behaviour-preserving at all four sites, including the two that predate the PR: same construction order (TracedAccessWorldStateSetGeneratingBlockAccessListEthereumCodeInfoRepository(tracedState)EthereumVirtualMachine → processor), same parallel: false, and the _stateProvider.Commit/CommitTree that has to precede the traced state at line 952 stays at the call site rather than moving into the helper. The remaining duplication across the four is the block build + Execute + BAL fetch, which genuinely differs (beneficiary set or not, different assertions) — extracting that too would be over-extraction, so stopping here is the right line.

Re-confirmed on the unchanged code

  • Gas. The charge still agrees with EthereumGasPolicy.ConsumeAccountAccessGas (EthereumGasPolicy.cs:231-252) in every cold/warm × self/precompile combination. The delegation != resolvedTarget term is what makes the self-designation row match: the policy's WarmUp-then-test order warms the target inside its own charge, while this path defers WarmUp to line 572 so a reverting frame can discard it. Eip8038Constants.WarmAccess == GasCostOf.WarmStateRead (Eip8038Constants.cs:9), so the hardcoded constant cannot diverge from what the policy charges. No overflow: entryCharge ≤ frame.GasLimit at line 493 plus ≤3000.
  • Parity with both existing sites, step for step: fetch target with followDelegation: false → charge the designation access → bail before touching the designee → AddAccountReadIsPrecompile ? CodeInfo.Empty : GetCachedCodeInfoNoDelegation. Matches EvmInstructions.Call.cs:163-196 and TransactionProcessor.cs:1216-1244, AddAccountRead before the IsPrecompile branch included. The policy also offers ConsumeAccountAccessGasWithDelegation (EthereumGasPolicy.cs:217), which would collapse both charges into one call — but it warms as it charges, i.e. before frameTracker.TakeSnapshot(), so it is correctly not used here.
  • Ordering vs NEW_ACCOUNT. CALL charges the designation before NEW_ACCOUNT, this path after. Unobservable (same total, both failures return gasUsed = frame.GasLimit, stateGasUsed = 0) and moot regardless: a delegated account has code, so it is never dead and entryState is 0 whenever delegation is not null.
  • Revert / warming. WarmUp(delegation) sits after frameTracker.TakeSnapshot() (568), so Restore() at 594 discards it with the target's; on the entry-OOG return neither address is warmed. StackAccessTracker.WarmUp is readonly, so the in accessTracker copy is fine.
  • No leak on the new bail-out. The OOG return at 532 is before ExecutionEnvironment.Rent (548), so the new failure path rents nothing it must return.
  • !UseHotAndColdStorage: no charge, code still loaded — same as CALL, whose load also sits outside that guard. Chained designations still resolve one hop only, as on the CALL path.
  • Tests. EntryGasDelta legs are intrinsic-identical (frame target addresses never enter CalculateTokensInCallData) and both execute zero-cost code, so the 3000 / 100 deltas isolate the entry charge; the designatePrecompile: true case would read 115 rather than 100 if the IsPrecompile guard were missing and identity actually ran, so it guards dispatch, not just the warm disjunct.
  • Performance. followDelegation: false plus one null check on a path that already reads the target's code; the extra IsPrecompile / AddAccountRead / WarmUp run only for delegated targets.

Findings

Low / nit — the two delegation is not null blocks could be one (TransactionProcessorBase.FrameTx.cs:524-544) — CALL splits them because the NEW_ACCOUNT charge sits between; here entryState is folded in at line 492, so nothing separates them and the second null test is redundant. Merging makes "charged before read" one visual unit; keeping them apart preserves line-by-line symmetry with CALL. Either is fine — no defect. Inline

Informational — unchanged, and still unresolved from the previous round. A frame targeting a precompile directly never executes it: codeless on line 475 is GetCodeHash(target) == Keccak.OfAnEmptyString, true for every precompile account, so the frame takes the DEFAULT default-code path at 499 and spends only the 100-gas entry access — nothing in the frame path dispatches a precompile target (grep IsPrecompile over the file finds only the three access-cost/guard sites). Pre-existing on the base branch and out of scope here; worth a separate look only if create_evm_from_frame is meant to build an EVM for a precompile target, since the designation path is now careful about precompiles while the direct path silently skips them.

Not verified

dotnet build / dotnet test require approval in this environment (the build attempt was blocked), so I could not reproduce the reported 5125 passed or the fixture results. Everything above is static analysis against CodeInfoRepository, EthereumGasPolicy, CALL, and the top-level path; the "deleting line 540 turns only the new test red" claim is derived from CodeInfoRepository.cs:59-99 and confirmed by @Marchhill's own run, not measured here.
· branch marc/frame-delegation-entry-charge

@Marchhill

Copy link
Copy Markdown
Contributor Author

@claude review

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Marchhill's task in 2m 26s —— View job


Re-review: charge a frame for resolving its target's delegation

  • Read the diff against origin/frames-devnet8-integration
  • Verified 567a580 (the fold) is behaviour-preserving
  • Re-checked entry-charge arithmetic, ordering, warming, revert semantics
  • Re-checked parity with CALL and the top-level path on the current file
  • Re-checked the tests
  • Posted findings

Verdict: no Critical/High/Medium findings. Every finding from the previous four rounds is resolved, and the only new commit is a pure renesting. Mergeable.

567a580 is behaviour-identical

The diff is exactly a renesting — the outer delegation is not null now guards both halves and the inner spec.UseHotAndColdStorage guards only the charge, where before the two blocks tested delegation is not null && spec.UseHotAndColdStorage and delegation is not null separately. The three properties that could have moved did not:

  • Order — the charge and its entryCharge > frame.GasLimit return are at 528-535, still ahead of WorldState.AddAccountRead(delegation) at 540 and the load at 541-543. The reference order (target access → resolve_delegated_code_addressget_code) holds.
  • Arithmetic — same expression over the same operands (delegation != resolvedTarget && IsCold && !IsPrecompile), so no gas moves in any combination.
  • !UseHotAndColdStorage — still reaches the load with no charge, which is the one case the fold could plausibly have broken by hoisting the guard. It didn't: the guard wraps only lines 527-536.

frameTracker.WarmUp(delegation) is untouched at 572, still after TakeSnapshot() at 568, so Restore() at 594 discards it on a reverting frame.

Re-confirmed on the current file

  • Parity with CALL, step for step. EvmInstructions.Call.cs:161-196 and FrameTx.cs:521-543 now read the same: charge the target's access → fetch target code with followDelegation: false → charge the designation access and bail → AddAccountRead(delegated)IsPrecompile ? CodeInfo.Empty : GetCachedCodeInfoNoDelegation. AddAccountRead before the IsPrecompile branch included, so a designated precompile is recorded on both paths.
  • Gas. EthereumGasPolicy.cs:238-251 is the rule this reproduces, and line 246 is why the delegation != resolvedTarget term is needed: WarmUp(address) && !IsPrecompile(address) warms the target inside its own charge, so a self-designation is already warm when the policy charges it, while this path defers WarmUp to 572 so a revert can discard it. GetColdAccountAccessCost (257) resolves to the same Eip8038Constants.ColdAccountAccess the tests assert, and GasCostOf.WarmStateRead == Eip8038Constants.WarmAccess, so the hardcoded warm constant cannot diverge on a non-8038 spec.
  • ConsumeAccountAccessGasWithDelegation (EthereumGasPolicy.cs:217) would collapse both charges into one call, but it warms as it charges — i.e. before TakeSnapshot() — so it is correctly not used here.
  • Reachability of the branch. A delegated account's code hash is the keccak of its 23 designation bytes, never Keccak.OfAnEmptyString, so codeless at 475 is false and the delegation block is reached; the same fact makes IsDeadAccount(resolvedTarget) false, so entryState is 0 whenever delegation is not null and the NEW_ACCOUNT-ordering difference against CALL (which charges the designation before NEW_ACCOUNT) is moot as well as unobservable.
  • No leak on the new bail-out. The OOG return at 533-534 precedes ExecutionEnvironment.Rent (548), so it rents nothing it must return. No overflow: entryCharge ≤ frame.GasLimit at 493 plus ≤3000, and frame.GasLimit - entryCharge at 577 is guarded by 531.
  • Chained designations still resolve one hop only (GetCachedCodeInfoNoDelegation hands back the raw designation bytes), matching the CALL path.
  • A non-codeless VERIFY frame targeting a delegated account falls through 478 into the same entry path and so pays the designation access too — consistent, since it also goes through create_evm_from_frame.
  • Performance. followDelegation: false plus one null check on a path that already reads the target's code; the extra IsPrecompile / AddAccountRead / WarmUp run only for delegated targets.
  • Tests. Unchanged since the last round apart from the four TracedProcessor() call sites, which preserve construction order and keep the _stateProvider.Commit/CommitTree at the call site where it has to precede the traced state. EntryGasDelta's two legs are intrinsic-identical (frame target addresses never enter CalculateTokensInCallData) and both execute zero-cost code, so the 3000 / 100 deltas isolate the entry charge. The BAL pair covers both halves the gas assertions cannot see: …LeavesTheDesignatedAccountOutOfTheBal fails if 521 goes back to followDelegation: true, and …RecordsThePrecompileInTheBal fails if line 540 is deleted.

Findings

None new. Both Lows from the previous round are closed (TracedProcessor() extracted; the designated-precompile BAL entry pinned), and the nit about the two delegation is not null blocks is what 567a580 implements.

Informational — unchanged, pre-existing, out of scope. A frame targeting a precompile directly still never executes it: codeless at 475 is true for every precompile account, so the frame takes the DEFAULT default-code path at 499 and spends only the 100-gas entry access. IsPrecompile appears in this file only at the two access-cost sites and the designation guard, so nothing dispatches a precompile target. Present on the base branch; worth a separate look only if create_evm_from_frame is meant to build an EVM for a precompile target, since the designation path is now careful about precompiles while the direct path silently skips them.

Not verified

dotnet build / dotnet test require approval in this environment (the build attempt was blocked again), so I could not reproduce the reported 5125 passed or the fixture results. Everything above is static analysis against EthereumGasPolicy, CodeInfoRepository/ICodeInfoRepository, EvmInstructions.Call.cs, and the top-level path.
· branch marc/frame-delegation-entry-charge

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