diff --git a/crates/ev-revm/src/deploy.rs b/crates/ev-revm/src/deploy.rs index d6ac3cf..620697f 100644 --- a/crates/ev-revm/src/deploy.rs +++ b/crates/ev-revm/src/deploy.rs @@ -45,3 +45,38 @@ 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..8478f96 100644 --- a/crates/ev-revm/src/handler.rs +++ b/crates/ev-revm/src/handler.rs @@ -78,11 +78,14 @@ 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()) { + 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(), diff --git a/crates/node/src/txpool.rs b/crates/node/src/txpool.rs index 9b983df..614f716 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}, @@ -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)] @@ -286,13 +286,19 @@ 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 +311,24 @@ impl PoolTransactionError for EvTxPoolError { #[derive(Debug, Clone)] 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) -> Self { + pub fn new( + inner: EthTransactionValidator, + deploy_allowlist: Option, + ) -> Self + where + Client: BlockNumReader, + { Self { inner: Arc::new(inner), + deploy_allowlist, } } @@ -383,6 +400,31 @@ 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) + } + }; + let caller = pooled.transaction().signer(); + 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, + is_top_level_create, + block_number, + ) { + return Err(InvalidPoolTransactionError::other( + EvTxPoolError::DeployNotAllowed, + )); + } + } + let consensus = pooled.transaction().inner(); let EvTxEnvelope::EvNode(tx) = consensus else { if sender_balance < *pooled.cost() { @@ -422,7 +464,7 @@ impl EvTransactionValidator { impl TransactionValidator for EvTransactionValidator where - Client: ChainSpecProvider + StateProviderFactory, + Client: ChainSpecProvider + StateProviderFactory + BlockNumReader, { type Transaction = EvPooledTransaction; @@ -470,7 +512,7 @@ pub struct EvolvePoolBuilder; impl PoolBuilder for EvolvePoolBuilder where Types: NodeTypes< - ChainSpec: EthereumHardforks, + ChainSpec = reth_chainspec::ChainSpec, Primitives: NodePrimitives, >, Node: FullNodeTypes, @@ -521,7 +563,26 @@ 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_else(|err| { + warn!( + target: "reth::cli", + "Failed to parse evolve config from chainspec: {err}" + ); + Default::default() + }); + let deploy_allowlist = + evolve_config + .deploy_allowlist_settings() + .map(|(allowlist, activation)| { + 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 +637,28 @@ 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 +666,9 @@ 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 +679,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 +689,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 +723,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 +746,29 @@ 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(_))); + } + } }