From c93a0f78d0df4035770d36148b1e46bd4b52694f Mon Sep 17 00:00:00 2001 From: chatton Date: Mon, 16 Feb 2026 08:59:26 +0000 Subject: [PATCH 1/6] txpool: enforce deploy allowlist at admission; unify check in one place; add unit test; fix ChainSpec bound in pool builder --- crates/node/src/txpool.rs | 100 +++++++++++++++++++++++++++++++++++--- 1 file changed, 92 insertions(+), 8 deletions(-) diff --git a/crates/node/src/txpool.rs b/crates/node/src/txpool.rs index 9b983df..96c124a 100644 --- a/crates/node/src/txpool.rs +++ b/crates/node/src/txpool.rs @@ -286,13 +286,16 @@ pub enum EvTxPoolError { /// Error while querying account info from the state provider. #[error("state provider error: {0}")] StateProvider(String), + /// Top-level contract deployment not allowed for caller. + #[error("contract deployment not allowed")] + DeployNotAllowed, } impl PoolTransactionError for EvTxPoolError { fn is_bad_transaction(&self) -> bool { matches!( self, - Self::EmptyCalls | Self::InvalidCreatePosition | Self::InvalidSponsorSignature + Self::EmptyCalls | Self::InvalidCreatePosition | Self::InvalidSponsorSignature | Self::DeployNotAllowed ) } @@ -305,13 +308,15 @@ impl PoolTransactionError for EvTxPoolError { #[derive(Debug, Clone)] pub struct EvTransactionValidator { inner: Arc>, + deploy_allowlist: Option, } impl EvTransactionValidator { /// Wraps the provided Ethereum validator with EV-specific validation logic. - pub fn new(inner: EthTransactionValidator) -> Self { + pub fn new(inner: EthTransactionValidator, deploy_allowlist: Option) -> Self { Self { inner: Arc::new(inner), + deploy_allowlist, } } @@ -383,6 +388,25 @@ impl EvTransactionValidator { where Client: StateProviderFactory, { + // Unified deploy allowlist check (covers both Ethereum and EvNode txs). + if let Some(settings) = &self.deploy_allowlist { + let is_top_level_create = match pooled.transaction().inner() { + EvTxEnvelope::Ethereum(tx) => alloy_consensus::Transaction::is_create(tx), + EvTxEnvelope::EvNode(ref signed) => { + let tx = signed.tx(); + tx.calls.first().map(|c| c.to.is_create()).unwrap_or(false) + } + }; + if is_top_level_create { + let caller = pooled.transaction().signer(); + if !settings.is_allowed(caller) { + return Err(InvalidPoolTransactionError::other( + EvTxPoolError::DeployNotAllowed, + )); + } + } + } + let consensus = pooled.transaction().inner(); let EvTxEnvelope::EvNode(tx) = consensus else { if sender_balance < *pooled.cost() { @@ -470,7 +494,7 @@ pub struct EvolvePoolBuilder; impl PoolBuilder for EvolvePoolBuilder where Types: NodeTypes< - ChainSpec: EthereumHardforks, + ChainSpec = reth_chainspec::ChainSpec, Primitives: NodePrimitives, >, Node: FullNodeTypes, @@ -521,7 +545,21 @@ where ctx.task_executor().clone(), blob_store.clone(), ) - .map(EvTransactionValidator::new); + .map(|inner| { + // Wire deploy-allowlist from chainspec extras into the pool validator. + let evolve_config = crate::config::EvolvePayloadBuilderConfig::from_chain_spec( + ctx.chain_spec().as_ref(), + ) + .unwrap_or_default(); + let deploy_allowlist = evolve_config + .deploy_allowlist_settings() + .map(|(allowlist, activation)| { + // Note: pool validation currently assumes allowlist is active once set. + // Activation height is still enforced during execution. + ev_revm::deploy::DeployAllowlistSettings::new(allowlist, activation) + }); + EvTransactionValidator::new(inner, deploy_allowlist) + }); if validator.validator().inner.eip4844() { let kzg_settings = validator.validator().inner.kzg_settings().clone(); @@ -576,6 +614,25 @@ mod tests { Signed::new_unhashed(tx, sample_signature()) } + /// Creates a non-sponsored `EvNode` transaction with CREATE as the first call. + fn create_non_sponsored_evnode_create_tx(gas_limit: u64, max_fee_per_gas: u128) -> EvNodeSignedTx { + let tx = EvNodeTransaction { + chain_id: 1, + nonce: 0, + max_priority_fee_per_gas: 1, + max_fee_per_gas, + gas_limit, + calls: vec![Call { + to: TxKind::Create, + value: U256::ZERO, + input: Bytes::from_static(&[0x60, 0x00, 0x60, 0x00, 0xf3]), // minimal initcode + }], + access_list: AccessList::default(), + fee_payer_signature: None, + }; + Signed::new_unhashed(tx, sample_signature()) + } + fn create_pooled_tx(signed_tx: EvNodeSignedTx, signer: Address) -> EvPooledTransaction { let envelope = EvTxEnvelope::EvNode(signed_tx); let recovered = alloy_consensus::transaction::Recovered::new_unchecked(envelope, signer); @@ -583,7 +640,7 @@ mod tests { EvPooledTransaction::new(recovered, encoded_length) } - fn create_test_validator() -> EvTransactionValidator { + fn create_test_validator(deploy_allowlist: Option) -> EvTransactionValidator { use reth_transaction_pool::{ blobstore::InMemoryBlobStore, validate::EthTransactionValidatorBuilder, }; @@ -594,7 +651,7 @@ mod tests { .no_shanghai() .no_cancun() .build(blob_store); - EvTransactionValidator::new(inner) + EvTransactionValidator::new(inner, deploy_allowlist) } /// Tests that non-sponsored `EvNode` transactions with insufficient sender balance @@ -604,7 +661,7 @@ mod tests { /// sender balance for non-sponsored `EvNode` transactions. #[test] fn non_sponsored_evnode_rejects_insufficient_balance() { - let validator = create_test_validator(); + let validator = create_test_validator(None); // Create a non-sponsored EvNode transaction let gas_limit = 21_000u64; @@ -638,7 +695,7 @@ mod tests { /// Tests that non-sponsored `EvNode` transactions with sufficient balance are accepted. #[test] fn non_sponsored_evnode_accepts_sufficient_balance() { - let validator = create_test_validator(); + let validator = create_test_validator(None); let gas_limit = 21_000u64; let max_fee_per_gas = 1_000_000_000u128; @@ -661,4 +718,31 @@ mod tests { result ); } + + /// Tests pool-level deploy allowlist rejection for EvNode CREATE when caller not allowlisted. + #[test] + fn evnode_create_rejected_when_not_allowlisted() { + + + // Configure deploy allowlist with a different address than the signer + let allowed = Address::from([0x11u8; 20]); + let settings = ev_revm::deploy::DeployAllowlistSettings::new(vec![allowed], 0); + let validator = create_test_validator(Some(settings)); + + let gas_limit = 200_000u64; + let max_fee_per_gas = 1_000_000_000u128; + let signed_tx = create_non_sponsored_evnode_create_tx(gas_limit, max_fee_per_gas); + + let signer = Address::from([0x22u8; 20]); // not allowlisted + let pooled = create_pooled_tx(signed_tx, signer); + + let sender_balance = *pooled.cost() + U256::from(1); + let mut state: Option> = None; + + let result = validator.validate_evnode(&pooled, sender_balance, &mut state); + assert!(result.is_err()); + if let Err(err) = result { + assert!(matches!(err, InvalidPoolTransactionError::Other(_))); + } + } } From caadf1ae70986f03d450c6f80877e569f01594b5 Mon Sep 17 00:00:00 2001 From: chatton Date: Mon, 16 Feb 2026 09:24:14 +0000 Subject: [PATCH 2/6] chore: refactor to use same check_deploy_allowed fn in both cases --- crates/ev-revm/src/deploy.rs | 33 ++++++++++++++++++++++++++++ crates/ev-revm/src/handler.rs | 17 +++++++-------- crates/node/src/txpool.rs | 41 +++++++++++++++++++++++++---------- 3 files changed, 70 insertions(+), 21 deletions(-) diff --git a/crates/ev-revm/src/deploy.rs b/crates/ev-revm/src/deploy.rs index d6ac3cf..be497b8 100644 --- a/crates/ev-revm/src/deploy.rs +++ b/crates/ev-revm/src/deploy.rs @@ -45,3 +45,36 @@ impl DeployAllowlistSettings { self.allowlist.binary_search(&caller).is_ok() } } + +/// Error returned by deploy allowlist checks. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeployCheckError { + /// Caller is not allowed to perform top-level contract creation. + NotAllowed, +} + +// Intentionally no envelope discriminator here to keep dependencies light. + +/// Enforces the deploy allowlist policy. +/// +/// If `is_top_level_create` is false or settings are None or not active yet, this is a no-op. +/// Otherwise returns `NotAllowed` if `caller` is not in the allowlist. +pub fn check_deploy_allowed( + settings: Option<&DeployAllowlistSettings>, + caller: Address, + is_top_level_create: bool, + block_number: u64, +) -> Result<(), DeployCheckError> { + if !is_top_level_create { + return Ok(()); + } + let Some(settings) = settings else { return Ok(()); }; + if !settings.is_active(block_number) { + return Ok(()); + } + if settings.is_allowed(caller) { + Ok(()) + } else { + Err(DeployCheckError::NotAllowed) + } +} diff --git a/crates/ev-revm/src/handler.rs b/crates/ev-revm/src/handler.rs index 4de0f08..d4a1382 100644 --- a/crates/ev-revm/src/handler.rs +++ b/crates/ev-revm/src/handler.rs @@ -78,16 +78,15 @@ impl EvHandler { .number() .try_into() .unwrap_or(u64::MAX); - let Some(settings) = self.deploy_allowlist_for_block(block_number) else { - return Ok(()); - }; let tx = evm.ctx_ref().tx(); - if matches!(tx.kind(), TxKind::Create) && !settings.is_allowed(tx.caller()) { - return Err( - ::from_string( - "contract deployment not allowed".to_string(), - ), - ); + let caller = tx.caller(); + let is_create = matches!(tx.kind(), TxKind::Create); + + let settings = self.deploy_allowlist_for_block(block_number); + if let Err(_e) = crate::deploy::check_deploy_allowed(settings, caller, is_create, block_number) { + return Err(::from_string( + "contract deployment not allowed".to_string(), + )); } Ok(()) } diff --git a/crates/node/src/txpool.rs b/crates/node/src/txpool.rs index 96c124a..c6edc9b 100644 --- a/crates/node/src/txpool.rs +++ b/crates/node/src/txpool.rs @@ -21,7 +21,7 @@ use reth_node_builder::{ BuilderContext, }; use reth_primitives_traits::NodePrimitives; -use reth_storage_api::{AccountInfoReader, StateProviderFactory}; +use reth_storage_api::{AccountInfoReader, BlockNumReader, StateProviderFactory}; use reth_transaction_pool::{ blobstore::DiskFileBlobStore, error::{InvalidPoolTransactionError, PoolTransactionError}, @@ -305,15 +305,24 @@ impl PoolTransactionError for EvTxPoolError { } /// Transaction validator that adds EV-specific checks on top of the base validator. -#[derive(Debug, Clone)] +#[derive(Clone, Debug)] pub struct EvTransactionValidator { inner: Arc>, deploy_allowlist: Option, } -impl EvTransactionValidator { +impl EvTransactionValidator +where + Client: BlockNumReader, +{ /// Wraps the provided Ethereum validator with EV-specific validation logic. - pub fn new(inner: EthTransactionValidator, deploy_allowlist: Option) -> Self { + pub fn new( + inner: EthTransactionValidator, + deploy_allowlist: Option, + ) -> Self + where + Client: BlockNumReader, + { Self { inner: Arc::new(inner), deploy_allowlist, @@ -397,13 +406,19 @@ impl EvTransactionValidator { tx.calls.first().map(|c| c.to.is_create()).unwrap_or(false) } }; - if is_top_level_create { - let caller = pooled.transaction().signer(); - if !settings.is_allowed(caller) { - return Err(InvalidPoolTransactionError::other( - EvTxPoolError::DeployNotAllowed, - )); - } + let caller = pooled.transaction().signer(); + let block_number = self + .inner + .client() + .best_block_number() + .unwrap_or(0); + if let Err(_e) = ev_revm::deploy::check_deploy_allowed( + Some(settings), + caller, + is_top_level_create, + block_number, + ) { + return Err(InvalidPoolTransactionError::other(EvTxPoolError::DeployNotAllowed)); } } @@ -446,7 +461,9 @@ impl EvTransactionValidator { impl TransactionValidator for EvTransactionValidator where - Client: ChainSpecProvider + StateProviderFactory, + Client: ChainSpecProvider + + StateProviderFactory + + BlockNumReader, { type Transaction = EvPooledTransaction; From 945e71ab67ef67cb14d2eff32861737c7f977cc6 Mon Sep 17 00:00:00 2001 From: chatton Date: Mon, 16 Feb 2026 09:34:59 +0000 Subject: [PATCH 3/6] chore: pr cleanup --- crates/node/src/txpool.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/node/src/txpool.rs b/crates/node/src/txpool.rs index c6edc9b..564b1cc 100644 --- a/crates/node/src/txpool.rs +++ b/crates/node/src/txpool.rs @@ -305,7 +305,7 @@ impl PoolTransactionError for EvTxPoolError { } /// Transaction validator that adds EV-specific checks on top of the base validator. -#[derive(Clone, Debug)] +#[derive(Debug, Clone)] pub struct EvTransactionValidator { inner: Arc>, deploy_allowlist: Option, @@ -571,8 +571,6 @@ where let deploy_allowlist = evolve_config .deploy_allowlist_settings() .map(|(allowlist, activation)| { - // Note: pool validation currently assumes allowlist is active once set. - // Activation height is still enforced during execution. ev_revm::deploy::DeployAllowlistSettings::new(allowlist, activation) }); EvTransactionValidator::new(inner, deploy_allowlist) @@ -739,8 +737,6 @@ mod tests { /// Tests pool-level deploy allowlist rejection for EvNode CREATE when caller not allowlisted. #[test] fn evnode_create_rejected_when_not_allowlisted() { - - // Configure deploy allowlist with a different address than the signer let allowed = Address::from([0x11u8; 20]); let settings = ev_revm::deploy::DeployAllowlistSettings::new(vec![allowed], 0); From 8261d05b11ea338d0dec5d56c61260dd8d8caeb7 Mon Sep 17 00:00:00 2001 From: chatton Date: Mon, 16 Feb 2026 09:41:15 +0000 Subject: [PATCH 4/6] chore: fmt --- crates/ev-revm/src/deploy.rs | 4 +++- crates/ev-revm/src/handler.rs | 12 +++++++---- crates/node/src/txpool.rs | 39 ++++++++++++++++++++--------------- 3 files changed, 33 insertions(+), 22 deletions(-) diff --git a/crates/ev-revm/src/deploy.rs b/crates/ev-revm/src/deploy.rs index be497b8..620697f 100644 --- a/crates/ev-revm/src/deploy.rs +++ b/crates/ev-revm/src/deploy.rs @@ -68,7 +68,9 @@ pub fn check_deploy_allowed( if !is_top_level_create { return Ok(()); } - let Some(settings) = settings else { return Ok(()); }; + let Some(settings) = settings else { + return Ok(()); + }; if !settings.is_active(block_number) { return Ok(()); } diff --git a/crates/ev-revm/src/handler.rs b/crates/ev-revm/src/handler.rs index d4a1382..8478f96 100644 --- a/crates/ev-revm/src/handler.rs +++ b/crates/ev-revm/src/handler.rs @@ -83,10 +83,14 @@ impl EvHandler { let is_create = matches!(tx.kind(), TxKind::Create); let settings = self.deploy_allowlist_for_block(block_number); - if let Err(_e) = crate::deploy::check_deploy_allowed(settings, caller, is_create, block_number) { - return Err(::from_string( - "contract deployment not allowed".to_string(), - )); + if let Err(_e) = + crate::deploy::check_deploy_allowed(settings, caller, is_create, block_number) + { + return Err( + ::from_string( + "contract deployment not allowed".to_string(), + ), + ); } Ok(()) } diff --git a/crates/node/src/txpool.rs b/crates/node/src/txpool.rs index 564b1cc..94caef0 100644 --- a/crates/node/src/txpool.rs +++ b/crates/node/src/txpool.rs @@ -295,7 +295,10 @@ impl PoolTransactionError for EvTxPoolError { fn is_bad_transaction(&self) -> bool { matches!( self, - Self::EmptyCalls | Self::InvalidCreatePosition | Self::InvalidSponsorSignature | Self::DeployNotAllowed + Self::EmptyCalls + | Self::InvalidCreatePosition + | Self::InvalidSponsorSignature + | Self::DeployNotAllowed ) } @@ -407,18 +410,16 @@ where } }; let caller = pooled.transaction().signer(); - let block_number = self - .inner - .client() - .best_block_number() - .unwrap_or(0); + let block_number = self.inner.client().best_block_number().unwrap_or(0); if let Err(_e) = ev_revm::deploy::check_deploy_allowed( Some(settings), caller, is_top_level_create, block_number, ) { - return Err(InvalidPoolTransactionError::other(EvTxPoolError::DeployNotAllowed)); + return Err(InvalidPoolTransactionError::other( + EvTxPoolError::DeployNotAllowed, + )); } } @@ -461,9 +462,7 @@ where impl TransactionValidator for EvTransactionValidator where - Client: ChainSpecProvider - + StateProviderFactory - + BlockNumReader, + Client: ChainSpecProvider + StateProviderFactory + BlockNumReader, { type Transaction = EvPooledTransaction; @@ -568,11 +567,12 @@ where ctx.chain_spec().as_ref(), ) .unwrap_or_default(); - let deploy_allowlist = evolve_config - .deploy_allowlist_settings() - .map(|(allowlist, activation)| { - ev_revm::deploy::DeployAllowlistSettings::new(allowlist, activation) - }); + let deploy_allowlist = + evolve_config + .deploy_allowlist_settings() + .map(|(allowlist, activation)| { + ev_revm::deploy::DeployAllowlistSettings::new(allowlist, activation) + }); EvTransactionValidator::new(inner, deploy_allowlist) }); @@ -630,7 +630,10 @@ mod tests { } /// Creates a non-sponsored `EvNode` transaction with CREATE as the first call. - fn create_non_sponsored_evnode_create_tx(gas_limit: u64, max_fee_per_gas: u128) -> EvNodeSignedTx { + fn create_non_sponsored_evnode_create_tx( + gas_limit: u64, + max_fee_per_gas: u128, + ) -> EvNodeSignedTx { let tx = EvNodeTransaction { chain_id: 1, nonce: 0, @@ -655,7 +658,9 @@ mod tests { EvPooledTransaction::new(recovered, encoded_length) } - fn create_test_validator(deploy_allowlist: Option) -> EvTransactionValidator { + fn create_test_validator( + deploy_allowlist: Option, + ) -> EvTransactionValidator { use reth_transaction_pool::{ blobstore::InMemoryBlobStore, validate::EthTransactionValidatorBuilder, }; From ba5934610f683c379c627662a8fcc149301d8ed4 Mon Sep 17 00:00:00 2001 From: chatton Date: Mon, 16 Feb 2026 09:53:24 +0000 Subject: [PATCH 5/6] ci: clippy --- crates/node/src/txpool.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/node/src/txpool.rs b/crates/node/src/txpool.rs index 94caef0..7915183 100644 --- a/crates/node/src/txpool.rs +++ b/crates/node/src/txpool.rs @@ -739,7 +739,7 @@ mod tests { ); } - /// Tests pool-level deploy allowlist rejection for EvNode CREATE when caller not allowlisted. + /// Tests pool-level deploy allowlist rejection for `EvNode` CREATE when caller not allowlisted. #[test] fn evnode_create_rejected_when_not_allowlisted() { // Configure deploy allowlist with a different address than the signer From fe0743fc9aace2acc44d06b52a5032bd74c79a35 Mon Sep 17 00:00:00 2001 From: chatton Date: Tue, 17 Feb 2026 14:24:49 +0000 Subject: [PATCH 6/6] chore: address PR feedback --- crates/node/src/txpool.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/node/src/txpool.rs b/crates/node/src/txpool.rs index 7915183..614f716 100644 --- a/crates/node/src/txpool.rs +++ b/crates/node/src/txpool.rs @@ -29,7 +29,7 @@ use reth_transaction_pool::{ EthTransactionValidator, PoolTransaction, TransactionOrigin, TransactionValidationOutcome, TransactionValidationTaskExecutor, TransactionValidator, }; -use tracing::{debug, info}; +use tracing::{debug, info, warn}; /// Pool transaction wrapper for `EvTxEnvelope`. #[derive(Debug, Clone)] @@ -410,7 +410,9 @@ where } }; let caller = pooled.transaction().signer(); - let block_number = self.inner.client().best_block_number().unwrap_or(0); + let block_number = self.inner.client().best_block_number().map_err(|err| { + InvalidPoolTransactionError::other(EvTxPoolError::StateProvider(err.to_string())) + })?; if let Err(_e) = ev_revm::deploy::check_deploy_allowed( Some(settings), caller, @@ -566,7 +568,13 @@ where let evolve_config = crate::config::EvolvePayloadBuilderConfig::from_chain_spec( ctx.chain_spec().as_ref(), ) - .unwrap_or_default(); + .unwrap_or_else(|err| { + warn!( + target: "reth::cli", + "Failed to parse evolve config from chainspec: {err}" + ); + Default::default() + }); let deploy_allowlist = evolve_config .deploy_allowlist_settings()