Skip to content

feat(frames): catch frames-devnet-0 up to the EIP-8141 reference (execution-specs #3396) - #12918

Open
AnkushinDaniil wants to merge 25 commits into
feature/frames-devnet-0from
daniil/frames-8141/spec-catchup
Open

feat(frames): catch frames-devnet-0 up to the EIP-8141 reference (execution-specs #3396)#12918
AnkushinDaniil wants to merge 25 commits into
feature/frames-devnet-0from
daniil/frames-8141/spec-catchup

Conversation

@AnkushinDaniil

Copy link
Copy Markdown
Contributor

Changes

Catch the 8141-only frames-devnet-0 line up to the latest EIP-8141 reference (execution-specs PR #3396) and make it pass the EEST fixtures built from that PR (200/200). Twenty-one commits on top of feature/frames-devnet-0, each compiling on its own.

Grouped by theme:

  • Fork wiring — define Bogota as Amsterdam + EIP-8141; parse the "Bogota" spec name.
  • Wire format — decode the frame-tx fees field as a nested list.
  • Gas model — intrinsic 12000 and per-value-frame transfer cost 6000; track per-frame gas used in FrameTxContext for FRAMEPARAM 0x0A/0x0B; add TXPARAM 0x0C (STATE_GAS_LEFT).
  • Block inclusion (EIP-8037) — reserve a frame tx by its exact per-dimension budgets (execution = max(intrinsic + Σexec, floor), state = Σstate) instead of a worst-case from the combined gas limit.
  • Settlement — subtract an unrolled atomic batch's state gas from both accumulators so it does not leak into the execution dimension; apply an APPROVE payment before the frame receipt so a sender-creating approval charges NEW_ACCOUNT state gas to the approving frame and warms the payer.
  • Receipts — a frame tx has no transaction-level status; derive both status and logs from the frame receipts.

Types of changes

  • Bugfix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)

Testing

  • 200/200 blockchain fixtures built from execution-specs PR Update Fast Sync configuration in Nethermind repository #3396 (fill --fork Bogota).
  • 288/288 unit tests matching FrameTx | Eip8141 | Eip8037.
  • Full Nethermind.Evm.Test suite: 5147 total, 0 failed.
  • rebase --exec build check: every one of the 21 commits compiles independently.

Notes

Isolated branch; not merged into feature/frames-devnet-0. Diagnostics removed; no other-EIP surface introduced.

AnkushinDaniil and others added 21 commits August 17, 2026 10:39
Split each frame's single gas_limit into limits = [execution, state] to
match the merged ethereum/EIPs#12062 spec update. Encoding, static
validation, intrinsic budget, FRAMEPARAM introspection and the JSON-RPC
view now carry both dimensions; runtime remains combined-pool
(execution + state) pending the explicit dual-pool follow-up.

(cherry picked from commit 40aa68a)
Seed each frame's execution pool from limits.execution and its state-gas
reservoir from limits.state, so state work is drawn independently of the
execution budget. The per-frame charge folds execution gas used plus the
reservoir-funded state gas, keeping the payer charge and the block state/
regular split unchanged. Add the MAX_VERIFY_STATE_GAS public-mempool bound
(sum of the validation prefix limits.state) alongside MAX_VERIFY_GAS.

(cherry picked from commit 232c4f8)
Our devnet-0 base charges frame entry gas (cold account access, EIP-8037
new-account cost); the merged PR-12847 dual-pool runtime was written against
the family branch, which does not. Charge the entry cost through
TryConsumeStateAndExecutionGas on the seeded frame budget so cold access draws
limits.execution and the new-account cost draws limits.state (spilling into
execution), leaving the per-frame gasUsed/stateGasUsed read straight off the
policy as PR-12847 does.
…ests fixture

The fee-collector regression test mutates _spec, absent from the 8141-only
fixture, so the project did not compile. Wrap the prototype in an
OverridableReleaseSpec as the sibling FrameTxBlockGasTests fixture does.
An exceptionally halted payload frame (a fresh SSTORE followed by INVALID)
consumed state gas from its reservoir and then rolled the write back, but the
frame processor left ResetForHalt to the ordinary transaction processor and
never called it, so the reverted state usage was billed to the payer and added
to the block state dimension. Snap the frame's state gas back to its seed on
the error path, matching the ordinary halt path, so gas_used is the execution
budget alone and the frame owes zero state gas. Adds a regression test.
Within a frame transaction EthereumGasPolicy applied the EIP-8037 reservoir
spill, so a state charge exceeding limits.state was paid from limits.execution
and the frame could commit state its state budget could not afford. EIP-8141
makes the two pools independent: a charge exceeding its pool halts, and
execution gas is never spent on state charges. Add an IndependentStatePool flag
seeded by FromFrameLimits and propagated to child call frames; ConsumeStateGas
halts instead of spilling when it is set. Envelope EIP-8037 behaviour is
unchanged. Observation frames in the frame tests declare a state budget for
their recording writes, and a regression test proves the no-spill halt.
…a floor

max_gas and settlement folded the calldata floor over the whole transaction,
so a frame tx whose floor exceeded its execution component under-reserved
max_cost and was charged only the floor, silently dropping the state gas.
Reserve max(standard_gas_limit, calldata_floor_gas + sum(limits.state)) and
settle tx_execution_gas = max(gas_after_refund - tx_state_gas, calldata_floor_gas)
with gas_used = tx_execution_gas + tx_state_gas, so the floor binds on the
execution dimension alone; this also applies the storage refund to the block
execution dimension. Adds a regression test.
EIP-8141 encodes each frame's gas_used as a two-element [execution, state] list
mirroring the frame's limits, but the receipt carried a single scalar, so every
frame-transaction receipt hashed to a different receipts root than a conforming
client even when execution agreed. Split TxFrameReceipt into ExecutionGasUsed
and StateGasUsed, encode gas_used as a nested sequence in the network, storage,
and compact receipt decoders, and populate both dimensions from the frame's
execution and state usage. A batch-unrolled frame keeps its execution gas but
reports zero state gas, per unroll_atomic_batch.
…d it

EIP-8141 attributes every SSTORE state charge to the frame that pays it and,
when a later frame reverses that slot, refunds the state gas by reducing the
paying frame's receipt rather than the reversing frame's. The receipt carried
the charge on whichever frame happened to reverse the slot, so a cross-frame
reversal produced a different receipts root than a conforming client.

Track the outstanding SSTORE-charge owner per (address, slot) on the frame
context and journal it, together with the per-frame gas_used.state corrections,
at the same rollback boundaries as world state: the EVM call revert/halt seams,
a frame revert/halt, and an atomic-batch unroll. On a reversal, reduce the
owner frame's gas_used.state and, only when the owner is the current frame,
credit its state_gas_left; a cross-frame reversal never credits the reversing
frame. At settlement the accumulated correction drops both the gross and the
state dimension by the same amount, so a refill lowers gas_used directly and
outside the EIP-3529 refund cap.

Adds regression tests: a cross-frame reversal reduces the creating frame's
state gas and not the reversing frame's; a reversal in a frame that reverts, or
in an inner call that reverts, leaves the creating frame's state gas intact.
…seed

Three defense-in-depth fixes on the frame-transaction decode and gas-policy
seams flagged in review, none reachable under a validated block:
- saturate each frame's execution+state limit before summing them into the
  transaction-level GasLimit, so a per-frame limit that overflows ulong no
  longer wraps to a small value that pre-execution consumers under-count;
- check the frame limits sub-list length unconditionally, so an over-long
  limits list is rejected rather than silently shifting the parse when a caller
  passes AllowExtraBytes;
- saturate the state reservoir seed in FromFrameLimits so a state limit above
  long.MaxValue, reachable only through an unvalidated eth_call, cannot seed a
  negative reservoir.
A frame-entry state charge, account creation on a value transfer or sender
creation by APPROVE, was metered against limits.state but allowed to spill the
shortfall into limits.execution, so a frame declaring too little state gas was
admitted and, on the codeless sender path, transferred value and created the
account. EIP-8141 meters every state-dependent charge inside limits.state and
fails the frame when it is exhausted, with no spill, since the pools are
independent within a frame transaction. Seed the frame policy once and consume
the entry charge through it, failing the frame on an underfunded state budget
and removing the duplicated affordability rule the VM entry consume had drifted
from. Adds a regression test; the transfer scenarios now declare the
new-account state budget the spec requires.
Adopts EIP-8141 revision ethereum/EIPs#12187: signature-byte copy moves
off SIGPARAM's param 0x04 into a dedicated SIGDATACOPY (0xb5) opcode so
SIGPARAM's stack effect is static. SIGPARAM now accepts param 0x00-0x03
only, and 0x03 (len(signature)) is restricted to ARBITRARY entries.
SIGDATACOPY copies an ARBITRARY entry's raw bytes with CALLDATACOPY
semantics; a non-ARBITRARY scheme or out-of-bounds index halts.
Bogota is the frames-devnet-0 target fork. Base it on Amsterdam (not
Osaka) and register the "Bogota" spec name so the frame-transaction
fixtures resolve to it.
EIP-8141 encodes the transaction fees as a nested list rather than flat
fields. Decode the nested structure so spec wire vectors round-trip.
Catch up to the EIP-8141 gas revision (ethereum/EIPs#12062): the frame
transaction intrinsic is 12000, and each value-bearing frame targeting a
foreign account adds a 6000 value-transfer cost to the mandatory gas.
FRAMEPARAM 0x0A/0x0B report a completed frame's execution and state gas,
so record each frame's gas in the transaction context.
Add the state-gas-left transaction parameter (TXPARAM 0x0C) and the
completed-frame execution/state-gas frame parameters (FRAMEPARAM
0x0A/0x0B), guarded to frames before the current one.
A frame transaction reserves execution and state gas separately:
execution = max(intrinsic + sum of execution budgets, floor), state =
sum of state budgets. Reserve by dimension instead of the legacy
worst-case from the combined gas limit.
A frame transaction has no transaction-level status (ethereum/EIPs#12061):
both status and log set come from the frame receipts. Derive Logs
alongside the already-derived StatusCode so a failed-frame receipt keeps
its logs and the receipts root is unchanged.
- dispatch a frame targeting a precompile without 7702 delegation
- resolve 7702 delegation and charge delegate access at frame entry
- on atomic-batch unroll, drop the batch's state gas from both the state
  dimension and the combined accumulator, so it does not leak into the
  execution dimension of the header
- apply a payment approval before the frame receipt is built, creating
  the sender and charging NEW_ACCOUNT from the frame's state budget
- derive the transaction status from the frames (MarkAsSuccess/Failed)
Update the frame-transaction tests to the EIP-8037/8141 revisions:
intrinsic 12000, precompile-target dispatch cost, and explicit state-gas
budgets for state-touching frames (SSTORE, dead-account transfer, deploy).
@claude

claude Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Claude finished @AnkushinDaniil's task in 12m 0s —— View job


Review: EIP-8141 spec catch-up (frames-devnet-0)

  • Gather context (fetch diff, read source)
  • Review gas model / 2D-gas correctness
  • Review block inclusion (EIP-8037) + settlement
  • Review RLP wire format changes
  • Review receipts / RPC surface
  • Post findings

Well-structured change overall — the two-dimensional plumbing is coherent, the state-gas ownership journal is correctly hooked into every rollback boundary I could find, and the reservation math is a sound upper bound on settlement. Findings below; details are in the inline comments.

1 High, 5 Medium, 6 Low.


High

H1 — a post-hoc approval failure leaves the frame's state committed. TransactionProcessorBase.FrameTx.cs:204-217

ExecuteFrame rolls back based on the substate it returns (line 587). When TryApplyApproval fails afterwards, frameSucceeded is flipped to false and the substate replaced, but nothing restores the frame's snapshot or frameTracker. For a non-VERIFY, non-batch frame that writes state and then APPROVEs payment with a nonexistent sender and insufficient limits.state: the writes stay committed while the receipt reports StatusFailure and drops the frame's logs, the frame is charged actual gas rather than the full limits.execution an exceptional halt owes, and the warm/cold touches persist. State root and receipts diverge. VERIFY frames are masked (a failed VERIFY aborts the tx) and batch frames are masked by the unroll — everything else is exposed. → inline

Medium

M1 — a reverted frame now owes its entry NEW_ACCOUNT state gas. TransactionProcessorBase.FrameTx.cs:585

The old line forced 0 for ShouldRevert || IsError; the new one resets only for IsError. On a top-level revert RefundRevertedTopLevelStateGas refunds down to InitialStateGasUsed, which is entryState now that the entry charge is drawn through the frame policy — so a value > 0 frame targeting a dead account that reverts bills NEW_ACCOUNT into blockStateGas although line 589 undid the creation. Contradicts the halt path's own invariant and the nested-*CALL credit in VirtualMachine.cs:305-310. Execute_PayloadFrameReverts_OwesNoStateGas misses it (zero-value frame ⇒ entryState == 0). → inline

M2 — the one unclamped stateGasCorrection subtraction can wrap ulong. TransactionProcessorBase.FrameTx.cs:354

Lines 349 and 357 both clamp; line 354 does not. The clamp on 349 is itself an admission that a correction may exceed the state gas it reverses. If it ever does in aggregate, grossGas wraps to ~2^64 ⇒ spentGas ~2^64 ⇒ payer gets no refund of the pre-charged MaxCost and the beneficiary is credited premiumPerGas * spentGas. Ether creation from an arithmetic wrap deserves a clamp or a hard invariant check, not an implicit argument. Suggested diff in the inline. → inline

M3 — Target == sender is not "self-transfer" outside SENDER mode. FrameTxValidation.cs:383

The 6000 value-transfer charge is skipped whenever the target resolves to the sender. That reasoning holds only for ModeSender (caller == sender); in DEFAULT/VERIFY mode the caller is ENTRY_POINT, so such a frame is a real transfer priced as free — as is any DEFAULT frame with Target is null. The PR description states the rule with no target qualifier, which is what makes me think the reference keys on frame.value != 0 alone. Please confirm against execution-specs#3396 and add a TestCase either way. → inline

M4 — the EIP-7825 per-tx cap is silently dropped for frame txs in the inclusion check. BlockAccessListManager.Validation.cs:104-124

The non-frame branch keeps Math.Min(Eip7825Constants.DefaultTxGasLimitCap, txGas); the frames branch is uncapped. This is a block-validation rule, so if the reference keeps the cap we reject blocks other clients accept. The added comment justifies the exactness of the per-dimension budgets but says nothing about the cap. (Two things do check out: frameIntrinsic + totalFrameExecution cannot overflow, since TryCalculateGasBudget already rejected any wrapping total; and the reservation bounds the settlement's blockExecutionGas/blockStateGas.) → inline

M5 — the RPC receipt surface was not split alongside the tx surface. Nethermind.JsonRpc/Data/FrameReceiptForRpc.cs:18,23

FrameForRpc gained executionGasLimit/stateGasLimit, but FrameReceiptForRpc still exposes a single gasUsed fed by the combined TxFrameReceipt.GasUsed. The spec's frame receipt is [status, [execution, state], logs], so the state dimension is invisible over JSON-RPC and a client cannot reconcile a receipt against the header's state dimension. Asymmetric with the wire and RLP changes in the same PR.

Low

  • L1(long)frame.StateGasLimit at lines 206 and 577 goes negative for StateGasLimit > long.MaxValue, while FromFrameLimits clamps (EthereumGasPolicy.cs:53). Only the block-inclusion state-dimension check makes this unreachable in consensus today; clamp at the cast for local reasoning.
  • L2TxFrame.GasLimit has no production consumer left after this PR and silently wraps. → inline
  • L3IGasPolicy.FromFrameLimits's static virtual default is dead: EthereumGasPolicy is the only implementation and it overrides. → inline
  • L4 — the receipt-storage RLP format changed (gas_used scalar → nested list) with no version guard, so frame receipts already in a devnet DB throw on decode. Fine for an unreleased line; worth a note in the PR body so operators wipe rather than debug.
  • L5VmState.StateGasJournalCheckpoint defaults to 0 on RentTopLevel. Safe today only because HandleRevert/PopAndRestoreParentState are unreachable for a top-level state (VirtualMachine.cs:237 returns first) — a restore that ever reached one would wipe earlier frames' ownership records. Worth an assert or a comment pinning the reasoning.
  • L6FrameTxVerifyGasFilter reuses AcceptTxResult.FrameTxVerifyGasTooHigh and Metrics.PendingTransactionsFrameTxVerifyGasTooHigh for the new state-gas bound, so the two rejection reasons are indistinguishable in metrics.

Verified as correct

  • State-gas journal restore coverage. RestoreStateGasJournal is wired into HandleRevert, TryChargeAndDepositCode, and PopAndRestoreParentState; both callers of the last are failure handlers, so a successful child call never loses its records. Frame-level and batch-level restores in the loop cover the top-level cases the VM leaves alone.
  • Batch unroll bookkeepingtotalFrameGasUsed -= (totalStateGas - batchStartStateGas) plus RestoreStateGasJournal(batchStartJournal) correctly prevents a batch frame from remaining a refill owner for a post-batch frame.
  • Reservation soundnessmax(intrinsic + Σexec, floor) / Σstate bounds settlement in both dimensions, and floorReservation = floorGas + totalStateGas keeps spentGas <= maxGas so the payer refund cannot underflow.
  • IndependentStatePool propagationCreateChildFrameGas is the only child-gas path, and it forwards the flag; the other factories are regular-tx entry points.
  • RLP nested fees / limits — encoder lengths, StartSequence, and Check boundaries are consistent on both sides.
  • BloomBlockReceiptsTracer overwrites Logs before the parallel bloom pass, so the derived log set reaches the receipts root.

Note on the PR description

"no other-EIP surface introduced" doesn't quite hold: ExecuteFrame gains EIP-7702 delegation resolution at frame entry (extra access charge for the designated address, precompile-through-designation suppressed, frameTracker.WarmUp(delegated)). Presumably intended spec catch-up, but it's unlisted and worth its own line in the body — and its own test, which I don't see in the diff.


mergeable: false — H1 is a state/receipt divergence, and M1-M5 are unacknowledged.
· branch daniil/frames-8141/spec-catchup

Comment on lines +204 to +217
if (frameSucceeded && frameContext.ApprovalScopeSignal != 0)
{
long remainingStateBudget = (long)frame.StateGasLimit - frameStateGas;
if (!TryApplyApproval(frameContext, resolvedTarget, spec, in accessTracker, remainingStateBudget, out long approvalStateGas))
{
frameSucceeded = false;
substate = new TransactionSubstate(EvmExceptionType.OutOfGas, tracer.IsTracingInstructions);
}
else
{
frameStateGas += approvalStateGas;
frameGasUsed += (ulong)approvalStateGas;
}
}

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.

High — a post-hoc approval failure leaves the frame's state committed.

ExecuteFrame decides whether to roll back from the substate it computed (if (substate.ShouldRevert || substate.IsError) { WorldState.Restore(snapshot); frameTracker.Restore(); }, line 587). By the time TryApplyApproval returns false here, ExecuteFrame has already returned success, so nothing restores the snapshot or the access-tracker touches.

Result for a non-VERIFY, non-batch frame that writes state and then APPROVEs payment while the sender account does not exist and the frame's remaining limits.state is below NEW_ACCOUNT:

  • the frame's storage writes / balance changes stay committed,
  • its receipt says StatusFailure and its logs are dropped (frameLogs = []), so the receipt/bloom no longer describes the state that was actually applied,
  • frameGasUsed is the amount actually consumed rather than the full limits.execution an exceptional halt owes,
  • the warm/cold journal keeps the frame's touches.

The VERIFY case is masked because a failed VERIFY frame aborts the whole tx via WorldState.Restore(txSnapshot); batch frames are masked by the unroll. Everything else diverges from the reference on both state root and receipts.

Cheapest fix that keeps one rollback boundary: decide the approval inside ExecuteFrame, before its ShouldRevert || IsError restore — or take a snapshot around the frame here and restore it (plus frameTracker) when TryApplyApproval fails, and charge frame.ExecutionGasLimit.

Fix this →

Comment on lines +574 to +585
if (substate.IsError)
{
// EIP-8141 (ethereum/EIPs#12062): an exceptionally halted frame grows no state and owes zero state gas.
TGasPolicy.ResetForHalt(ref state.Gas, (long)frame.StateGasLimit, 0);
}

ulong combinedLimit = frame.ExecutionGasLimit + frame.StateGasLimit;
gasUsed = substate.IsError
? combinedLimit - (ulong)Math.Max(0, TGasPolicy.GetStateReservoir(in state.Gas))
: TGasPolicy.GetPreRefundGas(in state.Gas, combinedLimit);
// Clamp: a reverted or errored frame carries no non-negativity guarantee on its state gas.
stateGasUsed = Math.Max(0, TGasPolicy.GetStateGasUsed(in state.Gas));

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 — a reverted frame now owes its entry NEW_ACCOUNT state gas.

The old code was stateGasUsed = substate.ShouldRevert || substate.IsError ? 0 : entryState + GetStateGasUsed(...). The reset above only covers IsError; for ShouldRevert the value now comes straight from the policy.

Trace a top-level revert: entryState is drawn through TryConsumeStateAndExecutionGas (line 487) before RentTopLevel, so InitialStateGasUsed == entryState. PrepareTopLevelSubstateRefundRevertedTopLevelStateGas refunds only down to that floor (RefundStateGas(..., stateGasFloor: InitialStateGasUsed)), so GetStateGasUsed is entryState, not 0.

So a frame with value > 0 targeting a dead account that then REVERTs is billed NEW_ACCOUNT state gas into totalStateGasblockStateGas, even though WorldState.Restore(snapshot) on line 589 undid the creation. That contradicts the "a reverted frame commits no state, so it grows none" invariant the halt path upholds, and it is inconsistent with the nested-call treatment in VirtualMachine.cs:305-310, which explicitly credits NEW_ACCOUNT back for a reverted *CALL.

Execute_PayloadFrameReverts_OwesNoStateGas does not catch it — its frame carries zero value, so entryState == 0. Worth either extending ResetForHalt to the revert case or adding the missing test case if the reference really does keep the charge.

Fix this →

Comment on lines +342 to +360
long stateGasCorrection = frameContext.TotalStateGasCorrection;
for (int f = 0; f < frameReceipts.Length; f++)
{
long correction = frameContext.StateGasCorrectionFor(f);
if (correction > 0)
{
TxFrameReceipt corrected = frameReceipts[f];
ulong reducedState = corrected.StateGasUsed > (ulong)correction ? corrected.StateGasUsed - (ulong)correction : 0;
frameReceipts[f] = new TxFrameReceipt(corrected.Status, corrected.ExecutionGasUsed, reducedState, corrected.Logs);
}
}

ulong grossGas = intrinsicGas + totalFrameGasUsed - (ulong)stateGasCorrection;
ulong gasAfterRefund = grossGas - RefundHelper.CalculateClaimableRefund(grossGas, (ulong)refundCounter, spec);
// EIP-8141 (ethereum/EIPs#12062): the calldata floor bounds the execution dimension alone, and state gas is added on top rather than absorbed. State gas is clamped since later-frame refills can drive the running total negative.
ulong blockStateGas = (ulong)Math.Max(0, totalStateGas - stateGasCorrection);
ulong executionAfterState = gasAfterRefund > blockStateGas ? gasAfterRefund - blockStateGas : 0;
ulong blockExecutionGas = Math.Max(executionAfterState, floorGas);
ulong spentGas = blockExecutionGas + blockStateGas;

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 — the one unclamped subtraction of stateGasCorrection can wrap ulong.

Both neighbouring uses of the correction defend themselves:

  • line 349 — corrected.StateGasUsed > (ulong)correction ? ... : 0
  • line 357 — (ulong)Math.Max(0, totalStateGas - stateGasCorrection)

but line 354 does not. The clamp on line 349 is an explicit statement that a per-frame correction may exceed that frame's recorded state gas; if that ever happens across the whole set, intrinsicGas + totalFrameGasUsed - (ulong)stateGasCorrection wraps to ~2^64, and the fallout is not a wrong number in a log line:

  • gasAfterRefund ≈ 2^64, executionAfterState ≈ 2^64, spentGas ≈ 2^64;
  • chargedCost > maxCost, so the payer gets no refund of the pre-charged MaxCost;
  • WorldState.AddToBalanceAndCreateIfNotExists(header.GasBeneficiary!, premiumPerGas * (UInt256)spentGas, spec) credits the beneficiary against a fabricated gas figure — ether creation.

A long cast of stateGasCorrection that turns out negative (a journal restore outrunning its record) lands in the same place. Given the blast radius, this deserves the same Math.Max(0, ...) treatment as line 357, or a hard invariant check that fails the transaction.

Suggested change
long stateGasCorrection = frameContext.TotalStateGasCorrection;
for (int f = 0; f < frameReceipts.Length; f++)
{
long correction = frameContext.StateGasCorrectionFor(f);
if (correction > 0)
{
TxFrameReceipt corrected = frameReceipts[f];
ulong reducedState = corrected.StateGasUsed > (ulong)correction ? corrected.StateGasUsed - (ulong)correction : 0;
frameReceipts[f] = new TxFrameReceipt(corrected.Status, corrected.ExecutionGasUsed, reducedState, corrected.Logs);
}
}
ulong grossGas = intrinsicGas + totalFrameGasUsed - (ulong)stateGasCorrection;
ulong gasAfterRefund = grossGas - RefundHelper.CalculateClaimableRefund(grossGas, (ulong)refundCounter, spec);
// EIP-8141 (ethereum/EIPs#12062): the calldata floor bounds the execution dimension alone, and state gas is added on top rather than absorbed. State gas is clamped since later-frame refills can drive the running total negative.
ulong blockStateGas = (ulong)Math.Max(0, totalStateGas - stateGasCorrection);
ulong executionAfterState = gasAfterRefund > blockStateGas ? gasAfterRefund - blockStateGas : 0;
ulong blockExecutionGas = Math.Max(executionAfterState, floorGas);
ulong spentGas = blockExecutionGas + blockStateGas;
long stateGasCorrection = frameContext.TotalStateGasCorrection;
for (int f = 0; f < frameReceipts.Length; f++)
{
long correction = frameContext.StateGasCorrectionFor(f);
if (correction > 0)
{
TxFrameReceipt corrected = frameReceipts[f];
ulong reducedState = corrected.StateGasUsed > (ulong)correction ? corrected.StateGasUsed - (ulong)correction : 0;
frameReceipts[f] = new TxFrameReceipt(corrected.Status, corrected.ExecutionGasUsed, reducedState, corrected.Logs);
}
}
// The correction can never exceed the state gas it reverses, but clamp rather than wrap: a
// wrapped grossGas would skip the payer refund and over-credit the beneficiary.
ulong grossCharge = intrinsicGas + totalFrameGasUsed;
ulong correctionApplied = stateGasCorrection > 0 ? (ulong)stateGasCorrection : 0;
ulong grossGas = grossCharge > correctionApplied ? grossCharge - correctionApplied : 0;
ulong gasAfterRefund = grossGas - RefundHelper.CalculateClaimableRefund(grossGas, (ulong)refundCounter, spec);
// EIP-8141 (ethereum/EIPs#12062): the calldata floor bounds the execution dimension alone, and state gas is added on top rather than absorbed. State gas is clamped since later-frame refills can drive the running total negative.
ulong blockStateGas = (ulong)Math.Max(0, totalStateGas - stateGasCorrection);
ulong executionAfterState = gasAfterRefund > blockStateGas ? gasAfterRefund - blockStateGas : 0;
ulong blockExecutionGas = Math.Max(executionAfterState, floorGas);
ulong spentGas = blockExecutionGas + blockStateGas;

Comment on lines +382 to +386
// EIP-8141 (ethereum/EIPs#12062): a value-bearing frame with an explicit target other than the sender pays a flat value-transfer cost.
if (!frame.Value.IsZero && frame.Target is not null && !frame.Target.Equals(transaction.SenderAddress))
{
valueTransferCost += Eip8141Constants.ValueTransferGasCost;
}

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 — Target == sender is not the same as "self-transfer" outside SENDER mode.

The guard skips the 6000 charge whenever the target resolves to the sender, on the reasoning that such a frame moves value to itself. That only holds for ModeSender, where caller == sender (TransactionProcessorBase.FrameTx.cs:186). For ModeDefault / ModeVerify the caller is Eip8141Constants.EntryPointAddress, so a value-bearing frame whose target is the sender is an ENTRY_POINT → sender transfer — a real transfer, priced here as free.

Two sub-cases both fall through the same hole:

  • frame.Target is not null && frame.Target.Equals(SenderAddress) in DEFAULT mode;
  • frame.Target is null (resolves to the sender) in DEFAULT mode — excluded by the is not null clause.

The PR description states the rule as a flat "per-value-frame transfer cost 6000" with no target qualifier, which is what makes me suspect the reference keys purely on frame.value != 0. If that is right, this under-reserves and under-charges those frames and diverges on gas. (It is not masked by the ENTRY_POINT balance check on line 449 — that reverts the frame, but the static budget is still computed from the frame layout and still feeds intrinsicGas/maxGas.)

Please confirm against execution-specs#3396 which predicate the reference uses, and add a TestCase for a value-bearing DEFAULT frame targeting the sender either way.

Comment on lines +104 to +124
if (tx.SupportsFrames && tx.Frames is not null && FrameTxValidation.TryCalculateGasBudget(tx, spec, out ulong frameIntrinsic, out ulong frameFloor, out _))
{
// EIP-8141 frames declare exact per-dimension budgets: the execution reservation is the
// intrinsic execution plus the frames' execution budgets (bounded by the calldata floor);
// the state reservation is the frames' state budgets.
ulong totalFrameExecution = 0;
ulong totalFrameState = 0;
foreach (TxFrame frame in tx.Frames)
{
totalFrameExecution += frame.ExecutionGasLimit;
totalFrameState += frame.StateGasLimit;
}

ulong executionReservation = Math.Max(frameIntrinsic + totalFrameExecution, frameFloor);
outcome = Eip8037BlockGasInclusionCheck.Validate(
block.Header.GasLimit,
cumulativeExecution,
cumulativeState,
executionReservation,
totalFrameState);
}

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 — the EIP-7825 per-tx cap is silently dropped for frame transactions.

The else branch still goes through Validate(..., txGas), which applies Math.Min(Eip7825Constants.DefaultTxGasLimitCap, txGas) to the execution reservation. The frames branch bypasses that entirely: executionReservation = Math.Max(frameIntrinsic + totalFrameExecution, frameFloor), uncapped.

That makes the check strictly more demanding for a frame tx whose intrinsic + Σlimits.execution exceeds TX_GAS_LIMIT_CAP — and this is CheckPerTxInclusion, i.e. a block-validation rule. If the reference keeps the cap here, we reject blocks other clients accept, which is the worse failure direction. Please state the reference behaviour in the comment (the current comment justifies the exactness of the budgets but is silent on the cap) and, if the cap does apply, restore it.

Two things that do check out, for the record: frameIntrinsic + totalFrameExecution cannot overflow, because TryCalculateGasBudget already rejected any tx whose intrinsic + Σ(execution + state) wraps; and the reservation is a sound upper bound on the settlement's blockExecutionGas / blockStateGas.

Comment on lines +18 to +24
/// <summary>
/// Seeds an EIP-8141 frame budget from its two-dimensional <c>limits = [execution, state]</c>: the execution
/// dimension funds <c>gas_left</c> and the state dimension funds the state reservoir. Pre-EIP-8037 policies,
/// having no state dimension, fall back to a single combined budget.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
static virtual TSelf FromFrameLimits(ulong executionGasLimit, ulong stateGasLimit) =>

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 — dead default implementation.

EthereumGasPolicy is the only struct ... : IGasPolicy<...> in the tree and it overrides FromFrameLimits, so this body never runs. The "pre-EIP-8037 policies fall back to a single combined budget" fallback it documents has no subject.

Not wrong, just extra interface surface for a hypothetical implementor — and AGENTS.md ranks "adds code without adding surface area" above this. If it stays, the saturating branch is worth a one-line note on why saturation (rather than rejection) is the right failure mode, since CalculateGasBudget rejects the same overflow a few layers up.

Comment on lines +45 to +48
/// <summary>The combined gas the frame reserves against the payer, <c>limits.execution + limits.state</c>.</summary>
/// <remarks>Static validation rejects a transaction whose frame reservations overflow, so this sum never
/// wraps for a frame reaching execution.</remarks>
public ulong GasLimit => ExecutionGasLimit + StateGasLimit;

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 — this property has no production consumer left, and its stated invariant is not enforced where it is read.

After this PR the only remaining reads of TxFrame.GasLimit are in tests; every production site now uses ExecutionGasLimit / StateGasLimit explicitly (FrameTxDecoder computes its own saturating sum, FrameTxValidation and ExecuteFrame add the two components with their own overflow handling). AGENTS.md asks for code a change leaves unused to be removed — and a silently-wrapping ulong sum that only tests exercise is exactly the surface that grows a real caller later.

The <remarks> is also load-bearing but unverifiable at the read site: IsWellFormed does reject a per-frame overflow, but it is CalculateGasBudget (not IsWellFormed) that gates ExecuteFrameTx. They happen to make the same check — worth saying so explicitly if the property stays.

Same shape applies to TxFrameReceipt.GasUsed, which does still have one consumer — see the FrameReceiptForRpc point in the review summary.

Address review of PR #12918. A payment APPROVE that must create a
non-existent sender charges NEW_ACCOUNT from the approving frame's state
pool; a pool that cannot cover it halts the frame, and the reference
(ethereum/EIPs#12062 attempt_approval) discards every approval effect.
The approval ran after ExecuteFrame had already committed the frame, so a
shortfall left the frame's writes, logs, and warm touches committed while
its receipt reported failure. Move the approval inside ExecuteFrame's
rollback boundary, and defer every context mutation past the budget check
so a shortfall applies nothing. A fail-first regression proves it: a
second sponsor sets the payer so the transaction stays valid and the
divergence is observable rather than masked by the unset-payer revert.

Two defensive corrections to the same accounting:
- a reverted frame resets its state gas like an exceptional halt, so it
  never carries an entry NEW_ACCOUNT charge (the reference rolls the entry
  charge back on revert; no reachable change for coded targets today).
- clamp the state-gas correction subtraction so a correction exceeding the
  running total cannot wrap ulong.
The lint gate fails the build on IDE0005 for the unused Nethermind.State
directive.
feature/frames-devnet-0 no longer built on its own, so every PR against it
was red regardless of its own diff. None of this is a frame change; it is
folded into PR #12918 to turn CI green.

- Eth72ProtocolHandlerTests: pass the LightTransaction ctor's TxType, which
  it gained without this call site being updated (CS7036).
- Eth68ProtocolHandlerTests: the Eth68 handler now takes an
  IChainHeadSpecProvider, not an ISpecProvider (CS1503).
- drop an unused using in FrameTxVerifyDosMeasurement and a duplicate one in
  TxPoolSourceTests, and an unused using in FrameTxProducerRetryMeasurement
  (IDE0005 / CS0105 fail the lint gate).
- suppress the SSH.NET GHSA-q939-rpr3-3284 audit for Nethermind.IntegrationTests
  only. The advisory is a ScpClient recursive-download path traversal in a
  test-only transitive dependency (via Testcontainers) that the suite never
  exercises; the fixed 2026.0.0 forces a repo-wide BouncyCastle major bump,
  out of scope here.
if (frameSucceeded && frameContext.ApprovalScopeSignal != 0)
{
long remainingStateBudget = (long)frame.StateGasLimit - frameStateGas;
if (!TryApplyApproval(frameContext, resolvedTarget, spec, in accessTracker, remainingStateBudget, out long approvalStateGas))

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.

When TryApplyApproval returns false the frame is relabelled as an exceptional halt, but ExecuteFrame has already returned and committed it, so its storage writes, balance moves and warm/cold touches stay while the receipt says StatusFailure and drops the frame's logs. That is a state-root and receipts-root divergence, and the frame is charged actual gas rather than its execution limit. Restoring a snapshot taken around the frame, or deciding the approval inside ExecuteFrame, fixes both.

Snapshot frameSnapshot = WorldState.TakeSnapshot();
StackAccessTracker frameApprovalTracker = accessTracker;
frameApprovalTracker.TakeSnapshot();
// ... ExecuteFrame ...
if (!TryApplyApproval(frameContext, resolvedTarget, spec, in accessTracker, remainingStateBudget, out long approvalStateGas))
{
    WorldState.Restore(frameSnapshot);
    frameApprovalTracker.Restore();
    frameGasUsed = frame.ExecutionGasLimit;
    frameStateGas = 0;
    frameSucceeded = false;
    substate = new TransactionSubstate(EvmExceptionType.OutOfGas, tracer.IsTracingInstructions);
}

_codeInfoRepository.GetCachedCodeInfo(resolvedTarget, followDelegation: false, vmSpec: spec, delegationAddress: out delegated);
if (spec.UseHotAndColdStorage && delegated is not null)
{
entryExecution += accessTracker.IsCold(delegated) && !spec.IsPrecompile(delegated)

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 designation charge reads IsCold(delegated) before the target has been warmed, so a target delegating to itself is priced cold twice. ConsumeAccountAccessGas warms first, which makes the regular CALL path charge cold plus warm for the identical access, so the two paths disagree on gas_used.execution by ColdAccountAccess - WarmAccess. Treating a designation back to the target as warm aligns them.

bool delegatedCold = !delegated.Equals(resolvedTarget)
    && accessTracker.IsCold(delegated)
    && !spec.IsPrecompile(delegated);
entryExecution += delegatedCold
    ? (spec.IsEip8038Enabled ? Eip8038Constants.ColdAccountAccess : GasCostOf.ColdAccountAccess)
    : Eip8038Constants.WarmAccess;

}
else
{
tracer.MarkAsFailed(Eip8141Constants.EntryPointAddress, in gasConsumed, [], error: null);

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 new failure branch passes error: null, so SimulateTxTracer renders "execution reverted: " for a frame transaction that never reverted, and BuildFailedReceipt stores a null Error on a receipt whose status is failure. MarkAsFailed also carries no logs, so every tracer except BlockReceiptsTracer loses the frame-derived log set it used to get. A concrete reason string fixes the first half.

tracer.MarkAsFailed(Eip8141Constants.EntryPointAddress, in gasConsumed, [],
    error: "frame transaction has a failed frame");

{
bool ssetOutOfGas = !TGasPolicy.ConsumeStorageWrite<TEip8037, OnFlag>(ref gas, spec);
if (ssetOutOfGas) goto OutOfGas;
FrameTxContext? chargeFrameCtx = vm.TxExecutionContext.FrameTxContext;

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 ownership recording added to the SSTORE charge branch is not behind TEip8037.IsActive, although the reversal branch that is its only reader is. On specs without EIP-8037 the JIT therefore still emits the context load, the null check and the call on every fresh zero-slot write instead of deleting them. Wrapping it in if (TEip8037.IsActive) matches the reversal branch.

if (TEip8037.IsActive)
{
    FrameTxContext? chargeFrameCtx = vm.TxExecutionContext.FrameTxContext;
    chargeFrameCtx?.RecordStateChargeOwner(in storageCell, chargeFrameCtx.CurrentFrameIndex);
}

/// </summary>
public void RecordStateChargeOwner(in StorageCell slot, int frame)
{
int previousOwner = _stateChargeOwner.TryGetValue(slot, out int existing) ? existing : NoOwner;

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.

Both ownership methods hash and probe the StorageCell key twice — TryGetValue then an indexer set, and TryGetValue then Remove — on a path driven directly by SSTORE. CollectionsMarshal.GetValueRefOrAddDefault and Remove(key, out value) collapse each to one operation, and the dictionary is a plain single-threaded Dictionary so both are safe here.

public void RecordStateChargeOwner(in StorageCell slot, int frame)
{
    ref int owner = ref CollectionsMarshal.GetValueRefOrAddDefault(_stateChargeOwner, slot, out bool exists);
    _stateGasJournal.Add(new StateGasJournalEntry(StateGasJournalKind.OwnerSet, slot, exists ? owner : NoOwner, 0));
    owner = frame;
}

public bool TryResolveStateChargeOwner(in StorageCell slot, out int owner)
{
    if (!_stateChargeOwner.Remove(slot, out owner)) return false;
    _stateGasJournal.Add(new StateGasJournalEntry(StateGasJournalKind.OwnerCleared, slot, owner, 0));
    return true;
}

…d fees wire

Three tests still encoded the pre-catch-up shape and failed on CI:

- FrameTxDecoderTests hand-built payloads carried the three fee scalars
  flat; the decoder now reads fees as a nested list, so a padded payload
  hit the fee sequence-prefix check before the trailing-signature guard.
- The two max-gas gating tests (TxPool picker and block-production picker)
  fixed their boundary case to the old 15,000 intrinsic; with the
  spec-correct 12,000 intrinsic the 115,000 frame now fits the 130,000
  block, so the reject/skip case needed a higher limit.

No production change; the wire and intrinsic are already covered by the
#3396 fixtures (200/200).

@flcl42 flcl42 left a comment

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.

Found 5 issues (2 medium and 3 low) in src/Nethermind/Nethermind.Core/FrameTxValidation.cs, src/Nethermind/Nethermind.Core/TxFrameReceipt.cs, src/Nethermind/Nethermind.Specs/Forks/Bogota.cs, src/Nethermind/Nethermind.Core.Test/Encoding/FrameTxDecoderTests.cs, and 1 other file.

valueTransferCost += Eip8141Constants.ValueTransferGasCost;
}

ulong frameGas = frame.ExecutionGasLimit + frame.StateGasLimit;

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] Scalar admission rejects independently fitting frame gas dimensions

This budget now adds execution and state limits together for payer escrow, while EIP-8037 validation admits a frame transaction when each dimension independently fits. The unchanged GasLimitTxFilter and block-production picker compare this combined value with one scalar block limit, so an empty 30M block rejects a transaction reserving about 4M execution and 27M state even though received-block validation accepts it.

public ulong ExecutionGasUsed { get; } = executionGasUsed;

/// <summary>State gas attributed to the frame (<c>gas_used.state</c>) after all refills and rollbacks.</summary>
public ulong StateGasUsed { get; } = stateGasUsed;

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] Frame receipt RPC output collapses the state-gas dimension

For any frame that consumes state gas, the consensus receipt now records separate execution and state values, but FrameReceiptForRpc still assigns their sum to a single gasUsed field. Consequently eth_getTransactionReceipt cannot represent the [execution, state] pair emitted by the RLP receipt, and receipts with identical totals but different splits serialize identically; the adapted RPC test uses zero state gas and checks only status.

/// <c>bogotaTime</c>, a step after <c>amsterdamTime</c>. Not scheduled on any public network.
/// </summary>
public class Bogota() : NamedReleaseSpec<Bogota>(Osaka.Instance)
public class Bogota() : NamedReleaseSpec<Bogota>(Amsterdam.Instance)

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] The Bogota genesis guidance still targets Osaka

Bogota now inherits Amsterdam, but GethGenesisConfigJson.BogotaTime is still documented as “frame-tx opcodes over Osaka.” Because hard-fork labels expand only their own Apply delta, an Osaka-plus-Bogota genesis following that guidance is accepted with EIP-8141 enabled while Amsterdam’s EIP-8037 remains disabled, which does not match Bogota.Instance or the new two-dimensional frame gas behavior.

Rlp.Encode(0L), // max_priority_fee_per_gas
Rlp.Encode(0L), // max_fee_per_gas
Rlp.Encode(0L), // max_fee_per_blob_gas
Rlp.Encode(Rlp.Encode(0L), Rlp.Encode(0L), Rlp.Encode(0L)), // fees

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] The trailing-signature test still documents a nine-field payload

Nesting the three fee scalars on this line reduces the valid payload to seven top-level elements, but the comment immediately above still says both the payload and strict clients read exactly nine. That contradicts the exact-element invariant the padding test is intended to preserve and can misdirect future decoder changes.

0x07 => stack.PushUInt32<TTracingInst>((uint)(frame.IsAtomicBatch ? 1 : 0)),
0x08 => stack.PushUInt256<TTracingInst>(frame.Value),
0x09 => stack.PushUInt256<TTracingInst>((UInt256)frame.StateGasLimit),
0x0A => FrameExecutionGasUsed<TTracingInst>(ctx, index, ref stack),

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] Frame gas-usage introspection parameters have no test coverage

FRAMEPARAM 0x0A and 0x0B (per-frame execution and state gas-used reads) and TXPARAM 0x0C (state gas left) are introduced here but have no corresponding test case; the FRAMEPARAM parameterized test stops at 0x09 and the TXPARAM test stops at 0x0B. FRAMEPARAM 0x0B returns StateGasUsedFor, which nets the cross-frame refill correction (_frameStateGasCorrection) that this change adds, so an inverted sign or a clamping error in that correction would be observable only through these untested reads. The SIGPARAM 0x03 restriction to the ARBITRARY scheme (non-ARBITRARY now halts instead of returning a length) is likewise untested. A test that creates a slot in one frame, reverses it in a later frame, and reads FRAMEPARAM 0x0B from a third frame would pin the corrected value.

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