From 0fb606b9e1318987f9637221d5e1d6f35451338f Mon Sep 17 00:00:00 2001 From: ilitteri Date: Mon, 11 May 2026 16:20:00 -0300 Subject: [PATCH 1/6] feat(l1): reject contract senders at mempool admission (EIP-3607) Adds the EIP-3607 sender check that every major Ethereum execution client (geth, reth, nethermind, erigon, besu) enforces. Transactions whose recovered sender has deployed code are rejected, except where the code is a valid EIP-7702 delegation designation (the account is still an EOA in spirit, just pointing at delegate code). - New helper `is_eip7702_delegation(code: &[u8]) -> bool` plus `EIP7702_DELEGATION_PREFIX = 0xef0100` and `EIP7702_DELEGATED_CODE_LEN = 23` constants in `crates/common/types/account.rs`. - `Blockchain::validate_transaction` now inspects the sender's `code_hash`; if non-empty, it fetches the code (single extra store hit only when the sender actually has code) and routes through `is_eip7702_delegation`. Non-delegation contract senders are rejected with the new `MempoolError::SenderIsContract`. - Six unit tests for the delegation predicate covering: empty, valid 23-byte designation, too-short, too-long, wrong-prefix, and arbitrary contract code. --- crates/blockchain/blockchain.rs | 17 +++++++++- crates/blockchain/error.rs | 2 ++ crates/common/types/account.rs | 58 +++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/crates/blockchain/blockchain.rs b/crates/blockchain/blockchain.rs index 98ebe601fd1..368ca5efba6 100644 --- a/crates/blockchain/blockchain.rs +++ b/crates/blockchain/blockchain.rs @@ -57,7 +57,7 @@ use constants::{ }; use error::MempoolError; use error::{ChainError, InvalidBlockError}; -use ethrex_common::constants::{EMPTY_TRIE_HASH, MIN_BASE_FEE_PER_BLOB_GAS}; +use ethrex_common::constants::{EMPTY_KECCACK_HASH, EMPTY_TRIE_HASH, MIN_BASE_FEE_PER_BLOB_GAS}; use crossbeam::channel::{self as cb, TryRecvError, select}; // Re-export stateless validation functions for backwards compatibility @@ -66,6 +66,7 @@ use ethrex_common::types::EIP4844Transaction; use ethrex_common::types::block_access_list::BlockAccessList; use ethrex_common::types::block_execution_witness::ExecutionWitness; use ethrex_common::types::fee_config::FeeConfig; +use ethrex_common::types::is_eip7702_delegation; use ethrex_common::types::{ AccountInfo, AccountState, AccountUpdate, Block, BlockHash, BlockHeader, BlockNumber, ChainConfig, Code, Receipt, Transaction, WrappedEIP4844Transaction, validate_block_body, @@ -2491,6 +2492,20 @@ impl Blockchain { return Err(MempoolError::NonceTooLow); } + // EIP-3607: reject txs from senders with deployed code, unless the + // code is an EIP-7702 delegation designation (the account is still + // an EOA in spirit, just pointing at delegate code). + if sender_acc_info.code_hash != *EMPTY_KECCACK_HASH { + let is_delegation = match self.storage.get_account_code(sender_acc_info.code_hash) { + Ok(Some(code)) => is_eip7702_delegation(code.bytecode.as_ref()), + Ok(None) => false, + Err(e) => return Err(e.into()), + }; + if !is_delegation { + return Err(MempoolError::SenderIsContract); + } + } + let tx_cost = tx .cost_without_base_fee() .ok_or(MempoolError::InvalidTxGasvalues)?; diff --git a/crates/blockchain/error.rs b/crates/blockchain/error.rs index 39436472dd4..761020c72a7 100644 --- a/crates/blockchain/error.rs +++ b/crates/blockchain/error.rs @@ -83,6 +83,8 @@ pub enum MempoolError { TxMaxInitCodeSizeError, #[error("Transaction max data size exceeded")] TxMaxDataSizeError, + #[error("Transaction sender is a contract account (EIP-3607)")] + SenderIsContract, #[error("Transaction gas limit exceeded")] TxGasLimitExceededError, #[error( diff --git a/crates/common/types/account.rs b/crates/common/types/account.rs index 589ac4f41cf..06f1b444b5c 100644 --- a/crates/common/types/account.rs +++ b/crates/common/types/account.rs @@ -191,6 +191,19 @@ pub fn code_hash(code: &Bytes, crypto: &dyn Crypto) -> H256 { H256(crypto.keccak256(code.as_ref())) } +/// EIP-7702 delegation designation: an EOA whose code is `0xef0100 || address`. +/// See . +pub const EIP7702_DELEGATION_PREFIX: [u8; 3] = [0xef, 0x01, 0x00]; +/// Total byte length of an EIP-7702 delegation designation: 3-byte prefix +/// plus the 20-byte target address. +pub const EIP7702_DELEGATED_CODE_LEN: usize = 23; + +/// Returns true iff `code` is a valid EIP-7702 delegation designation +/// (exactly 23 bytes, prefixed with `0xef0100`). +pub fn is_eip7702_delegation(code: &[u8]) -> bool { + code.len() == EIP7702_DELEGATED_CODE_LEN && code.starts_with(&EIP7702_DELEGATION_PREFIX) +} + impl RLPEncode for AccountInfo { fn encode(&self, buf: &mut dyn bytes::BufMut) { Encoder::new(buf) @@ -396,4 +409,49 @@ mod test { .unwrap() ) } + + #[test] + fn test_is_eip7702_delegation_valid() { + // 0xef0100 || 20-byte address + let mut code = Vec::with_capacity(23); + code.extend_from_slice(&EIP7702_DELEGATION_PREFIX); + code.extend_from_slice(&[0x42; 20]); + assert!(is_eip7702_delegation(&code)); + } + + #[test] + fn test_is_eip7702_delegation_rejects_empty() { + assert!(!is_eip7702_delegation(&[])); + } + + #[test] + fn test_is_eip7702_delegation_rejects_short() { + // Prefix only, no address. + assert!(!is_eip7702_delegation(&EIP7702_DELEGATION_PREFIX)); + } + + #[test] + fn test_is_eip7702_delegation_rejects_long() { + // Correct prefix but 24 bytes total. + let mut code = Vec::with_capacity(24); + code.extend_from_slice(&EIP7702_DELEGATION_PREFIX); + code.extend_from_slice(&[0x42; 21]); + assert!(!is_eip7702_delegation(&code)); + } + + #[test] + fn test_is_eip7702_delegation_rejects_wrong_prefix() { + // Right length, wrong magic. + let mut code = Vec::with_capacity(23); + code.extend_from_slice(&[0xef, 0x01, 0x01]); // off by one in the last prefix byte + code.extend_from_slice(&[0x42; 20]); + assert!(!is_eip7702_delegation(&code)); + } + + #[test] + fn test_is_eip7702_delegation_rejects_arbitrary_contract_code() { + // Real contract code starting with anything else. + let code = vec![0x60, 0x60, 0x60, 0x40, 0x52 /* ... */]; + assert!(!is_eip7702_delegation(&code)); + } } From 376ae58e63f136b7209d1b6d36dd9b6d816c25bb Mon Sep 17 00:00:00 2001 From: ilitteri Date: Mon, 11 May 2026 19:21:57 -0300 Subject: [PATCH 2/6] fix(l1): collapse EIP-3607 match block to `is_some_and` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the `match get_account_code` block with the idiomatic `?`-chain + `is_some_and`. Same semantics, no behavior change: error propagation via `?`, `Ok(None)` (code-hash set but code body missing — state inconsistency) remains a "not a delegation" result and falls through to the `SenderIsContract` rejection, which is the conservative choice peer clients also make. Doc-comment updated to document this intent explicitly so a future "fix" doesn't accidentally admit inconsistent storage. Follow-up not in this PR: a full integration test of `validate_transaction → SenderIsContract` requires seeding sender state (AccountInfo + code) via `apply_account_updates_batch`, which the test scaffolding in `test/tests/blockchain/mempool_tests.rs` doesn't currently do. The six unit tests of `is_eip7702_delegation` already cover the byte-level predicate; the six-line wire-up here is visually verifiable. --- crates/blockchain/blockchain.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/blockchain/blockchain.rs b/crates/blockchain/blockchain.rs index 368ca5efba6..df19a154db5 100644 --- a/crates/blockchain/blockchain.rs +++ b/crates/blockchain/blockchain.rs @@ -2492,15 +2492,17 @@ impl Blockchain { return Err(MempoolError::NonceTooLow); } - // EIP-3607: reject txs from senders with deployed code, unless the - // code is an EIP-7702 delegation designation (the account is still - // an EOA in spirit, just pointing at delegate code). + // EIP-3607: reject txs from senders with deployed code, unless + // the code is an EIP-7702 delegation designation (the account is + // still an EOA in spirit, just pointing at delegate code). When + // the code hash is set but `get_account_code` returns `None`, + // the store is inconsistent or pruned; we conservatively treat + // that as non-delegation and reject the tx. if sender_acc_info.code_hash != *EMPTY_KECCACK_HASH { - let is_delegation = match self.storage.get_account_code(sender_acc_info.code_hash) { - Ok(Some(code)) => is_eip7702_delegation(code.bytecode.as_ref()), - Ok(None) => false, - Err(e) => return Err(e.into()), - }; + let is_delegation = self + .storage + .get_account_code(sender_acc_info.code_hash)? + .is_some_and(|code| is_eip7702_delegation(code.bytecode.as_ref())); if !is_delegation { return Err(MempoolError::SenderIsContract); } From db12e3be67f8506fd355f4eb7a4d6ae9358ba163 Mon Sep 17 00:00:00 2001 From: ilitteri Date: Mon, 11 May 2026 21:34:17 -0300 Subject: [PATCH 3/6] perf(l1): length pre-check before fetching sender bytecode for EIP-3607 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot + @MegaRedHand flagged that `validate_transaction` decodes the sender's full bytecode via `get_account_code` for any non-empty `code_hash` just to conclude "not a delegation". For large contracts this is a wasted DB hit on every admission. Use `Store::get_code_metadata` (which the storage layer already exposes for length lookups) to fast-reject any code whose length is not exactly `EIP7702_DELEGATED_CODE_LEN` (23 bytes) — those cannot encode the `0xef0100 || address` delegation designation. Only when the metadata length matches do we fetch + verify the prefix. --- crates/blockchain/blockchain.rs | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/crates/blockchain/blockchain.rs b/crates/blockchain/blockchain.rs index df19a154db5..376a598971d 100644 --- a/crates/blockchain/blockchain.rs +++ b/crates/blockchain/blockchain.rs @@ -66,11 +66,11 @@ use ethrex_common::types::EIP4844Transaction; use ethrex_common::types::block_access_list::BlockAccessList; use ethrex_common::types::block_execution_witness::ExecutionWitness; use ethrex_common::types::fee_config::FeeConfig; -use ethrex_common::types::is_eip7702_delegation; use ethrex_common::types::{ AccountInfo, AccountState, AccountUpdate, Block, BlockHash, BlockHeader, BlockNumber, ChainConfig, Code, Receipt, Transaction, WrappedEIP4844Transaction, validate_block_body, }; +use ethrex_common::types::{EIP7702_DELEGATED_CODE_LEN, is_eip7702_delegation}; use ethrex_common::types::{ELASTICITY_MULTIPLIER, P2PTransaction}; use ethrex_common::types::{Fork, MempoolTransaction}; use ethrex_common::utils::keccak; @@ -2494,15 +2494,26 @@ impl Blockchain { // EIP-3607: reject txs from senders with deployed code, unless // the code is an EIP-7702 delegation designation (the account is - // still an EOA in spirit, just pointing at delegate code). When - // the code hash is set but `get_account_code` returns `None`, - // the store is inconsistent or pruned; we conservatively treat - // that as non-delegation and reject the tx. + // still an EOA in spirit, just pointing at delegate code). + // + // Length-based fast path: any code whose length isn't exactly + // `EIP7702_DELEGATED_CODE_LEN` (23) cannot be a delegation, so + // we reject without loading the bytecode. Only when the metadata + // length matches do we fetch + verify the prefix. This avoids + // pulling potentially large contract bytecode on every contract + // sender that hits admission (Copilot / @MegaRedHand review). if sender_acc_info.code_hash != *EMPTY_KECCACK_HASH { - let is_delegation = self + let metadata_len = self .storage - .get_account_code(sender_acc_info.code_hash)? - .is_some_and(|code| is_eip7702_delegation(code.bytecode.as_ref())); + .get_code_metadata(sender_acc_info.code_hash)? + .map(|m| m.length); + let is_delegation = if metadata_len == Some(EIP7702_DELEGATED_CODE_LEN as u64) { + self.storage + .get_account_code(sender_acc_info.code_hash)? + .is_some_and(|code| is_eip7702_delegation(code.bytecode.as_ref())) + } else { + false + }; if !is_delegation { return Err(MempoolError::SenderIsContract); } From c1ed9c077f6749349881a314bb17b439733c097d Mon Sep 17 00:00:00 2001 From: ilitteri Date: Mon, 11 May 2026 21:34:52 -0300 Subject: [PATCH 4/6] fix(l1): surface missing-code DB inconsistency as StoreError in EIP-3607 path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot + @codex review: when `code_hash` is non-empty but the bytecode is missing from the store, the previous code silently treated the account as "not a delegation" and rejected the tx with `SenderIsContract`. That masks an inconsistent DB state (code hash present but code missing) and — more importantly — wrongly rejects a valid EIP-7702-delegated EOA whenever the local copy of the delegation bytecode happens to be absent (snapsync recovery, pruning window, etc.). The VM path treats this same condition as a DB error. Now: when the code metadata claims a 23-byte payload, missing bytecode returns `StoreError::Custom("code missing for hash …")` so callers see the real cause rather than a spurious EIP-3607 rejection. --- crates/blockchain/blockchain.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/blockchain/blockchain.rs b/crates/blockchain/blockchain.rs index 376a598971d..c1c118d8c79 100644 --- a/crates/blockchain/blockchain.rs +++ b/crates/blockchain/blockchain.rs @@ -2508,9 +2508,22 @@ impl Blockchain { .get_code_metadata(sender_acc_info.code_hash)? .map(|m| m.length); let is_delegation = if metadata_len == Some(EIP7702_DELEGATED_CODE_LEN as u64) { - self.storage + // Metadata says the code is delegation-shaped; if the + // bytecode is then missing from the store, the DB is + // inconsistent — surface that as `StoreError` instead of + // silently treating the sender as a contract (which would + // wrongly reject a valid 7702-delegated EOA per + // Copilot/@codex review). + let code = self + .storage .get_account_code(sender_acc_info.code_hash)? - .is_some_and(|code| is_eip7702_delegation(code.bytecode.as_ref())) + .ok_or_else(|| { + StoreError::Custom(format!( + "code missing for hash {:?} despite present metadata", + sender_acc_info.code_hash + )) + })?; + is_eip7702_delegation(code.bytecode.as_ref()) } else { false }; From 98564000c3fa2cb33d6f2d48b6bf6749e8b9edc9 Mon Sep 17 00:00:00 2001 From: ilitteri Date: Mon, 11 May 2026 21:35:47 -0300 Subject: [PATCH 5/6] refactor(l1): collapse levm code_has_delegation onto common is_eip7702_delegation @MegaRedHand: "There are already many implementations of this." The new `is_eip7702_delegation` predicate in `ethrex-common` duplicates the existing levm `code_has_delegation` (and the predicate in `utils.rs:181`). levm's `code_has_delegation` now delegates to the common helper so both crates exercise the same byte-level check. Signature (`Result`) is preserved because several callers use `?`; the result is always `Ok(_)` now since the common helper is infallible. The `SET_CODE_DELEGATION_BYTES` / `EIP7702_DELEGATED_CODE_LEN` consts in levm/constants.rs are retained for the in-place slicing in `get_authorized_address_from_code` and adjacent helpers; cross-crate constant unification can be a follow-up. --- crates/vm/levm/src/utils.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/vm/levm/src/utils.rs b/crates/vm/levm/src/utils.rs index 4e9ccc5af6e..45468f7fc3a 100644 --- a/crates/vm/levm/src/utils.rs +++ b/crates/vm/levm/src/utils.rs @@ -178,11 +178,10 @@ pub fn word_to_address(word: U256) -> Address { // ================== EIP-7702 related functions ===================== pub fn code_has_delegation(code: &Bytes) -> Result { - if code.len() == EIP7702_DELEGATED_CODE_LEN { - let first_3_bytes = &code.get(..3).ok_or(InternalError::Slicing)?; - return Ok(*first_3_bytes == SET_CODE_DELEGATION_BYTES); - } - Ok(false) + // Delegate to the canonical predicate in `ethrex-common` so the + // EIP-7702 designation check (`0xef0100 || address`, exactly 23 bytes) + // has a single source of truth. Result kept for caller compatibility. + Ok(ethrex_common::types::is_eip7702_delegation(code.as_ref())) } /// Gets the address inside the bytecode if it has been From 0c2c8d68ee27e5285fc6046a7ef446c2ceea167d Mon Sep 17 00:00:00 2001 From: ilitteri Date: Tue, 12 May 2026 17:09:06 -0300 Subject: [PATCH 6/6] docs(l1): drop reviewer mentions from EIP-3607 code comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comments describe the behavior; reviewer attribution belongs in the PR body and the commit message, not in `// ...`. Two references in validate_transaction (one to the length pre-check rationale, one to the missing-bytecode StoreError fallback) named the reviewers who raised them — stripped without changing the explanations themselves. --- crates/blockchain/blockchain.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/blockchain/blockchain.rs b/crates/blockchain/blockchain.rs index c1c118d8c79..48f0166581a 100644 --- a/crates/blockchain/blockchain.rs +++ b/crates/blockchain/blockchain.rs @@ -2501,7 +2501,7 @@ impl Blockchain { // we reject without loading the bytecode. Only when the metadata // length matches do we fetch + verify the prefix. This avoids // pulling potentially large contract bytecode on every contract - // sender that hits admission (Copilot / @MegaRedHand review). + // sender that hits admission. if sender_acc_info.code_hash != *EMPTY_KECCACK_HASH { let metadata_len = self .storage @@ -2512,8 +2512,7 @@ impl Blockchain { // bytecode is then missing from the store, the DB is // inconsistent — surface that as `StoreError` instead of // silently treating the sender as a contract (which would - // wrongly reject a valid 7702-delegated EOA per - // Copilot/@codex review). + // wrongly reject a valid 7702-delegated EOA). let code = self .storage .get_account_code(sender_acc_info.code_hash)?