Skip to content

fix(l2): reserve L1 gas upfront in prepare_execution to prevent DA fee griefing#6264

Open
avilagaston9 wants to merge 9 commits intomainfrom
fix/l2-validate-gas-limit-covers-l1-fee
Open

fix(l2): reserve L1 gas upfront in prepare_execution to prevent DA fee griefing#6264
avilagaston9 wants to merge 9 commits intomainfrom
fix/l2-validate-gas-limit-covers-l1-fee

Conversation

@avilagaston9
Copy link
Contributor

@avilagaston9 avilagaston9 commented Feb 25, 2026

Motivation

A user can send a transaction with gas_limit = intrinsic_gas (e.g., 21000). The tx passes validation, gets included in the blob, but at finalize time actual_gas + l1_gas > gas_limit triggers a revert. The recalculated l1_gas = gas_limit - actual_gas = 0, so the L1 fee vault receives nothing. The sequencer pays DA costs and gets nothing back — a griefing vector.

Closes #6053

Description

Reserve l1_gas from gas_remaining during prepare_execution by calling increase_consumed_gas(l1_gas), so execution physically cannot consume the L1 fee budget. This replaces the previous validate-only approach (validate_gas_limit_covers_l1_fee) which rejected transactions where gas_limit < intrinsic_gas + l1_gas but didn't prevent a contract from burning enough execution gas to squeeze the L1 fee at finalize time.

With reservation, the L1 fee vault always receives the full l1_gas payment:

  • If gas_limit < intrinsic_gas + l1_gas, the tx is rejected upfront with IntrinsicGasTooLow
  • The finalize revert path is removed (dead code — actual_gas_used <= gas_limit is guaranteed)
  • The refund cap (EIP-3529) is applied to execution gas only since l1_gas is not refundable

Design note: calculate_l1_fee_gas is called in both prepare and finalize

calculate_l1_fee_gas is computed twice: once in reserve_l1_gas (prepare) and once in finalize_non_privileged_execution (finalize). Both calls use vm.tx and vm.env.gas_price which are immutable during execution, so they always return the same value. An alternative would be storing the reserved l1_gas on the VM/environment to avoid recomputation, but we intentionally keep the current approach to avoid modifying the environment struct for now.

How to Test

cargo test -p ethrex-test -- levm::l2_gas_reservation
cargo clippy -p ethrex-levm -- -D warnings
cargo fmt --all -- --check

Checklist

  • Updated STORE_SCHEMA_VERSION (crates/storage/lib.rs) if the PR includes breaking changes to the Store requiring a re-sync.

@avilagaston9 avilagaston9 changed the title Validate gas_limit covers L1 DA fee upfront in L2Hook fix(l2): validate gas_limit covers L1 DA fee upfront in L2Hook Feb 25, 2026
@github-actions github-actions bot added the L2 Rollup client label Feb 25, 2026
@github-actions github-actions bot removed the L2 Rollup client label Feb 25, 2026
@github-actions
Copy link

🤖 Kimi Code Review

Review Feedback

Critical Issues

  1. Integer overflow in validate_gas_limit_covers_l1_fee (lines 267-268):
    The .checked_add(l1_gas) on line 267 correctly handles overflow, but the .saturating_sub(gas_remaining) on line 266 can underflow if gas_remaining > gas_limit. This could happen if gas accounting is incorrect elsewhere. Use .checked_sub() instead and handle the error case.

  2. Incorrect error mapping (line 258):
    The error from calculate_l1_fee_gas is mapped to TxValidationError::InsufficientMaxFeePerGas, but this is misleading. The function is validating gas limit sufficiency, not max fee per gas. Create a more appropriate error variant like TxValidationError::InsufficientGasLimit.

  3. Unsafe unwrap in gas remaining conversion (line 263):
    The .unwrap_or(0) on line 263 could mask serious issues if gas_remaining is negative (which shouldn't happen but could indicate bugs). Consider using .try_into().map_err(|_| TxValidationError::InternalError)? to properly handle conversion failures.

Minor Issues

  1. Redundant zero check (lines 259-261):
    The early return when l1_gas == 0 is unnecessary since the subsequent logic handles this case correctly. Removing it would simplify the code.

  2. Inconsistent naming:
    The function name validate_gas_limit_covers_l1_fee doesn't match the actual validation - it checks if gas limit covers both intrinsic gas AND L1 fee. Consider renaming to validate_gas_limit_covers_intrinsic_and_l1_fee.

Suggested Fix for Critical Issues

fn validate_gas_limit_covers_l1_fee(
    vm: &VM<'_>,
    l1_fee_config: &Option<L1FeeConfig>,
) -> Result<(), TxValidationError> {
    let l1_gas = calculate_l1_fee_gas(vm, l1_fee_config)
        .map_err(|_| TxValidationError::InsufficientGasLimit)?; // Fix item 2
    
    let gas_remaining: u64 = vm.current_call_frame.gas_remaining.try_into()
        .map_err(|_| TxValidationError::InternalError)?; // Fix item 3
    
    let intrinsic_gas = vm
        .current_call_frame
        .gas_limit
        .checked_sub(gas_remaining) // Fix item 1
        .ok_or(TxValidationError::InternalError)?;
    
    let min_gas_limit = intrinsic_gas
        .checked_add(l1_gas)
        .ok_or(TxValidationError::IntrinsicGasTooHigh)?; // More specific error
    
    if vm.current_call_frame.gas_limit < min_gas_limit {
        return Err(TxValidationError::InsufficientGasLimit);
    }
    Ok(())
}

The overall approach is sound and addresses an important economic vulnerability in L2 transaction processing.


Automated review by Kimi (Moonshot AI) · custom prompt

@github-actions
Copy link

🤖 Codex Code Review

Findings:

  1. Error mapping likely incorrect
    In crates/vm/levm/src/hooks/l2_hook.rs within validate_gas_limit_covers_l1_fee, any error from calculate_l1_fee_gas is mapped to TxValidationError::InsufficientMaxFeePerGas. That conflates fee calculation failures with max fee insufficiency and could mask real issues. Recommend mapping to a more accurate error (or propagating the original) so callers can distinguish malformed L1 fee config vs max fee errors.

  2. Silent overflow fallback can under‑validate
    In the same function, gas_remaining is converted with try_into().unwrap_or(0). If the conversion fails, you’ll treat remaining gas as 0, inflating intrinsic_gas and potentially letting too‑low gas_limit pass. This is a correctness and security risk. Prefer returning an error on conversion failure rather than falling back to 0.

  3. Assumption on intrinsic gas consumption needs enforcement
    The function assumes add_intrinsic_gas() already consumed intrinsic gas. If this ever gets called earlier or from a different path, the check will be wrong. Consider asserting/guarding this state or documenting it more explicitly near the call site in validate_tx in crates/vm/levm/src/hooks/l2_hook.rs.

If you want line-precise comments, I can annotate with exact line numbers after you share the current file with nl -ba.


Automated review by OpenAI Codex · custom prompt

@github-actions
Copy link

github-actions bot commented Feb 25, 2026

Lines of code report

Total lines added: 4
Total lines removed: 0
Total lines changed: 4

Detailed view
+--------------------------------------------+-------+------+
| File                                       | Lines | Diff |
+--------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/hooks/l2_hook.rs | 598   | +4   |
+--------------------------------------------+-------+------+

@github-actions
Copy link

🤖 Claude Code Review

Here is my review of PR 6264:


Review: fix(l2): validate gas_limit covers L1 DA fee upfront in L2Hook

Summary

This PR correctly identifies and patches a griefing vector: a user could submit a transaction with gas_limit = intrinsic_gas, which passes all existing validation but reverts at finalize time with l1_gas recalculated to 0, forcing the sequencer to absorb DA costs unreimbursed. The fix is well-scoped and the overall logic is sound. A few issues are worth addressing.


Issues

1. Misleading error variant on checked_add overflow (line 272–274)

let min_gas_limit = intrinsic_gas
    .checked_add(l1_gas)
    .ok_or(TxValidationError::IntrinsicGasTooLow)?;

If intrinsic_gas + l1_gas overflows u64, returning IntrinsicGasTooLow is semantically backwards — the actual condition is the opposite (both values are individually too large, not too small). In practice this overflow is unreachable thanks to EIP-7825's ~16M gas cap, but the code should either document this explicitly or use a more appropriate error (e.g. InternalError::Overflow wrapped into a VMError, since no TxValidationError::GasOverflow exists). If keeping IntrinsicGasTooLow is intentional for simplicity, add a comment.

2. Silent unwrap_or(0) on negative gas_remaining (line 267)

let gas_remaining: u64 = vm.current_call_frame.gas_remaining.try_into().unwrap_or(0);

gas_remaining is an i64. If it is negative (which add_intrinsic_gas would have already caught and rejected), this silently treats it as 0, making intrinsic_gas = gas_limit, which ensures the validation always rejects the transaction. That fallback behavior is safe, but the silent discard of a potential negative value hides a logic anomaly. Either assert it is non-negative (since this is a post-condition of add_intrinsic_gas) or propagate an InternalError. At minimum, a comment explaining why this is safe is warranted.

3. Incorrect error mapping for internal errors from calculate_l1_fee_gas (line 261–262)

let l1_gas = calculate_l1_fee_gas(vm, l1_fee_config)
    .map_err(|_| TxValidationError::InsufficientMaxFeePerGas)?;

calculate_l1_fee_gas can fail with InternalError::DivisionByZero (when gas_price == 0) or InternalError::Overflow. Mapping all of these to InsufficientMaxFeePerGas is misleading — it gives the caller (and any monitoring tooling) the wrong diagnosis. A zero gas_price at this point would be a bug since validate_sufficient_max_fee_per_gas_l2 runs immediately before, but the catch-all erasure of internal errors makes debugging harder. Consider map_err(|e| match e { VMError::TxValidation(e) => e, _ => TxValidationError::InsufficientMaxFeePerGas }) or just propagating VMError directly by adjusting the return type.

4. Missing tests

No unit or integration tests are added for the new validation path. Given this is a security fix for a griefing vector, test cases are important, at minimum covering:

  • gas_limit == intrinsic_gas with a non-zero L1 fee config → should be rejected.
  • gas_limit == intrinsic_gas + l1_gas → should be accepted.
  • l1_fee_config == None → should be accepted without any gas check.
  • Privileged transaction → should bypass the check entirely.

Minor Observations

  • The doc comment correctly states "This function must be called after add_intrinsic_gas()." This invariant is respected in the current call sequence (both DefaultHook.prepare_execution and prepare_execution_fee_token call add_intrinsic_gas internally before returning), but it is fragile. A future refactor could violate it silently — calling this function before add_intrinsic_gas would accept transactions that should be rejected (under-estimation of intrinsic_gas from gas_remaining). This would be worth a debug_assert or a structural note in the code.

  • calculate_l1_fee_gas is computed twice: once here during validation and once in finalize_non_privileged_execution. This is correct and necessary since finalize needs the re-evaluation after actual execution, but confirms tx.length() must return a stable value across both calls — which it does.

  • The saturating_sub on line 270 for intrinsic_gas = gas_limit - gas_remaining is safe since gas_remaining <= gas_limit always holds post-add_intrinsic_gas, but an explicit assert or comment would make the invariant obvious.


Verdict

The core fix is correct and the griefing vector is properly closed. The main actionable items are: adding tests (item 4), clarifying or improving the error handling for the overflow case (item 1) and the internal error mapping (item 3), and documenting the unwrap_or(0) assumption (item 2).


Automated review by Claude (Anthropic) · custom prompt

@github-actions
Copy link

github-actions bot commented Feb 25, 2026

Benchmark Results Comparison

Benchmark Results: MstoreBench

Command Mean [s] Min [s] Max [s] Relative
main_revm_MstoreBench 270.0 ± 4.7 266.4 281.0 1.24 ± 0.02
main_levm_MstoreBench 218.6 ± 2.1 216.2 221.9 1.00
pr_levm_MstoreBench 257.6 ± 122.6 216.3 606.5 1.18 ± 0.56
Detailed Results

Benchmark Results: BubbleSort

Command Mean [s] Min [s] Max [s] Relative
main_revm_BubbleSort 2.989 ± 0.047 2.933 3.057 1.10 ± 0.02
main_levm_BubbleSort 2.721 ± 0.036 2.687 2.805 1.00
pr_revm_BubbleSort 2.949 ± 0.019 2.927 2.993 1.08 ± 0.02
pr_levm_BubbleSort 2.732 ± 0.041 2.693 2.808 1.00 ± 0.02

Benchmark Results: ERC20Approval

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Approval 986.7 ± 12.9 973.3 1009.4 1.01 ± 0.01
main_levm_ERC20Approval 1030.2 ± 23.3 1018.6 1096.0 1.06 ± 0.02
pr_revm_ERC20Approval 974.9 ± 4.4 970.4 982.8 1.00
pr_levm_ERC20Approval 1039.8 ± 17.7 1024.2 1085.0 1.07 ± 0.02

Benchmark Results: ERC20Mint

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Mint 135.6 ± 1.2 133.9 137.4 1.01 ± 0.01
main_levm_ERC20Mint 157.5 ± 2.8 155.2 163.5 1.18 ± 0.02
pr_revm_ERC20Mint 133.8 ± 1.3 132.0 135.3 1.00
pr_levm_ERC20Mint 158.0 ± 1.1 156.7 160.3 1.18 ± 0.01

Benchmark Results: ERC20Transfer

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Transfer 241.5 ± 18.4 232.8 293.1 1.05 ± 0.08
main_levm_ERC20Transfer 264.4 ± 4.5 255.5 272.7 1.15 ± 0.02
pr_revm_ERC20Transfer 230.8 ± 1.1 229.4 232.9 1.00
pr_levm_ERC20Transfer 266.8 ± 3.4 264.4 275.1 1.16 ± 0.02

Benchmark Results: Factorial

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Factorial 229.7 ± 1.0 228.5 232.0 1.00 ± 0.01
main_levm_Factorial 249.9 ± 2.6 248.3 257.1 1.09 ± 0.01
pr_revm_Factorial 229.3 ± 0.8 228.0 230.9 1.00
pr_levm_Factorial 249.8 ± 3.2 247.4 258.4 1.09 ± 0.01

Benchmark Results: FactorialRecursive

Command Mean [s] Min [s] Max [s] Relative
main_revm_FactorialRecursive 1.688 ± 0.041 1.584 1.720 1.00
main_levm_FactorialRecursive 9.660 ± 0.058 9.579 9.747 5.72 ± 0.14
pr_revm_FactorialRecursive 1.691 ± 0.029 1.641 1.737 1.00 ± 0.03
pr_levm_FactorialRecursive 9.628 ± 0.076 9.473 9.730 5.70 ± 0.15

Benchmark Results: Fibonacci

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Fibonacci 211.7 ± 3.4 208.6 220.9 1.00 ± 0.02
main_levm_Fibonacci 228.3 ± 4.5 224.5 236.1 1.08 ± 0.02
pr_revm_Fibonacci 210.9 ± 1.5 208.0 212.6 1.00
pr_levm_Fibonacci 226.2 ± 3.2 223.7 233.3 1.07 ± 0.02

Benchmark Results: FibonacciRecursive

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_FibonacciRecursive 851.7 ± 6.9 836.9 860.0 1.25 ± 0.02
main_levm_FibonacciRecursive 681.9 ± 7.3 673.8 693.1 1.00 ± 0.01
pr_revm_FibonacciRecursive 863.8 ± 27.7 830.5 907.9 1.27 ± 0.04
pr_levm_FibonacciRecursive 679.2 ± 6.9 672.4 690.5 1.00

Benchmark Results: ManyHashes

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ManyHashes 8.5 ± 0.1 8.4 8.6 1.00 ± 0.02
main_levm_ManyHashes 9.7 ± 0.1 9.7 9.9 1.15 ± 0.02
pr_revm_ManyHashes 8.5 ± 0.2 8.4 8.9 1.00
pr_levm_ManyHashes 9.9 ± 0.1 9.8 10.0 1.17 ± 0.02

Benchmark Results: MstoreBench

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_MstoreBench 270.0 ± 4.7 266.4 281.0 1.24 ± 0.02
main_levm_MstoreBench 218.6 ± 2.1 216.2 221.9 1.00
pr_revm_MstoreBench 269.0 ± 4.7 265.2 278.8 1.23 ± 0.02
pr_levm_MstoreBench 257.6 ± 122.6 216.3 606.5 1.18 ± 0.56

Benchmark Results: Push

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Push 295.3 ± 1.2 293.9 297.6 1.07 ± 0.01
main_levm_Push 280.3 ± 10.0 273.1 300.7 1.02 ± 0.04
pr_revm_Push 294.6 ± 1.2 293.4 297.0 1.07 ± 0.01
pr_levm_Push 275.1 ± 1.2 273.5 277.1 1.00

Benchmark Results: SstoreBench_no_opt

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_SstoreBench_no_opt 171.4 ± 5.8 164.6 181.6 1.59 ± 0.05
main_levm_SstoreBench_no_opt 108.1 ± 0.7 107.0 109.1 1.00 ± 0.01
pr_revm_SstoreBench_no_opt 169.5 ± 2.3 166.5 174.6 1.57 ± 0.02
pr_levm_SstoreBench_no_opt 108.1 ± 0.6 107.0 108.8 1.00

@avilagaston9 avilagaston9 changed the title fix(l2): validate gas_limit covers L1 DA fee upfront in L2Hook fix(l2): reserve L1 gas upfront in prepare_execution to prevent DA fee griefing Feb 25, 2026
@github-actions github-actions bot added the L2 Rollup client label Feb 25, 2026
to prevent a griefing vector where transactions with gas_limit = intrinsic_gas
pass validation but revert at finalize time with l1_gas recalculated to 0,
leaving the sequencer to absorb DA costs with no reimbursement.

The new validate_gas_limit_covers_l1_fee function checks that
gas_limit >= intrinsic_gas + l1_gas during prepare_execution, rejecting
underfunded transactions before they enter the blob. Privileged transactions
are unaffected (they return early before the check). The finalize revert
path is kept as a safety net for legitimate cases where execution gas
consumption pushes actual_gas + l1_gas over gas_limit.

Closes #6053
…g it,

so execution physically cannot consume the L1 fee budget. Previously the
validate-only approach still allowed a contract to burn enough execution
gas to squeeze the L1 fee at finalize time. Now increase_consumed_gas(l1_gas)
is called during prepare_execution, making the revert-for-l1-gas path in
finalize_non_privileged_execution dead code (removed). The refund cap
(EIP-3529) is applied to execution gas only since l1_gas is not refundable.
…sic_plus_l1

to a single iteration with an assertion, and add test_no_l1_fee_config_21000_is_enough
to confirm gas_limit=21000 suffices when L1 fee is disabled.
@avilagaston9 avilagaston9 force-pushed the fix/l2-validate-gas-limit-covers-l1-fee branch from 01ddfd3 to 9fd98ce Compare February 25, 2026 17:57
gas_used - l1_gas separation works correctly when a contract consumes
execution gas. The L1 fee vault must still receive the full l1_gas payment.
…tract

call reverts due to out-of-gas, the L1 fee vault still receives the full
l1_gas payment thanks to the upfront reservation in prepare_execution.
@avilagaston9 avilagaston9 marked this pull request as ready for review February 25, 2026 18:25
Copilot AI review requested due to automatic review settings February 25, 2026 18:25
@avilagaston9 avilagaston9 requested review from a team and ManuelBilbao as code owners February 25, 2026 18:25
@github-actions
Copy link

🤖 Kimi Code Review

Review Summary

This PR introduces a critical security fix for L2 gas reservation by reserving L1 data availability gas upfront during transaction preparation. The implementation correctly prevents a griefing vector where users could set gas_limit = intrinsic_gas to avoid L1 fee payments.

Key Changes Review

1. Security Fix: L1 Gas Reservation

  • File: crates/vm/levm/src/hooks/l2_hook.rs
  • Lines: 65-68, 235-250

The new reserve_l1_gas() function correctly reserves L1 gas upfront by consuming it from gas_remaining, ensuring the L1 fee vault always receives payment. This is a critical security improvement.

2. Simplified Gas Accounting

  • Lines: 106-132

The removal of the complex post-execution gas limit check logic is appropriate since L1 gas is now reserved upfront. The new approach:

  • Eliminates the need for the actual_gas_used > gas_limit check
  • Simplifies refund calculations by separating execution gas from L1 gas
  • Maintains EIP-7778 compliance for block accounting

3. Test Coverage

  • File: test/tests/levm/l2_gas_reservation_tests.rs

Comprehensive test suite covers:

  • Rejection of transactions with insufficient gas for L1 fees
  • Exact gas limit calculations
  • Contract execution with L1 gas reservation
  • Out-of-gas reverts still paying L1 fees
  • Edge cases with L1 fee disabled

Minor Issues

1. Potential Overflow in compute_l1_gas

  • File: test/tests/levm/l2_gas_reservation_tests.rs
  • Line: 117-123

The test helper compute_l1_gas uses integer division that could truncate to zero. While the test handles the l1_gas == 0 && l1_fee > 0 case, this could be clearer:

let l1_gas = l1_fee.div_ceil(GAS_PRICE);

2. Test Helper Redundancy

  • Lines: 87-94, 96-103

The make_tx and make_tx_to functions are nearly identical. Consider consolidating:

fn make_tx(gas_limit: u64) -> Transaction {
    make_tx_to(gas_limit, recipient_addr())
}

3. Gas Limit Calculation in Test

  • Line: 184-190

The comment mentions "One iteration is enough" but the code actually rebuilds the transaction. Consider clarifying:

// Compute exact gas limit needed
let l1_gas = compute_l1_gas(&tx);
let gas_limit = 21_000 + l1_gas;

Security Verification

All security concerns addressed:

  • L1 fee griefing vector eliminated
  • Gas accounting maintains consensus rules
  • No new overflow/underflow vulnerabilities introduced
  • Proper error handling for insufficient gas scenarios

Performance Impact

Minimal performance impact:

  • Single additional gas reservation check in prepare phase
  • Simplified post-execution logic
  • No additional storage operations

Conclusion

This PR successfully implements a critical security fix for L2 gas reservation with comprehensive test coverage. The changes are well-architected and maintain Ethereum consensus rules while eliminating a significant griefing vector.


Automated review by Kimi (Moonshot AI) · custom prompt

@greptile-apps
Copy link

greptile-apps bot commented Feb 25, 2026

Greptile Summary

This PR fixes a DA fee griefing vulnerability by reserving L1 gas upfront during prepare_execution via increase_consumed_gas(l1_gas). Previously, a malicious user could send a transaction with gas_limit = intrinsic_gas (21000), causing the tx to pass validation and get included in the blob, but then revert at finalize time when execution_gas + l1_gas > gas_limit, resulting in zero L1 fee payment to the vault while the sequencer still pays DA costs.

Key changes:

  • Added reserve_l1_gas() function called in prepare_execution that physically reserves L1 gas from the execution budget
  • Transactions with gas_limit < intrinsic_gas + l1_gas are now rejected upfront with IntrinsicGasTooLow
  • Removed the dead revert path in finalize_non_privileged_execution (lines 119-138 in old code) since actual_gas_used <= gas_limit is now guaranteed
  • Correctly applies EIP-3529 refund cap to execution gas only, since L1 gas is not refundable
  • Added comprehensive test suite covering edge cases: insufficient gas rejection, exact gas limit, contract execution with gas consumption, and OOG reverts

The implementation calls calculate_l1_fee_gas twice (prepare and finalize) using immutable values (vm.tx, vm.env.gas_price), so both calls return the same value. This approach avoids modifying the environment struct.

Confidence Score: 5/5

  • This PR is safe to merge with high confidence
  • The implementation is sound and well-tested. The reservation approach elegantly solves the griefing vector by making it physically impossible for execution to consume L1 gas. The refund calculation correctly excludes L1 gas from the refund cap. Comprehensive tests verify all edge cases including insufficient gas rejection, exact gas limits, contract execution, and OOG scenarios. No backward compatibility issues since this fixes a security vulnerability.
  • No files require special attention

Important Files Changed

Filename Overview
crates/vm/levm/src/hooks/l2_hook.rs Implements upfront L1 gas reservation in prepare_execution to prevent DA fee griefing, removes dead revert path, and correctly applies refund cap to execution gas only
test/tests/levm/l2_gas_reservation_tests.rs Comprehensive test suite covering upfront rejection, exact gas limits, L1 vault payment, contract execution, and OOG revert scenarios

Sequence Diagram

sequenceDiagram
    participant User
    participant VM
    participant Hook as L2Hook
    participant CallFrame
    participant Vault as L1FeeVault

    User->>VM: Submit tx (gas_limit)
    VM->>Hook: prepare_execution()
    Hook->>Hook: calculate_l1_fee_gas(tx)
    Note over Hook: l1_gas = (tx_size * l1_fee_per_byte) / gas_price
    Hook->>CallFrame: increase_consumed_gas(l1_gas)
    alt gas_limit < intrinsic_gas + l1_gas
        CallFrame-->>Hook: OutOfGas error
        Hook-->>VM: IntrinsicGasTooLow
        VM-->>User: Transaction rejected
    else sufficient gas
        CallFrame->>CallFrame: gas_remaining -= l1_gas
        Note over CallFrame: L1 gas reserved, cannot be consumed by execution
        Hook-->>VM: Success
        VM->>VM: Execute transaction
        VM->>Hook: finalize_execution(ctx_result)
        Hook->>Hook: calculate_l1_fee_gas(tx)
        Note over Hook: Same value as prepare
        Hook->>Hook: execution_gas = ctx_result.gas_used - l1_gas
        Hook->>Hook: Apply refund cap to execution_gas only
        Hook->>Vault: pay_to_l1_fee_vault(l1_gas * gas_price)
        Vault->>Vault: Receives full L1 fee
        Hook-->>VM: Success
    end
Loading

Last reviewed commit: 5396341

@github-actions
Copy link

🤖 Claude Code Review

Now I have a complete picture of the code. Let me write up the review.


PR Review: fix(l2): reserve L1 gas upfront in prepare_execution to prevent DA fee griefing

Summary

This PR fixes a real griefing vector: a user could send a transaction with gas_limit = intrinsic_gas, which passes validation but at finalize time recalculates l1_gas = 0, leaving the sequencer to absorb DA costs. The fix is sound in concept — consuming l1_gas from gas_remaining during prepare_execution so the execution budget physically cannot reach the L1 fee portion.


Correctness

EIP-3529 refund cap — correctly updated (important fix)

The old compute_gas_refunded used ctx_result.gas_used / MAX_REFUND_QUOTIENT. Now that ctx_result.gas_used includes l1_gas (because it was reserved in the call frame), using the raw total for the refund cap would over-allow refunds. The PR correctly inlines the computation against execution_gas_pre_refund:

// l2_hook.rs:119-122
let gas_refunded: u64 = vm
    .substate
    .refunded_gas
    .min(execution_gas_pre_refund / default_hook::MAX_REFUND_QUOTIENT);

This is a necessary and correct change.

checked_sub for execution_gas_pre_refund — safe but relies on an implicit invariant

// l2_hook.rs:113-116
let execution_gas_pre_refund = ctx_result
    .gas_used
    .checked_sub(l1_gas)
    .ok_or(InternalError::Underflow)?;

This cannot underflow because if reserve_l1_gas succeeded, gas_limit >= intrinsic_gas + l1_gas, and ctx_result.gas_used <= gas_limit is always true (even in OOG, where gas_remaining = 0 and gas_used = gas_limit). The invariant holds, but it's subtle — a comment linking it to the reservation guarantee would be helpful.

actual_gas_used <= gas_limit guaranteed — dead code removal is justified

With the reservation in place: execution_gas <= gas_limit - l1_gas, so actual_gas_used = execution_gas + l1_gas <= gas_limit. For Prague+ calldata floor: floor <= intrinsic_gas <= gas_limit - l1_gas, so the floor cannot push actual_gas_used above gas_limit either. The removed revert-and-re-calculate block is genuinely dead code. ✓

total_gas_pre_refund = ctx_result.gas_used — semantically equivalent to old code

Old: total_gas_pre_refund = gas_used_pre_refund + l1_gas (where gas_used_pre_refund was execution-only gas).
New: total_gas_pre_refund = ctx_result.gas_used (which is gas_limit - gas_remaining, where gas_remaining already had l1_gas deducted). These are equal. ✓

map_err(|_| ...) loses error information in reserve_l1_gas

// l2_hook.rs:250-251
vm.current_call_frame
    .increase_consumed_gas(l1_gas)
    .map_err(|_| TxValidationError::IntrinsicGasTooLow)?;

increase_consumed_gas only ever returns ExceptionalHalt::OutOfGas, so the wildcard is technically fine. However, calculate_l1_fee_gas (called just before) can return InternalError::DivisionByZero (when gas_price == 0). That error is a distinct VMError variant, not an ExceptionalHalt, and so it propagates through the ? on line 248 before reaching the map_err — so it won't be silently swallowed. Still, if gas_price is 0 with L1 fee config set, the caller gets DivisionByZero rather than IntrinsicGasTooLow, which is correct behavior preserved from before this PR.

Double computation of calculate_l1_fee_gas — acceptable but creates future maintenance risk

The design note acknowledges this. Both calls are on an immutable vm.tx and vm.env.gas_price, so the results are always identical. This is fine for now, but if anyone makes either of those fields mutable during execution in the future, a subtle bug would be introduced silently. Storing the reserved value in a local field (even just on the hook struct) would eliminate this risk.


Test Concerns

test_oog_revert_still_pays_l1_fee_vault — potentially fragile l1_gas estimation

// l2_gas_reservation_tests.rs:350-360
let preliminary_tx = make_tx_to(30_000, contract_addr());
let l1_gas = compute_l1_gas(&preliminary_tx);
// ...
let gas_limit = 21_000 + l1_gas + 100;
let tx = make_tx_to(gas_limit, contract_addr());

l1_gas is derived from preliminary_tx (gas_limit=30000), but tx has a different gas_limit (21_000 + l1_gas + 100). If the RLP encoding of the two gas_limit values differs in byte length (e.g., crossing a length boundary in RLP VarInt encoding), the transaction sizes differ, and the l1_gas actually used by the VM (calculated from tx) won't match l1_gas computed here. In practice, 30000 and ~21100 both encode to 2 bytes in RLP, so this is stable for current test values — but compare with test_gas_limit_exactly_covers_intrinsic_plus_l1, which properly rebuilds the tx and asserts stability:

// correct pattern:
let tx = make_tx(22_000);
let l1_gas = compute_l1_gas(&tx);
let gas_limit = 21_000 + l1_gas;
let tx = make_tx(gas_limit);
assert_eq!(compute_l1_gas(&tx), l1_gas, "l1_gas should be stable");

The OOG test should follow this pattern to guarantee correctness.

compute_l1_gas test helper — duplicates production logic in u64

The production calculate_l1_fee_gas uses U256 arithmetic to avoid overflow. The test helper reimplements the same formula in u64:

let l1_fee_per_blob: u64 = L1_FEE_PER_BLOB_GAS * u64::from(GAS_PER_BLOB); // 1,310,720,000 — safe with current constants

With GAS_PER_BLOB = 131072 and L1_FEE_PER_BLOB_GAS = 10_000, there's no overflow risk now. But this duplication means future changes to the L1 fee formula in calculate_l1_fee_gas won't automatically be reflected in tests, creating test drift risk. Exposing calculate_l1_fee_gas via a test-only helper (or making it pub(crate)) would be preferable.

Missing test coverage

  • Fee token path with L1 gas reservation (separate code path via prepare_execution_fee_token)
  • CREATE transactions with L1 gas reservation
  • SSTORE refund interaction: a transaction with SSTORE refunds should have the refund cap applied to execution_gas_pre_refund, not the L1-inclusive gas_used. This is now correctly computed, but there's no test to catch a regression.

Minor

  • validate_l1_gas_limit_covers_l1_fee is mentioned in commit messages but the final function name is reserve_l1_gas. No code issue, just commit message inconsistency.

  • The if l1_gas == 0 && l1_fee > 0 { 1 } else { l1_gas } minimum-1-gas logic exists in both the production calculate_l1_fee_gas and the test helper compute_l1_gas, which is good — they're aligned on this edge case.


Overall

The core fix is correct and addresses the griefing vector cleanly. The refund cap adjustment is a necessary and correct companion change. The main actionable items are:

  1. Fix the OOG test to use the same two-pass gas_limit pattern as test_gas_limit_exactly_covers_intrinsic_plus_l1
  2. Add a comment to execution_gas_pre_refund's checked_sub explaining why underflow cannot happen
  3. Consider adding tests for the SSTORE refund cap and fee-token path

Automated review by Claude (Anthropic) · custom prompt

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR addresses an L2 griefing vector where a transaction could be included in DA/blob data but end up paying zero to the L1 fee vault at finalize time by exhausting the execution gas budget. The fix reserves the computed l1_gas upfront during prepare_execution, so execution cannot consume the L1 DA fee budget.

Changes:

  • Reserve l1_gas during prepare_execution for non-privileged L2 transactions.
  • Update finalize accounting to treat reserved l1_gas as non-refundable and simplify/remove the previous “cap/revert” path.
  • Add a dedicated test suite covering L2 gas reservation behavior and L1 fee vault payment.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
crates/vm/levm/src/hooks/l2_hook.rs Reserves L1 DA gas upfront and updates finalize gas/refund accounting to separate execution gas from reserved l1_gas.
test/tests/levm/l2_gas_reservation_tests.rs Adds tests for upfront rejection, exact-budget success, vault payment correctness, and revert/OOG scenarios.
test/tests/levm/mod.rs Registers the new l2_gas_reservation_tests module.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 248 to 252
let l1_gas = calculate_l1_fee_gas(vm, l1_fee_config)?;
vm.current_call_frame
.increase_consumed_gas(l1_gas)
.map_err(|_| TxValidationError::IntrinsicGasTooLow)?;
Ok(())
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

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

reserve_l1_gas currently only ensures gas_limit >= intrinsic_gas + l1_gas (because it runs after add_intrinsic_gas). On Prague+ forks, compute_actual_gas_used can charge max(execution_gas, min_gas_used) (EIP-7623 floor), so a tx can pass reservation but later be charged min_gas_used + l1_gas, exceeding gas_limit and breaking refund accounting. Reservation should also enforce gas_limit >= min_gas_used + l1_gas (or an equivalent check).

Copilot uses AI. Check for mistakes.
let actual_gas_used = execution_gas
.checked_add(l1_gas)
.ok_or(InternalError::Overflow)?;

Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

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

The removed actual_gas_used > gas_limit cap/revert branch relies on the assumption that reservation makes actual_gas_used <= gas_limit always true. With Prague+ EIP-7623, compute_actual_gas_used can raise execution_gas to min_gas_used, so actual_gas_used = execution_gas + l1_gas can still exceed gas_limit unless reservation also accounts for the min-gas floor. Add a guard here or tighten reserve_l1_gas accordingly.

Suggested change
// Ensure total gas used does not exceed gas limit after Prague+ (EIP-7623).
if actual_gas_used > vm.env.gas_limit {
return Err(VMError::Internal(InternalError::Custom(
"actual gas used exceeds gas limit after min-gas adjustment".to_owned(),
)));
}

Copilot uses AI. Check for mistakes.
Comment on lines +214 to +222
/// A transaction with gas_limit exactly covering intrinsic_gas + l1_gas should succeed.
/// The execution gas budget is 0, but for a simple EOA transfer that's fine.
#[test]
fn test_gas_limit_exactly_covers_intrinsic_plus_l1() {
// Build the tx once, compute l1_gas, then rebuild with the exact gas_limit.
// One iteration is enough since a small gas_limit change barely affects RLP size.
let tx = make_tx(22_000);
let l1_gas = compute_l1_gas(&tx);
assert!(l1_gas > 0, "l1_gas should be positive for this test");
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

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

Current tests don’t cover the Prague+ EIP-7623 floor case where min_gas_used can exceed intrinsic (e.g., non-empty calldata with many non-zero bytes). Add a test that sets calldata to trigger min_gas_used > intrinsic, then asserts: (1) gas_limit < min_gas_used + l1_gas is rejected upfront and (2) gas_limit = min_gas_used + l1_gas succeeds while still paying the full L1 fee vault amount.

Copilot uses AI. Check for mistakes.
…prevent

an underflow in refund_sender when the EIP-7623 calldata gas floor exceeds
intrinsic_gas. Without this check, a transaction with heavy non-zero calldata
could pass both validate_min_gas_limit (gas_limit >= floor) and the l1_gas
reservation (gas_limit >= intrinsic + l1_gas) individually, but at finalize
max(execution_gas, floor) + l1_gas would exceed gas_limit.

let gas_refunded: u64 = default_hook::compute_gas_refunded(vm, ctx_result)?;
// Refund cap based on execution gas only (EIP-3529)
let gas_refunded: u64 = vm
Copy link
Contributor

Choose a reason for hiding this comment

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

Good that this doesn't use compute_gas_refunded(vm, ctx_result) — that helper caps refunds at ctx_result.gas_used / 5, which now includes l1_gas since it was consumed from gas_remaining. Using execution_gas_pre_refund here correctly excludes l1_gas from the denominator.

Worth noting for future maintainability: a "simplification" to call compute_gas_refunded would silently inflate the refund cap by l1_gas / 5, letting users reclaim gas that should cover the L1 fee.

@ilitteri ilitteri enabled auto-merge February 27, 2026 13:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

L2 Rollup client

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

L2: Validate gas_limit covers L1 DA fee to prevent griefing attack

4 participants