Skip to content

fix(l1): reject L2-only transaction types (0x7d/0x7e) in L1 blocks#6752

Open
ElFantasma wants to merge 2 commits into
mainfrom
fix/l1-reject-l2-tx-types
Open

fix(l1): reject L2-only transaction types (0x7d/0x7e) in L1 blocks#6752
ElFantasma wants to merge 2 commits into
mainfrom
fix/l1-reject-l2-tx-types

Conversation

@ElFantasma
Copy link
Copy Markdown
Contributor

Summary

ethrex uses one shared Transaction decoder for L1 and L2. It accepts the L2-only transaction types 0x7d (FeeToken) and 0x7e (PrivilegedL2Transaction) with no L1/L2 gate, and a privileged transaction's sender is its unsigned, caller-chosen embedded from (no signature recovery). Nothing on the L1 import path (validate_block_pre_execution, validate_block_body, the default LEVM hook) rejects these types, and engine_newPayload decodes through the same path.

As a result, an L1 block containing a type-0x7e transaction is decoded, accepted, and executed — using the attacker-chosen from as the sender. geth/reth and the execution spec reject these types outright, so ethrex would both diverge consensus (accept a block the rest of the network rejects) and let a block forge a sender on L1.

Fix

Reject L2-only transaction types on the L1 import path:

  • TxType::is_l2_only() (in crates/common/types/transaction.rs) classifies each transaction type. The match is exhaustive (no wildcard), so adding a new TxType variant won't compile until it is explicitly classified — an L2-only type can never be silently accepted on L1 by omission.
  • Blockchain::validate_l1_transaction_types, gated on BlockchainType::L1, rejects any block transaction whose type is L2-only. It is called right after validate_block_pre_execution at all three pre-execution sites (execute_block, execute_block_pipeline, execute_block_from_state), so every L1 import/execute path is covered.
  • New error InvalidBlockError::UnsupportedTransactionType(u8).

L2 is unaffected (the check early-returns when not BlockchainType::L1); the zkVM prover and L2 sequencer paths are untouched.

Test

Adds l1_rejects_privileged_l2_transaction_block, which builds an L1 block containing a type-0x7e transaction (post-state consistent so it reaches type acceptance) and asserts add_block rejects it. Confirmed red on the pre-fix code (add_block returned Ok(())) and green after the fix.

Scope

This closes the consensus / block-import vector. The mempool/RPC ingress (eth_sendRawTransaction admitting these types on L1) is tracked separately under the fee-token-l1-tx finding.

@ElFantasma ElFantasma requested review from a team as code owners May 29, 2026 19:27
@github-actions github-actions Bot added the L1 Ethereum client label May 29, 2026
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 29, 2026

⚠️ 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
Copy link
Copy Markdown

🤖 Kimi Code Review

This PR correctly addresses a critical consensus vulnerability where L2-only transaction types (0x7d, 0x7e) could be forged into L1 blocks. The implementation is security-conscious and uses Rust's type system effectively.

Strengths

  1. Exhaustive pattern matching in TxType::is_l2_only (crates/common/types/transaction.rs:1554-1560) ensures future transaction types cannot be silently omitted from this safety check.
  2. Defense in depth: The validation is applied at multiple block entry points (execute_block, add_block, validate_and_execute_block).
  3. Clear security documentation explains the sender-forgery risk of unsigned from fields in privileged transactions.

Suggestions

1. Verify validation uniqueness (Line 441, 519, 1335)

The check appears in three methods. If add_block internally calls execute_block or validate_and_execute_block, the validation runs twice. While harmless, verify these are distinct code paths (e.g., RPC execution vs. block import) or consolidate to a single validation point to avoid redundancy.

2. Strengthen test assertions (Line 3478+)

The test verifies rejection via assert!(result.is_err()), but should confirm the specific error variant to avoid false positives from unrelated failures (e.g., state root mismatch):

// Around line 3618
let err = result.expect_err("L1 must reject type-0x7e transaction");
assert!(matches!(
    err,
    ChainError::InvalidBlock(InvalidBlockError::UnsupportedTransactionType(0x7e))
), "Expected UnsupportedTransactionType(0x7e), got: {err:?}");

3. Add coverage for FeeToken type (0x7d)

The test only covers PrivilegedL2Transaction (0x7e). Add a similar test case for FeeToken (0x7d) or parameterize the existing test, as both types must be rejected.

4. Nit: Error formatting consistency

In errors.rs:38, the hex formatting with #x ({0:#x}) produces 0x7e, which is readable. Ensure logging systems elsewhere don't double-prefix this (e.g., 0x0x7e).

Security Verification

  • Correct: Early return Ok(()) for non-L1 chains allows L2 networks to use these types.
  • Correct: Validation occurs before EVM execution in all paths, preventing wasted computation.
  • Correct: The check operates on the decoded TxType enum rather than raw bytes, avoiding RLP decoding ambiguities.

The implementation is ready to merge after addressing Item 2 (test specificity) to prevent regression test brittleness.


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

@github-actions
Copy link
Copy Markdown

🤖 Claude Code Review

Now I have all the context I need for a thorough review.


Review: fix(l1): reject L2-only transaction types (0x7d/0x7e) in L1 blocks

Summary

This is a targeted security fix that correctly guards all three pre-execution entry points on the L1 path. The overall approach is sound and the exhaustive match pattern is a good compile-time safeguard. A few minor issues noted below.


Correctness

Three-point coveragevalidate_l1_transaction_types is called in execute_block, execute_block_pipeline, and execute_block_from_state, which are the canonical import/execution entry points. Two other sites that call vm.execute_block(block) directly are generate_bal_for_block (line ~494) and generate_witness_for_blocks (line ~1451); both operate on blocks already committed to the chain and validated on import, so skipping the check there is correct.

Check ordering — The validation fires immediately after validate_block_pre_execution and before vm.execute_block. This is the right place: structural header checks run first (fast, cheap), and the type guard runs before any state transitions occur.

Exhaustive match in is_l2_only — Excellent. A wildcard-free match means adding a new TxType variant will fail to compile until it is explicitly classified. This is the right pattern for a security-critical classifier.


Minor Issues

Double call to tx.tx_type() (blockchain.rs, lines ~414–419):

if tx.tx_type().is_l2_only() {
    return Err(ChainError::InvalidBlock(
        InvalidBlockError::UnsupportedTransactionType(tx.tx_type() as u8),
    ));
}

tx_type() is called twice. Even though the call is cheap, a local binding avoids the redundancy:

let ty = tx.tx_type();
if ty.is_l2_only() {
    return Err(ChainError::InvalidBlock(
        InvalidBlockError::UnsupportedTransactionType(u8::from(ty)),
    ));
}

This also brings the conversion in line with the existing From<TxType> for u8 impl rather than using a raw as u8 cast.


Test Coverage

The test l1_rejects_privileged_l2_transaction_block only exercises 0x7e (PrivilegedL2Transaction). The is_l2_only method also classifies 0x7d (FeeTokenTransaction) as L2-only, but no test verifies that an L1 block containing a FeeTokenTransaction is rejected. Adding a parallel assertion or a second test case for 0x7d would complete the coverage.

The test also constructs a fully valid state_root / receipts_root for the post-state, which is more work than necessary — the type check fires in execute_block before state root verification, so an inconsistent state root would not mask the failure. The test still passes correctly as written, but the extra root computation creates a misleading impression that state root correctness matters here.


No Issues Found With

  • The early-return guard if !matches!(self.options.r#type, BlockchainType::L1) — correctly leaves L2 unaffected.
  • The UnsupportedTransactionType(u8) error variant and {0:#x} formatting in the error message.
  • The doc comment on validate_l1_transaction_types — explains the "why" clearly and is appropriate for a security-critical check.
  • The round-trip encode_canonical / decode_canonical assertion in the test — confirms the type survives the standard block import codec path.

Summary

This is a correct and well-scoped fix. The two actionable items are:

  1. Replace the double tx.tx_type() call with a local binding and use u8::from(ty) instead of as u8.
  2. Add a second test (or assertion) covering rejection of a block containing a FeeTokenTransaction (0x7d).

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

@github-actions
Copy link
Copy Markdown

Lines of code report

Total lines added: 173
Total lines removed: 0
Total lines changed: 173

Detailed view
+-------------------------------------------+-------+------+
| File                                      | Lines | Diff |
+-------------------------------------------+-------+------+
| ethrex/crates/blockchain/blockchain.rs    | 2802  | +165 |
+-------------------------------------------+-------+------+
| ethrex/crates/common/errors.rs            | 36    | +2   |
+-------------------------------------------+-------+------+
| ethrex/crates/common/types/transaction.rs | 3409  | +6   |
+-------------------------------------------+-------+------+

@github-actions
Copy link
Copy Markdown

🤖 Codex Code Review

Findings:

  1. High: the fix only protects block import, not L1 mempool admission / local payload building. validate_transaction still auto-accepts PrivilegedL2Transaction on any chain at crates/blockchain/blockchain.rs, add_transaction_to_pool then inserts it into the mempool at crates/blockchain/blockchain.rs, and payload selection does not filter out L2-only tx types at crates/blockchain/mempool.rs. On top of that, Privileged txs are explicitly sorted ahead of normal txs at crates/blockchain/payload.rs. So an L1 node can still build an invalid block locally; for Privileged, the sender is still caller-controlled because sender() returns from directly at crates/common/types/transaction.rs. This should reject tx.tx_type().is_l2_only() during L1 mempool admission and ideally also at payload filtering.

  2. Medium: two L1 re-execution paths still bypass the new guard. generate_bal_for_block executes the block directly at crates/blockchain/blockchain.rs, and witness generation does the same at crates/blockchain/blockchain.rs, without calling validate_l1_transaction_types. Those are auxiliary paths today, but leaving some vm.execute_block call sites permissive makes the invariant easy to regress later. The safer pattern is to enforce the L1 tx-type check on every L1 block re-execution entrypoint.

Aside from those gaps, the main import-path change and the exhaustive TxType::is_l2_only() classification look sound.


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

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 29, 2026

Greptile Summary

This PR closes a consensus-divergence and sender-forgery vulnerability by rejecting L2-only transaction types (0x7d FeeToken, 0x7e PrivilegedL2Transaction) on the L1 block import path. The fix is correctly threaded through all three execute_block* call sites and is gated on BlockchainType::L1 so L2 execution is unaffected.

  • TxType::is_l2_only() uses an exhaustive match with no wildcard arm, so any future TxType variant must be explicitly classified or the code won't compile — a strong compile-time safety guarantee.
  • validate_l1_transaction_types() is inserted right after validate_block_pre_execution in execute_block, execute_block_pipeline, and execute_block_from_state, covering add_block, add_block_pipeline*, and add_blocks_in_batch.
  • The regression test builds a block with a correct pre-fix state root so it would have been accepted before the fix, proving the vulnerability end-to-end.

Confidence Score: 4/5

The change is a targeted security fix that correctly gates on BlockchainType::L1 and is applied at every block execution entry point; L2 paths are untouched.

All three execution entry points are covered, the is_l2_only match is exhaustively safe against future variants, and the default BlockchainType is L1, making the fix the safe default. The one minor finding is a cosmetic double tx_type() call that does not affect correctness.

No files require special attention beyond the double tx_type() call noted in blockchain.rs.

Important Files Changed

Filename Overview
crates/common/types/transaction.rs Adds is_l2_only() to TxType with an exhaustive match (no wildcard arm), ensuring any future TxType variant must be explicitly classified or the code won't compile.
crates/common/errors.rs Adds InvalidBlockError::UnsupportedTransactionType(u8) with a hex-formatted error message; straightforward and correct.
crates/blockchain/blockchain.rs Adds validate_l1_transaction_types() called at all three execute_block* sites; regression test correctly constructs a block whose state root is valid pre-fix to prove the vulnerability. Minor: tx_type() is called twice inside the validation loop.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Block received] --> B[find_parent_header]
    B --> C[validate_block_pre_execution]
    C --> D{validate_l1_transaction_types}
    D -- BlockchainType::L2 --> E[skip check, return Ok]
    D -- BlockchainType::L1 --> F{any tx where is_l2_only?}
    F -- No --> G[execute_block / execute_block_pipeline / execute_block_from_state]
    F -- Yes 0x7d or 0x7e --> H[Err: UnsupportedTransactionType]
    G --> I[validate_gas_used + state_root checks]
    I --> J[store_block]

    style H fill:#f55,color:#fff
    style D fill:#ff9,color:#000
Loading
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
crates/blockchain/blockchain.rs:415-421
`tx.tx_type()` is called twice in the hot path — once for the predicate and once to produce the error payload. Since `TxType` is `Copy` this is functionally safe, but caching the value in a `let` binding makes the intent clearer and avoids the redundant call.

```suggestion
        for tx in &block.body.transactions {
            let tx_type = tx.tx_type();
            if tx_type.is_l2_only() {
                return Err(ChainError::InvalidBlock(
                    InvalidBlockError::UnsupportedTransactionType(tx_type as u8),
                ));
            }
        }
```

Reviews (1): Last reviewed commit: "fix(l1): reject L2-only transaction type..." | Re-trigger Greptile

Comment on lines +415 to +421
for tx in &block.body.transactions {
if tx.tx_type().is_l2_only() {
return Err(ChainError::InvalidBlock(
InvalidBlockError::UnsupportedTransactionType(tx.tx_type() as u8),
));
}
}
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 tx.tx_type() is called twice in the hot path — once for the predicate and once to produce the error payload. Since TxType is Copy this is functionally safe, but caching the value in a let binding makes the intent clearer and avoids the redundant call.

Suggested change
for tx in &block.body.transactions {
if tx.tx_type().is_l2_only() {
return Err(ChainError::InvalidBlock(
InvalidBlockError::UnsupportedTransactionType(tx.tx_type() as u8),
));
}
}
for tx in &block.body.transactions {
let tx_type = tx.tx_type();
if tx_type.is_l2_only() {
return Err(ChainError::InvalidBlock(
InvalidBlockError::UnsupportedTransactionType(tx_type as u8),
));
}
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/blockchain/blockchain.rs
Line: 415-421

Comment:
`tx.tx_type()` is called twice in the hot path — once for the predicate and once to produce the error payload. Since `TxType` is `Copy` this is functionally safe, but caching the value in a `let` binding makes the intent clearer and avoids the redundant call.

```suggestion
        for tx in &block.body.transactions {
            let tx_type = tx.tx_type();
            if tx_type.is_l2_only() {
                return Err(ChainError::InvalidBlock(
                    InvalidBlockError::UnsupportedTransactionType(tx_type as u8),
                ));
            }
        }
```

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

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

L1 Ethereum client

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant