Skip to content

feat(l1): bal-devnet-7 v7.2.0#6671

Merged
edg-l merged 13 commits into
mainfrom
bal-devnet-7-v7.2.0-inline-refund
May 21, 2026
Merged

feat(l1): bal-devnet-7 v7.2.0#6671
edg-l merged 13 commits into
mainfrom
bal-devnet-7-v7.2.0-inline-refund

Conversation

@edg-l

@edg-l edg-l commented May 18, 2026

Copy link
Copy Markdown
Contributor

Aligns ethrex with bal-devnet-7 v7.2.0.

Tracking: #6583 (item 10).

Changes

  • Port feat(tests, spec-specs): eip8037 sstore/collision clear dynamics ethereum/execution-specs#2863: inline state_gas refunds (SSTORE 0→x→0, CREATE collision/revert, EIP-7702 auth refills) credit the local frame's reservoir immediately. state_gas_used becomes i64; the clamp-and-spill scaffolding (state_gas_refund_pending, state_gas_refund_absorbed, state_gas_spill_outstanding, state_gas_credit_against_drain, intrinsic-tracking field + frame snapshots) is removed.
  • Bump fixtures and eels_commit to tests-bal@v7.2.0 / a3e5201a5.
  • Preserve intrinsic state gas on top-level error: split out a dedicated vm.intrinsic_state_gas so the error path refunds only the execution portion. Without this, EIP-7702 set-code reverts and CREATE-tx reverts underbilled block_state_gas_used by AUTH_TOTAL × CPSB / NEW_ACCOUNT × CPSB.
  • default_hook.rs state_refund i64 cast: propagate InternalError::Overflow instead of silently saturating (match vm.rs).
  • engine_newPayloadV{4,5}: return JSON-RPC -32602 on fork/field mismatch (V4 with BAL field or Amsterdam timestamp; V5 missing BAL field or pre-Amsterdam timestamp). Was returning PayloadStatus.INVALID.

Test plan

  • cargo check, clippy, fmt: clean across ethrex-levm, ethrex-rpc, ethrex-vm, ethrex-blockchain, ethrex-l2.
  • make -C tooling/ef_tests/blockchain test-levm against tests-bal@v7.2.0: 8726 passed, 0 failed.
  • Hive eels/consume-engine Amsterdam: pending re-run; engine-API fix targets the 2 remaining test_fork_transition failures.

…et-7 v7.2.0)

EIP-8037 inline state_gas refunds (SSTORE 0->x->0 restoration, CREATE
collision/revert, EIP-7702 auth refills) now credit the local frame's
reservoir immediately instead of being clamped to local state_gas_used
and deferred via state_gas_refund_pending. Ports execution-specs#2863
(fuzz-driven correction; EELS/nimbus/nethermind clamped, reth was correct).

state_gas_used becomes signed (i64): can go negative when an inline refund
matches a charge that lives in an ancestor frame. The signed sum carries
refunds up through incorporate_child_on_success automatically. Drops the
clamp-and-spill scaffolding (state_gas_refund_pending,
state_gas_refund_absorbed, state_gas_spill_outstanding,
state_gas_credit_against_drain, intrinsic_state_gas_charged, plus 4 frame
snapshots).
@github-actions github-actions Bot added the L1 Ethereum client label May 18, 2026
@github-actions

github-actions Bot commented May 18, 2026

Copy link
Copy Markdown

⚠️ Known Issues — intentionally skipped tests

Source: docs/known_issues.md

Known Issues

Tests intentionally excluded from CI. Source of truth for the Known
Issues
section the L1 workflow appends to each ef-tests job summary
and posts as a sticky PR comment.

EF Tests — Stateless coverage narrowed to EIP-8025 optional-proofs

make -C tooling/ef_tests/blockchain test calls test-stateless-zkevm
instead of test-stateless. The zkevm@v0.3.3 fixtures are filled against
bal@v5.6.1, out of sync with current bal spec; the broad target trips ~549
fixtures. Re-broaden once the zkevm bundle is regenerated.

Why and resolution path

PR #6527 broadened
test-stateless to extract the entire for_amsterdam/ tree from the
zkevm bundle and run all of it under --features stateless; combined with
this branch's bal-devnet-7 semantics that scope produces ~549
GasUsedMismatch / ReceiptsRootMismatch /
BlockAccessListHashMismatch failures.

test-stateless-zkevm filters cargo to the eip8025_optional_proofs
suite, which still validates the stateless harness without the bal-version
mismatch.

Re-broaden by switching test: back to test-stateless in
tooling/ef_tests/blockchain/Makefile once the zkevm bundle is regenerated
against the current bal spec.

@github-actions

github-actions Bot commented May 18, 2026

Copy link
Copy Markdown

Lines of code report

Total lines added: 55
Total lines removed: 89
Total lines changed: 144

Detailed view
+-----------------------------------------------------+-------+------+
| File                                                | Lines | Diff |
+-----------------------------------------------------+-------+------+
| ethrex/crates/common/types/block.rs                 | 978   | +17  |
+-----------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine/payload.rs      | 1033  | +38  |
+-----------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/call_frame.rs             | 374   | -8   |
+-----------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/hooks/default_hook.rs     | 490   | -4   |
+-----------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/opcode_handlers/system.rs | 949   | -36  |
+-----------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/utils.rs                  | 628   | -3   |
+-----------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/vm.rs                     | 702   | -38  |
+-----------------------------------------------------+-------+------+

@github-actions

github-actions Bot commented May 18, 2026

Copy link
Copy Markdown

Benchmark Results Comparison

No significant difference was registered for any benchmark run.

Detailed Results

Benchmark Results: BubbleSort

Command Mean [s] Min [s] Max [s] Relative
main_revm_BubbleSort 2.961 ± 0.013 2.942 2.985 1.07 ± 0.01
main_levm_BubbleSort 2.790 ± 0.025 2.771 2.854 1.00 ± 0.01
pr_revm_BubbleSort 2.985 ± 0.030 2.950 3.031 1.07 ± 0.01
pr_levm_BubbleSort 2.778 ± 0.012 2.765 2.800 1.00

Benchmark Results: ERC20Approval

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Approval 981.2 ± 10.9 972.5 1009.3 1.00 ± 0.02
main_levm_ERC20Approval 1058.1 ± 14.0 1047.3 1095.4 1.08 ± 0.02
pr_revm_ERC20Approval 980.0 ± 10.2 963.8 1001.5 1.00
pr_levm_ERC20Approval 1063.6 ± 21.5 1046.1 1121.9 1.09 ± 0.02

Benchmark Results: ERC20Mint

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Mint 135.2 ± 2.0 133.5 140.1 1.01 ± 0.02
main_levm_ERC20Mint 162.4 ± 0.9 161.2 164.4 1.21 ± 0.01
pr_revm_ERC20Mint 134.3 ± 1.1 133.1 136.4 1.00
pr_levm_ERC20Mint 162.5 ± 1.1 161.1 165.0 1.21 ± 0.01

Benchmark Results: ERC20Transfer

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Transfer 232.4 ± 1.7 230.9 236.1 1.00 ± 0.01
main_levm_ERC20Transfer 265.8 ± 2.2 262.8 269.2 1.15 ± 0.01
pr_revm_ERC20Transfer 232.1 ± 1.8 230.4 235.1 1.00
pr_levm_ERC20Transfer 266.2 ± 3.1 261.7 272.7 1.15 ± 0.02

Benchmark Results: Factorial

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Factorial 226.4 ± 2.1 224.3 229.5 1.01 ± 0.01
main_levm_Factorial 271.2 ± 4.3 267.1 281.0 1.21 ± 0.02
pr_revm_Factorial 223.9 ± 1.0 222.9 226.0 1.00
pr_levm_Factorial 270.0 ± 2.0 267.7 273.3 1.21 ± 0.01

Benchmark Results: FactorialRecursive

Command Mean [s] Min [s] Max [s] Relative
main_revm_FactorialRecursive 1.695 ± 0.041 1.634 1.764 1.03 ± 0.03
main_levm_FactorialRecursive 1.659 ± 0.012 1.638 1.676 1.00 ± 0.01
pr_revm_FactorialRecursive 1.686 ± 0.046 1.616 1.767 1.02 ± 0.03
pr_levm_FactorialRecursive 1.653 ± 0.012 1.639 1.680 1.00

Benchmark Results: Fibonacci

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Fibonacci 206.3 ± 0.7 205.3 207.8 1.01 ± 0.01
main_levm_Fibonacci 251.6 ± 4.8 246.7 261.3 1.23 ± 0.02
pr_revm_Fibonacci 205.1 ± 1.0 203.8 207.7 1.00
pr_levm_Fibonacci 258.5 ± 4.5 251.0 264.8 1.26 ± 0.02

Benchmark Results: FibonacciRecursive

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_FibonacciRecursive 903.1 ± 9.4 890.8 919.6 1.24 ± 0.02
main_levm_FibonacciRecursive 727.2 ± 4.7 721.8 734.0 1.00 ± 0.01
pr_revm_FibonacciRecursive 890.9 ± 8.0 873.8 902.5 1.23 ± 0.01
pr_levm_FibonacciRecursive 726.6 ± 5.7 720.2 739.4 1.00

Benchmark Results: ManyHashes

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ManyHashes 8.5 ± 0.2 8.3 9.0 1.01 ± 0.03
main_levm_ManyHashes 9.8 ± 0.1 9.8 10.0 1.18 ± 0.01
pr_revm_ManyHashes 8.4 ± 0.1 8.2 8.5 1.00
pr_levm_ManyHashes 9.8 ± 0.1 9.6 10.0 1.17 ± 0.02

Benchmark Results: MstoreBench

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_MstoreBench 257.5 ± 1.1 256.4 259.8 1.06 ± 0.01
main_levm_MstoreBench 242.8 ± 0.9 241.9 244.2 1.00
pr_revm_MstoreBench 260.8 ± 2.5 257.8 264.7 1.07 ± 0.01
pr_levm_MstoreBench 243.1 ± 1.6 240.2 244.8 1.00 ± 0.01

Benchmark Results: Push

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Push 290.8 ± 8.6 286.5 315.1 1.01 ± 0.03
main_levm_Push 298.1 ± 1.2 296.7 300.4 1.03 ± 0.01
pr_revm_Push 288.9 ± 1.9 286.6 293.5 1.00
pr_levm_Push 299.6 ± 4.7 296.9 312.8 1.04 ± 0.02

Benchmark Results: SstoreBench_no_opt

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_SstoreBench_no_opt 173.9 ± 6.1 167.4 185.3 1.69 ± 0.10
main_levm_SstoreBench_no_opt 103.1 ± 5.2 100.4 117.7 1.00
pr_revm_SstoreBench_no_opt 170.8 ± 3.6 167.6 177.8 1.66 ± 0.09
pr_levm_SstoreBench_no_opt 103.6 ± 3.1 100.3 109.7 1.01 ± 0.06

edg-l added 3 commits May 18, 2026 16:49
…7 v7.2.0)

EELS keeps tx_env.intrinsic_state_gas and tx_output.state_gas_used in
separate fields; the error handler zeroes only the latter and leaves
intrinsic to be billed in block_state_gas_used. ethrex lumps both into
vm.state_gas_used, so the previous v7.2.0 port refunded the whole sum to
the reservoir and underbilled the block by intrinsic_state_gas.

Re-introduce intrinsic_state_gas as a dedicated VM field, captured in
add_intrinsic_gas. On top-level error, split state_gas_used and refund
only the execution portion to the reservoir; keep intrinsic so block
accounting matches EELS `tx_state_gas = intrinsic + state_gas_used`.

Fixes 67 ef-test failures across stSStoreTest, eip7702_set_code_tx,
eip8037_state_creation, eip7778_block_gas_accounting, and ported_static
stStackTests on tests-bal@v7.2.0 fixtures. Full suite now 8726 passed,
0 failed.
…devnet-7 v7.2.0)

Per the engine-API spec, calling engine_newPayloadV4 with an Amsterdam
payload (or one carrying the BAL field) and calling engine_newPayloadV5
with a pre-Amsterdam payload (or one missing the BAL field) MUST return
JSON-RPC error -32602, not PayloadStatus.INVALID. ethrex previously fell
through to block validation which surfaced INVALID; clients couldn't
distinguish "wrong API version" from "bad block".

Add structural checks at the top of both handlers:
- V4: reject if block_access_list is present or timestamp is Amsterdam.
- V5: reject if block_access_list is absent or timestamp is pre-Amsterdam.

Both map to RpcErr::WrongParam (-32602). Fixes 2 hive
eels/consume-engine fork-transition failures in
test_fork_transition::test_invalid_{pre,post}_fork_block_*_bal_hash_field.
@edg-l edg-l marked this pull request as ready for review May 18, 2026 16:02
@edg-l edg-l requested a review from a team as a code owner May 18, 2026 16:02
@ethrex-project-sync ethrex-project-sync Bot moved this to In Review in ethrex_l1 May 18, 2026
@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

The changes look correct. This PR updates the Amsterdam fork (EIP-8037) implementation to align with execution-specs v7.2.0 (EELS PR #2863), simplifying the state gas accounting from a complex "clamp-and-spill" mechanism to signed arithmetic.

Correctness & Safety:

  1. Signed state gas accounting (crates/vm/levm/src/vm.rs:449, crates/vm/levm/src/call_frame.rs:290):
    Changing state_gas_used to i64 and tracking only a single entry snapshot (state_gas_used_at_entry) correctly implements the v7.2.0 spec where inline refunds may locally exceed gross charges. All conversions use try_from with explicit error handling, and arithmetic uses checked_add/checked_sub, preventing overflow/underflow panics.

  2. Revert logic (crates/vm/levm/src/opcode_handlers/system.rs:1148-1167, 1220-1232):
    The incorporate_child_on_error math correctly uses signed arithmetic to return the child's net state gas consumption plus remaining reservoir to the parent. The max(0) clamp before converting back to u64 for the reservoir is spec-compliant.

  3. Engine API validation (crates/networking/rpc/engine/payload.rs:195-201, 314-320):
    Correctly enforces structural validation per the Engine API spec: V4 rejects BAL presence, V5 requires it, and fork timestamps are validated with -32602 (Invalid params) rather than INVALID payload status.

  4. Top-level failure handling (crates/vm/levm/src/vm.rs:894-905):
    The logic correctly splits the lumped state_gas_used into intrinsic (preserved for block accounting) and execution (refunded to reservoir) components on REVERT/Halt/OOG.

Minor observations:

  • Field naming consistency: The rename from intrinsic_state_gas_charged to intrinsic_state_gas (crates/vm/levm/src/vm.rs:489) is applied consistently across all usages (hooks, utils, vm).
  • Cleanup: Removal of unused std::mem import (crates/vm/levm/src/opcode_handlers/system.rs) and the five deleted snapshot fields from CallFrame reduces memory footprint per frame.

Suggestion (non-blocking):
Consider adding a debug assertion in credit_state_gas_refund (crates/vm/levm/src/vm.rs:649) verifying that amount does not exceed reasonable bounds (e.g., u64::MAX / 2), though the try_from conversions already prevent i64 overflow given Ethereum's gas limit constraints.


Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Here is my review of PR #6671:


PR #6671fix(l1): credit inline state-gas refunds to local reservoir (bal-devnet-7 v7.2.0)

Overview

This PR aligns ethrex with EELS PR #2863, replacing the "clamp-and-spill" state-gas refund model with simpler inline credits. Key changes:

  • state_gas_used changes type from u64i64 (can go negative when inline refunds exceed gross charges in a frame).
  • Five VM-level fields and four CallFrame snapshot fields are removed; replaced by a single state_gas_used_at_entry: i64 per frame.
  • credit_state_gas_refund simplifies from ~70 lines to ~10 lines.
  • intrinsic_state_gas_charged is renamed to intrinsic_state_gas with corrected semantics (stores raw intrinsic amount, not a snapshot).
  • engine_newPayloadV4/V5 now correctly return -32602 (Invalid params) on fork/field mismatch instead of PayloadStatus.INVALID.
  • Test fixtures and eels_commit bumped to tests-bal@v7.2.0.

The 332-line net deletion is a healthy signal. The 8726/0 EF test run provides strong correctness coverage.


Issues Found

1. saturating_add on a critical path in finalize_execution (vm.rs ~line 921)

self.state_refund = self.state_refund.saturating_add(new_account_refund);

All similar accumulations in this file use checked_add and propagate InternalError::Overflow. A silent saturation at u64::MAX here would corrupt block-level state_gas_used accounting. Should be:

self.state_refund = self
    .state_refund
    .checked_add(new_account_refund)
    .ok_or(InternalError::Overflow)?;

2. saturating_sub / saturating_add in the top-level error path vs removed debug_assert (vm.rs, finalize_execution)

Old code had:

debug_assert!(
    self.state_gas_used >= self.intrinsic_state_gas_charged,
    "invariant: intrinsic is a floor on state_gas_used ({} >= {})",
    ...
);

The new code uses saturating_sub and saturating_add for the same path, silently masking the case where state_gas_used < intrinsic_signed or reservoir_signed overflows:

let execution_state_gas_used = self.state_gas_used.saturating_sub(intrinsic_signed);
let reservoir_signed = i64::try_from(self.state_gas_reservoir)
    ...
    .saturating_add(execution_state_gas_used);

Given the invariant is removed, a debug_assert!(self.state_gas_used >= intrinsic_signed) would help catch regressions during testing without cost in release builds.

3. Misleading error variant in incorporate_child_on_error (system.rs)

let child_state_gas_used = self
    .state_gas_used
    .checked_sub(state_gas_used_at_entry)
    .ok_or(InternalError::Underflow)?;

For i64::checked_sub, None is returned on arithmetic overflow (result below i64::MIN), not on going negative (which is expected and correct here). The label InternalError::Underflow is misleading — InternalError::Overflow is closer to what's actually being caught. Same pattern appears in handle_return_create.

4. Unreachable error arm (system.rs, both revert paths)

self.state_gas_reservoir =
    u64::try_from(net_return.max(0)).map_err(|_| InternalError::Overflow)?;

After .max(0), net_return is guaranteed non-negative, so u64::try_from can never return Err. The .map_err(...) arm is dead code. A comment or a debug_assert!(net_return >= 0) would make the invariant explicit without the confusing implied fallibility.

5. Missing timing comment for CREATE snapshot (system.rs ~line 818)

The removed code had an explicit guard:

// EIP-8037: Save snapshot AFTER charging CREATE's account state gas
let create_state_gas_used_snapshot = self.state_gas_used;

The new code drops this comment entirely:

new_call_frame.state_gas_used_at_entry = self.state_gas_used;

The ordering invariant (snapshot taken after the CREATE account state gas charge so the child's net on revert excludes the parent's charge) is now invisible. A short comment preserving this reasoning would help future readers verify correctness without re-deriving from the EELS spec.

6. Duplicate incorporate_child_on_error logic (system.rs)

The identical 10-line block appears in both handle_return_call and handle_return_create. This duplication existed before the PR, but the new logic is nontrivial (signed arithmetic, clamping, checked operations). A shared helper (e.g., fn incorporate_child_state_gas_on_revert(&mut self, entry: i64) -> Result<(), VMError>) would reduce drift risk.


Correctness Confirmations

  • The i64 semantics for state_gas_used are correct: checked_sub in credit_state_gas_refund only returns None for actual i64 range overflow, not negative values — going negative is the intended behavior.
  • The state_gas_used_at_entry: 0 default in CallFrame::new() is safe — the field is always overwritten before it is read (only used in handle_return_call/handle_return_create for child frames).
  • The debug_assert_eq!(self.intrinsic_state_gas, 0, "intrinsic_state_gas set twice") guard is good and correctly carried over.
  • Engine API checks in payload.rs are correctly ordered: BAL field check before block parsing (fast path), Amsterdam timestamp check after block parsing (requires block.header.timestamp). Semantic correctness of -32602 vs INVALID is properly documented.
  • The intrinsic_state_gas rename (from intrinsic_state_gas_charged) is semantically correct: it now stores the raw intrinsic amount (equivalent to the old snapshot since state_gas_used starts at 0).

Summary

The PR is a clean, well-motivated simplification. The primary concern is Item 1 (silent saturation on state_refund in a block-accounting critical path) — the fix is one line. Items 2-5 are robustness/clarity issues rather than active bugs. The 8726/0 test result is reassuring but doesn't cover the saturation edge cases in Item 1.


Automated review by Claude (Anthropic) · sonnet · custom prompt

@edg-l edg-l changed the title fix(l1): credit inline state-gas refunds to local reservoir (bal-devnet-7 v7.2.0) feat(l1): bal-devnet-7 v7.2.0 May 18, 2026
@greptile-apps

greptile-apps Bot commented May 18, 2026

Copy link
Copy Markdown

Greptile Summary

This PR ports ethereum/execution-specs#2863 into ethrex's LEVM, replacing the clamp-and-spill state-gas scaffolding with a simpler inline-credit model: state_gas_used becomes i64, credits decrement it directly (possibly negative), and revert handling restores the parent reservoir via a single state_gas_used_at_entry snapshot. It also fixes the intrinsic/execution split on top-level tx failure and corrects V4/V5 engine-API error codes.

  • State-gas model rewrite (vm.rs, call_frame.rs, system.rs): five per-frame snapshot fields and three VM-level counters replaced by one i64 snapshot; credit_state_gas_refund simplified to reservoir += amount; state_gas_used -= amount.
  • Intrinsic split on failure (vm.rs, utils.rs): new intrinsic_state_gas field keeps the intrinsic portion in state_gas_used after a top-level failure, preventing EIP-7702 and CREATE-tx paths from under-billing block_state_gas_used.
  • Engine-API fix (payload.rs): V4/V5 field and fork-version mismatches now return JSON-RPC -32602 instead of PayloadStatus.INVALID.

Confidence Score: 4/5

The core accounting model is mechanically sound and fully tested (8726/8726 fixtures pass); two spots use silent saturation where the rest of the PR propagates errors explicitly.

The inline-credit simplification is well-reasoned: signed state_gas_used tracks net charges, revert math correctly restores the parent reservoir, and the intrinsic-split fix addresses a real under-billing path on failure. The only gaps are two saturating_* calls in finalize_execution that don't match the PR's own checked_* discipline.

The finalize_execution block in crates/vm/levm/src/vm.rs (lines 894-921) mixes saturating_* with the otherwise consistent checked_* pattern.

Important Files Changed

Filename Overview
crates/vm/levm/src/vm.rs Core state-gas model rewrite: state_gas_used promoted to i64, clamp-and-spill scaffolding removed; error-path arithmetic mixes saturating_* with checked_*
crates/vm/levm/src/hooks/default_hook.rs Collision and refund-sender paths updated to read net state_gas_used (i64) with .max(0) clamp; consistent with new VM fields
crates/vm/levm/src/opcode_handlers/system.rs Revert handling simplified to single state_gas_used_at_entry snapshot; checked_sub/add used correctly
crates/vm/levm/src/call_frame.rs Five snapshot fields collapsed into single state_gas_used_at_entry: i64; straightforward cleanup
crates/vm/levm/src/utils.rs intrinsic_state_gas now stores raw state_gas (not cumulative), enabling correct split on failure
crates/networking/rpc/engine/payload.rs V4/V5 handlers return -32602 for structural mismatches; correct per engine-API spec

Sequence Diagram

sequenceDiagram
    participant TX as Transaction
    participant VM as VM (finalize_execution)
    participant Hook as DefaultHook
    participant RS as refund_sender

    TX->>VM: top-level failure (REVERT/HALT/OOG)
    VM->>VM: "if !collision: reservoir += (state_gas_used - intrinsic).max(0)"
    VM->>VM: "state_gas_used = intrinsic_state_gas"
    VM->>VM: "if CREATE-tx: reservoir += new_account_refund, state_refund += new_account_refund"
    VM->>Hook: finalize_execution(ctx_result)
    alt collision
        Hook->>Hook: "state_gas = (state_gas_used - state_refund).max(0)"
        Hook->>Hook: "regular_gas = gas_limit - reservoir"
        Hook-->>TX: return early
    else non-collision
        Hook->>RS: refund_sender(vm, ctx_result)
        RS->>RS: "state_gas = (state_gas_used - state_refund).max(0)"
        RS->>RS: "regular_gas = raw_consumed - intrinsic_state - reservoir_initial - spill"
        RS-->>TX: gas accounting complete
    end
Loading
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
crates/vm/levm/src/vm.rs:898-901
The execution-portion refund calculation in the error path uses `saturating_sub` and `saturating_add` while the rest of this PR consistently propagates errors via `checked_*`. If `state_gas_used` were ever less than `i64::MIN + intrinsic_signed`, saturation would silently produce `i64::MIN` as `execution_state_gas_used` and then clamp the reservoir to zero — obscuring the accounting error rather than surfacing it.

```suggestion
                let execution_state_gas_used = self
                    .state_gas_used
                    .checked_sub(intrinsic_signed)
                    .ok_or(InternalError::Underflow)?;
                let reservoir_signed = i64::try_from(self.state_gas_reservoir)
                    .map_err(|_| InternalError::Overflow)?
                    .checked_add(execution_state_gas_used)
                    .ok_or(InternalError::Overflow)?
```

### Issue 2 of 2
crates/vm/levm/src/vm.rs:918-920
These two `saturating_add` calls on `u64` silently cap on overflow, inconsistent with the `checked_add` + `ok_or(InternalError::Overflow)` pattern used everywhere else in this PR (including in `charge_state_gas` and `credit_state_gas_refund` just above).

```suggestion
                self.state_gas_reservoir = self
                    .state_gas_reservoir
                    .checked_add(new_account_refund)
                    .ok_or(InternalError::Overflow)?;
                self.state_refund = self
                    .state_refund
                    .checked_add(new_account_refund)
                    .ok_or(InternalError::Overflow)?;
```

Reviews (1): Last reviewed commit: "chore(l1): drop devnet/PR refs from EIP-..." | Re-trigger Greptile

Comment thread crates/vm/levm/src/vm.rs
Comment on lines +898 to +901
let execution_state_gas_used = self.state_gas_used.saturating_sub(intrinsic_signed);
let reservoir_signed = i64::try_from(self.state_gas_reservoir)
.map_err(|_| InternalError::Overflow)?
.saturating_add(execution_state_gas_used);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 The execution-portion refund calculation in the error path uses saturating_sub and saturating_add while the rest of this PR consistently propagates errors via checked_*. If state_gas_used were ever less than i64::MIN + intrinsic_signed, saturation would silently produce i64::MIN as execution_state_gas_used and then clamp the reservoir to zero — obscuring the accounting error rather than surfacing it.

Suggested change
let execution_state_gas_used = self.state_gas_used.saturating_sub(intrinsic_signed);
let reservoir_signed = i64::try_from(self.state_gas_reservoir)
.map_err(|_| InternalError::Overflow)?
.saturating_add(execution_state_gas_used);
let execution_state_gas_used = self
.state_gas_used
.checked_sub(intrinsic_signed)
.ok_or(InternalError::Underflow)?;
let reservoir_signed = i64::try_from(self.state_gas_reservoir)
.map_err(|_| InternalError::Overflow)?
.checked_add(execution_state_gas_used)
.ok_or(InternalError::Overflow)?
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/vm/levm/src/vm.rs
Line: 898-901

Comment:
The execution-portion refund calculation in the error path uses `saturating_sub` and `saturating_add` while the rest of this PR consistently propagates errors via `checked_*`. If `state_gas_used` were ever less than `i64::MIN + intrinsic_signed`, saturation would silently produce `i64::MIN` as `execution_state_gas_used` and then clamp the reservoir to zero — obscuring the accounting error rather than surfacing it.

```suggestion
                let execution_state_gas_used = self
                    .state_gas_used
                    .checked_sub(intrinsic_signed)
                    .ok_or(InternalError::Underflow)?;
                let reservoir_signed = i64::try_from(self.state_gas_reservoir)
                    .map_err(|_| InternalError::Overflow)?
                    .checked_add(execution_state_gas_used)
                    .ok_or(InternalError::Overflow)?
```

How can I resolve this? If you propose a fix, please make it concise.

Comment thread crates/vm/levm/src/vm.rs Outdated
Comment on lines +918 to +920
self.state_gas_reservoir =
self.state_gas_reservoir.saturating_add(new_account_refund);
self.state_gas_refund_absorbed = self
.state_gas_refund_absorbed
.saturating_add(new_account_refund);
self.state_refund = self.state_refund.saturating_add(new_account_refund);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 These two saturating_add calls on u64 silently cap on overflow, inconsistent with the checked_add + ok_or(InternalError::Overflow) pattern used everywhere else in this PR (including in charge_state_gas and credit_state_gas_refund just above).

Suggested change
self.state_gas_reservoir =
self.state_gas_reservoir.saturating_add(new_account_refund);
self.state_gas_refund_absorbed = self
.state_gas_refund_absorbed
.saturating_add(new_account_refund);
self.state_refund = self.state_refund.saturating_add(new_account_refund);
self.state_gas_reservoir = self
.state_gas_reservoir
.checked_add(new_account_refund)
.ok_or(InternalError::Overflow)?;
self.state_refund = self
.state_refund
.checked_add(new_account_refund)
.ok_or(InternalError::Overflow)?;
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/vm/levm/src/vm.rs
Line: 918-920

Comment:
These two `saturating_add` calls on `u64` silently cap on overflow, inconsistent with the `checked_add` + `ok_or(InternalError::Overflow)` pattern used everywhere else in this PR (including in `charge_state_gas` and `credit_state_gas_refund` just above).

```suggestion
                self.state_gas_reservoir = self
                    .state_gas_reservoir
                    .checked_add(new_account_refund)
                    .ok_or(InternalError::Overflow)?;
                self.state_refund = self
                    .state_refund
                    .checked_add(new_account_refund)
                    .ok_or(InternalError::Overflow)?;
```

How can I resolve this? If you propose a fix, please make it concise.

@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

Findings

  1. payload.rs:224, payload.rs:350, auth/mod.rs:99, auth/mod.rs:120, auth/mod.rs:160, block_producer.rs:69, block_producer.rs:85
    The server is now strict about engine_newPayloadV4 vs V5, but the in-tree auth client/dev block producer is still pinned to engine_getPayloadV5 + engine_new_payload_v4 and still advertises only the old capability set. Once BAL-bearing Amsterdam/Osaka payloads are produced, that path will fetch a payload and then resubmit it to a method that now rejects it. This needs the companion client-side switch to engine_newPayloadV5 and fork-aware getPayloadV6/capability negotiation.

  2. payload.rs:208, payload.rs:224, payload.rs:334, payload.rs:350
    The new method-version gate is after get_block_from_payload(). That means a V4 request with an Amsterdam timestamp, or a V5 request with a pre-Amsterdam timestamp, can still return PayloadStatus::INVALID if tx decoding/block reconstruction fails first, instead of the intended WrongParam (-32602). Since this check only depends on self.payload.timestamp, it should run before block reconstruction so the version error cannot be masked by unrelated payload invalidity.

  3. payload.rs:183, payload.rs:198, serde_utils.rs:685
    engine_newPayloadV4 rejects BAL only after deserializing into Option<BlockAccessList>. With the current rlp_str_opt helper, blockAccessList: null and even blockAccessList: "" collapse to None, so an explicitly present BAL field can bypass the new V4 rejection. If the intent is “field must be absent”, you need to inspect raw JSON key presence before deserialization, like the V5 handler already does.

The LEVM signed state-gas refactor looked internally coherent from a static pass; my concrete concerns are on the RPC/versioning edges above. I couldn’t run cargo test in this environment because the sandbox blocks writable ~/.cargo/git dependency checkout, so this review is source-only.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

edg-l added 4 commits May 18, 2026 18:18
Address bot review on PR #6671:
- Extract the duplicated EIP-8037 revert-path math from handle_return_call
  and handle_return_create into a single VM helper.
- Switch checked_sub error label in the helper from InternalError::Underflow
  to InternalError::Overflow (i64::checked_sub only fails on range overflow).
- Drop the dead u64::try_from(...max(0)) map_err arm — non-negativity is
  proven by the .max(0) so `as u64` is sound.
- Replace remaining saturating_add on state_refund / state_gas_reservoir
  in the CREATE-tx error branch with checked_add propagating Overflow,
  matching the pattern in default_hook.rs.
- Restore the explanatory comment on the post-CREATE-charge snapshot
  ordering in CREATE/CREATE2 entry sites.
Block hash mismatch indicates the sender's hash was computed over header
fields the receiver doesn't know about (or vice versa) — a structural
payload schema error, not a block-validity failure. Per engine-API spec
this surfaces as JSON-RPC -32602, not PayloadStatus.INVALID.

Fixes the 2 remaining hive eels/consume-engine fork-transition failures
(test_invalid_{pre,post}_fork_block_*_bal_hash_field), where the test
injects/removes block_access_list_hash via rlp_modifier on the header.
The injected field changes the payload's block_hash; ethrex reconstructs
the header from V4/V5 schema and detects the mismatch.
…Payload

On block_hash mismatch, V4 also tries reconstructing the header with
block_access_list_hash injected (Some(H256::zero())) and V5 tries
reconstructing without it (None). If the alternate matches the payload's
block_hash, the sender used the wrong API version for that block's fork
— return JSON-RPC -32602 (Invalid params) per engine-API spec.

Real value-mismatch tests (BAL hash with wrong value, withdrawals_root,
requests_hash, etc.) don't match the alternate and fall through to the
usual PayloadStatus.INVALID path.

Targets the 2 hive eels/consume-engine fork-transition failures:
- test_invalid_pre_fork_block_with_bal_hash_field (V4 + zero-injected
  block_access_list_hash at FORK_TIMESTAMP-1)
- test_invalid_post_fork_block_without_bal_hash_field (V5 + removed
  block_access_list_hash at FORK_TIMESTAMP).

@ElFantasma ElFantasma 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.

The i64 transition is everywhere — try_from(u64)/try_from(i64) conversions are added at every boundary with InternalError::Overflow mapping. All bounded by tx gas_limit (well below i64::MAX), so the conversions are belt-and-suspenders in practice. Worth a one-line // state_gas_used is i64 throughout; tx gas_limit caps the magnitude well below i64::MAX comment near the field declaration so future readers don't wonder why the conversions exist.

Comment thread crates/networking/rpc/engine/payload.rs
Comment thread crates/vm/levm/src/vm.rs
Comment thread crates/networking/rpc/engine/payload.rs Outdated
@edg-l edg-l requested a review from ElFantasma May 20, 2026 14:45
Gate `block_access_list_hash` presence on Amsterdam activation across all
fork header validators. Pre-Amsterdam blocks with the field injected via
RLP (e.g. EEST consume-rlp fork-transition fixtures) were silently
accepted; now they are rejected before execution. The Amsterdam side is
also tightened: missing field is rejected as BlockAccessListHashNotPresent
instead of the looser BlockAccessListHashMismatch.

@ElFantasma ElFantasma 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.

Follow-up on the previous round. The structural header-validator addition in 4884189d05 is good (verified the routing through validate_block_pre_execution and the new InvalidBlockHeaderError variants), and the V4-Amsterdam-timestamp UnsupportedFork fix in abb964f311 is correct. But the V5 side of the same spec rule didn't get the symmetric update — one inline below.

Comment thread crates/networking/rpc/engine/payload.rs Outdated
…ngine-API spec

Symmetric with abb964f. amsterdam.md: client MUST return -38005 if
payload timestamp doesn't fall within Amsterdam time frame, in either
direction. Geth uses unsupportedForkErr for the same case.

Also cite the EELS fork-transition fixtures next to the V4/V5 alt-hash
heuristics so readers know which test pins them to InvalidParams.
@edg-l

edg-l commented May 21, 2026

Copy link
Copy Markdown
Contributor Author

Good catch, applied in deced65.

  1. V5 + pre-Amsterdam now returns UnsupportedFork (-38005); comment cites amsterdam.md verbatim. Symmetric with abb964f; geth eth/catalyst/api.go:756-757 does the same.
  2. Added EELS fixture citation at the V4 alt-hash heuristic (test_invalid_pre_fork_block_with_bal_hash_field[fork_BPO2ToAmsterdamAtTime15k-blockchain_test_engine]) and symmetric one at V5 (test_invalid_post_fork_block_without_bal_hash_field).
  3. Not flipping V5 alt-hash to UnsupportedFork: EELS test_invalid_post_fork_block_without_bal_hash_field (tests/amsterdam/eip7928_block_level_access_lists/test_fork_transition.py:148) explicitly asserts EngineAPIError.InvalidParams (-32602), so the flip would break the engine fixture. All EELS engine_api_error_code sites use InvalidParams; none use UnsupportedFork.

Verified via hive + consume engine on v7.2.0 fixtures: 21/21 amsterdam fork-transition tests, 6/6 cancun excess_blob_gas fork-transition tests.

@edg-l edg-l requested a review from ElFantasma May 21, 2026 12:35
@edg-l edg-l enabled auto-merge May 21, 2026 12:49
@edg-l edg-l added this pull request to the merge queue May 21, 2026
Merged via the queue into main with commit cf89ebb May 21, 2026
70 checks passed
@edg-l edg-l deleted the bal-devnet-7-v7.2.0-inline-refund branch May 21, 2026 14:15
@github-project-automation github-project-automation Bot moved this from In Review to Done in ethrex_l1 May 21, 2026
benbencik pushed a commit to benbencik/ethrex that referenced this pull request Jun 13, 2026
Aligns ethrex with bal-devnet-7 v7.2.0.

Tracking: lambdaclass#6583 (item 10).

## Changes

- Port ethereum/execution-specs#2863: inline `state_gas` refunds (SSTORE
`0→x→0`, CREATE collision/revert, EIP-7702 auth refills) credit the
local frame's reservoir immediately. `state_gas_used` becomes `i64`; the
clamp-and-spill scaffolding (`state_gas_refund_pending`,
`state_gas_refund_absorbed`, `state_gas_spill_outstanding`,
`state_gas_credit_against_drain`, intrinsic-tracking field + frame
snapshots) is removed.
- Bump fixtures and `eels_commit` to `tests-bal@v7.2.0` / `a3e5201a5`.
- Preserve intrinsic state gas on top-level error: split out a dedicated
`vm.intrinsic_state_gas` so the error path refunds only the execution
portion. Without this, EIP-7702 set-code reverts and CREATE-tx reverts
underbilled `block_state_gas_used` by `AUTH_TOTAL × CPSB` / `NEW_ACCOUNT
× CPSB`.
- `default_hook.rs` `state_refund` i64 cast: propagate
`InternalError::Overflow` instead of silently saturating (match
`vm.rs`).
- `engine_newPayloadV{4,5}`: return JSON-RPC `-32602` on fork/field
mismatch (V4 with BAL field or Amsterdam timestamp; V5 missing BAL field
or pre-Amsterdam timestamp). Was returning `PayloadStatus.INVALID`.

## Test plan

- `cargo check`, `clippy`, `fmt`: clean across `ethrex-levm`,
`ethrex-rpc`, `ethrex-vm`, `ethrex-blockchain`, `ethrex-l2`.
- `make -C tooling/ef_tests/blockchain test-levm` against
`tests-bal@v7.2.0`: 8726 passed, 0 failed.
- Hive `eels/consume-engine` Amsterdam: pending re-run; engine-API fix
targets the 2 remaining `test_fork_transition` failures.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

L1 Ethereum client

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants