diff --git a/README.md b/README.md index 04d0b27..05f52d5 100644 --- a/README.md +++ b/README.md @@ -324,6 +324,36 @@ How it works: This design ensures safe upgrades for existing networks: contracts that were previously rejected due to size limits won't suddenly become deployable until the network explicitly activates the new limit at a specific block height. +### Restricting Contract Deployment + +If you want a permissioned chain where only specific EOAs can deploy contracts, configure a deploy allowlist in the chainspec: + +```json +"config": { + ..., + "evolve": { + "deployAllowlist": [ + "0xYourDeployerAddressHere", + "0xAnotherDeployerAddressHere" + ], + "deployAllowlistActivationHeight": 0 + } +} +``` + +How it works: + +- The allowlist is enforced at the EVM handler before execution. +- Only top-level `CREATE` transactions from allowlisted callers are accepted. +- Contract-to-contract `CREATE/CREATE2` is still allowed (by design). +- If `deployAllowlistActivationHeight` is omitted, it defaults to `0` when the list is non-empty. +- If the list is empty or missing, contract deployment remains unrestricted. + +Operational notes: + +- The allowlist is static and must be changed via a chainspec update. +- Duplicate entries or the zero address are rejected at startup. + ### Payload Builder Configuration The payload builder can be configured with: diff --git a/crates/ev-revm/src/api/builder.rs b/crates/ev-revm/src/api/builder.rs index 9e3ab1e..ef3ecfc 100644 --- a/crates/ev-revm/src/api/builder.rs +++ b/crates/ev-revm/src/api/builder.rs @@ -30,7 +30,7 @@ where self, redirect: Option, ) -> DefaultEvEvm<::Context> { - EvEvm::from_inner(self.build_mainnet(), redirect, false) + EvEvm::from_inner(self.build_mainnet(), redirect, None, false) } fn build_ev_with_inspector( @@ -38,6 +38,11 @@ where inspector: INSP, redirect: Option, ) -> EvEvm<::Context, INSP> { - EvEvm::from_inner(self.build_mainnet_with_inspector(inspector), redirect, true) + EvEvm::from_inner( + self.build_mainnet_with_inspector(inspector), + redirect, + None, + true, + ) } } diff --git a/crates/ev-revm/src/api/exec.rs b/crates/ev-revm/src/api/exec.rs index 2de215c..c26c43e 100644 --- a/crates/ev-revm/src/api/exec.rs +++ b/crates/ev-revm/src/api/exec.rs @@ -44,9 +44,11 @@ where fn transact_one(&mut self, tx: Self::Tx) -> Result { let redirect = self.redirect(); + let deploy_allowlist = self.deploy_allowlist(); let inner = self.inner_mut(); inner.ctx.set_tx(tx); - let mut handler = EvHandler::<_, _, EthFrame>::new(redirect); + let mut handler = + EvHandler::<_, _, EthFrame>::new(redirect, deploy_allowlist); handler.run(inner) } @@ -58,8 +60,10 @@ where &mut self, ) -> Result, Self::Error> { let redirect = self.redirect(); + let deploy_allowlist = self.deploy_allowlist(); let inner = self.inner_mut(); - let mut handler = EvHandler::<_, _, EthFrame>::new(redirect); + let mut handler = + EvHandler::<_, _, EthFrame>::new(redirect, deploy_allowlist); handler.run(inner).map(|result| { let state = inner.journal_mut().finalize(); ExecResultAndState::new(result, state) @@ -91,9 +95,11 @@ where fn inspect_one_tx(&mut self, tx: Self::Tx) -> Result { let redirect = self.redirect(); + let deploy_allowlist = self.deploy_allowlist(); let inner = self.inner_mut(); inner.ctx.set_tx(tx); - let mut handler = EvHandler::<_, _, EthFrame>::new(redirect); + let mut handler = + EvHandler::<_, _, EthFrame>::new(redirect, deploy_allowlist); handler.inspect_run(inner) } } @@ -119,6 +125,7 @@ where data: Bytes, ) -> Result { let redirect = self.redirect(); + let deploy_allowlist = self.deploy_allowlist(); let inner = self.inner_mut(); inner .ctx @@ -127,7 +134,8 @@ where system_contract_address, data, )); - let mut handler = EvHandler::<_, _, EthFrame>::new(redirect); + let mut handler = + EvHandler::<_, _, EthFrame>::new(redirect, deploy_allowlist); handler.run_system_call(inner) } } @@ -146,6 +154,7 @@ where data: Bytes, ) -> Result { let redirect = self.redirect(); + let deploy_allowlist = self.deploy_allowlist(); let inner = self.inner_mut(); inner .ctx @@ -154,7 +163,8 @@ where system_contract_address, data, )); - let mut handler = EvHandler::<_, _, EthFrame>::new(redirect); + let mut handler = + EvHandler::<_, _, EthFrame>::new(redirect, deploy_allowlist); handler.inspect_run_system_call(inner) } } diff --git a/crates/ev-revm/src/deploy.rs b/crates/ev-revm/src/deploy.rs new file mode 100644 index 0000000..dba1573 --- /dev/null +++ b/crates/ev-revm/src/deploy.rs @@ -0,0 +1,44 @@ +//! Deploy allowlist settings for contract creation control. + +use alloy_primitives::Address; +use std::sync::Arc; + +/// Settings for gating contract deployment by caller allowlist. +#[derive(Debug, Clone)] +pub struct DeployAllowlistSettings { + allowlist: Arc<[Address]>, + activation_height: u64, +} + +impl DeployAllowlistSettings { + /// Creates a new deploy allowlist configuration. + pub fn new(allowlist: Vec
, activation_height: u64) -> Self { + let mut allowlist = allowlist; + debug_assert!(!allowlist.is_empty(), "deploy allowlist must not be empty"); + allowlist.sort_unstable(); + Self { + allowlist: Arc::from(allowlist), + activation_height, + } + } + + /// Returns the activation height for deploy allowlist enforcement. + pub const fn activation_height(&self) -> u64 { + self.activation_height + } + + /// Returns the allowlisted caller addresses. + pub fn allowlist(&self) -> &[Address] { + &self.allowlist + } + + /// Returns true if the allowlist is active at the given block number. + pub const fn is_active(&self, block_number: u64) -> bool { + block_number >= self.activation_height + } + + /// Returns true if the caller is in the allowlist. + pub fn is_allowed(&self, caller: Address) -> bool { + self.allowlist.binary_search(&caller).is_ok() + } +} diff --git a/crates/ev-revm/src/evm.rs b/crates/ev-revm/src/evm.rs index 043b593..c9161c8 100644 --- a/crates/ev-revm/src/evm.rs +++ b/crates/ev-revm/src/evm.rs @@ -1,6 +1,6 @@ //! EV-specific EVM wrapper that installs the base-fee redirect handler. -use crate::base_fee::BaseFeeRedirect; +use crate::{base_fee::BaseFeeRedirect, deploy::DeployAllowlistSettings}; use alloy_evm::{Evm as AlloyEvm, EvmEnv}; use alloy_primitives::{Address, Bytes}; use reth_revm::{ @@ -33,6 +33,7 @@ pub type DefaultEvEvm = EvEvm; pub struct EvEvm { inner: Evm, PRECOMP, EthFrame>, redirect: Option, + deploy_allowlist: Option, inspect: bool, } @@ -52,6 +53,7 @@ where frame_stack: FrameStack::new(), }, redirect, + deploy_allowlist: None, inspect: false, } } @@ -59,13 +61,19 @@ where impl EvEvm { /// Wraps an existing EVM instance with the redirect policy. - pub fn from_inner(inner: T, redirect: Option, inspect: bool) -> Self + pub fn from_inner( + inner: T, + redirect: Option, + deploy_allowlist: Option, + inspect: bool, + ) -> Self where T: IntoRevmEvm, { Self { inner: inner.into_revm_evm(), redirect, + deploy_allowlist, inspect, } } @@ -82,11 +90,17 @@ impl EvEvm { self.redirect } + /// Returns the configured deploy allowlist settings, if any. + pub fn deploy_allowlist(&self) -> Option { + self.deploy_allowlist.clone() + } + /// Allows adjusting the precompiles map while preserving redirect configuration. pub fn with_precompiles(self, precompiles: OP) -> EvEvm { EvEvm { inner: self.inner.with_precompiles(precompiles), redirect: self.redirect, + deploy_allowlist: self.deploy_allowlist, inspect: self.inspect, } } @@ -96,6 +110,7 @@ impl EvEvm { EvEvm { inner: self.inner.with_inspector(inspector), redirect: self.redirect, + deploy_allowlist: self.deploy_allowlist, inspect: self.inspect, } } diff --git a/crates/ev-revm/src/factory.rs b/crates/ev-revm/src/factory.rs index ae4d72e..d4d65e1 100644 --- a/crates/ev-revm/src/factory.rs +++ b/crates/ev-revm/src/factory.rs @@ -1,6 +1,6 @@ //! Helpers for wrapping Reth EVM factories with the EV handler. -use crate::{base_fee::BaseFeeRedirect, evm::EvEvm}; +use crate::{base_fee::BaseFeeRedirect, deploy::DeployAllowlistSettings, evm::EvEvm}; use alloy_evm::{ eth::{EthBlockExecutorFactory, EthEvmContext, EthEvmFactory}, precompiles::{DynPrecompile, Precompile, PrecompilesMap}, @@ -104,6 +104,7 @@ pub struct EvEvmFactory { inner: F, redirect: Option, mint_precompile: Option, + deploy_allowlist: Option, contract_size_limit: Option, } @@ -113,12 +114,14 @@ impl EvEvmFactory { inner: F, redirect: Option, mint_precompile: Option, + deploy_allowlist: Option, contract_size_limit: Option, ) -> Self { Self { inner, redirect, mint_precompile, + deploy_allowlist, contract_size_limit, } } @@ -186,7 +189,12 @@ impl EvmFactory for EvEvmFactory { evm_env.cfg_env.limit_contract_code_size = Some(limit); } let inner = self.inner.create_evm(db, evm_env); - let mut evm = EvEvm::from_inner(inner, self.redirect_for_block(block_number), false); + let mut evm = EvEvm::from_inner( + inner, + self.redirect_for_block(block_number), + self.deploy_allowlist.clone(), + false, + ); { let inner = evm.inner_mut(); self.install_mint_precompile(&mut inner.precompiles, block_number); @@ -206,7 +214,12 @@ impl EvmFactory for EvEvmFactory { input.cfg_env.limit_contract_code_size = Some(limit); } let inner = self.inner.create_evm_with_inspector(db, input, inspector); - let mut evm = EvEvm::from_inner(inner, self.redirect_for_block(block_number), true); + let mut evm = EvEvm::from_inner( + inner, + self.redirect_for_block(block_number), + self.deploy_allowlist.clone(), + true, + ); { let inner = evm.inner_mut(); self.install_mint_precompile(&mut inner.precompiles, block_number); @@ -220,6 +233,7 @@ pub fn with_ev_handler( config: EthEvmConfig, redirect: Option, mint_precompile: Option, + deploy_allowlist: Option, contract_size_limit: Option, ) -> EthEvmConfig> { let EthEvmConfig { @@ -230,6 +244,7 @@ pub fn with_ev_handler( *executor_factory.evm_factory(), redirect, mint_precompile, + deploy_allowlist, contract_size_limit, ); let new_executor_factory = EthBlockExecutorFactory::new( @@ -316,6 +331,7 @@ mod tests { Some(BaseFeeRedirectSettings::new(redirect, 0)), None, None, + None, ) .create_evm(state, evm_env.clone()); @@ -407,6 +423,7 @@ mod tests { None, Some(MintPrecompileSettings::new(contract, 0)), None, + None, ) .create_evm(state, evm_env); @@ -448,6 +465,7 @@ mod tests { Some(BaseFeeRedirectSettings::new(BaseFeeRedirect::new(sink), 5)), None, None, + None, ); let mut before_env: alloy_evm::EvmEnv = EvmEnv::default(); @@ -515,6 +533,7 @@ mod tests { None, Some(MintPrecompileSettings::new(contract, 3)), None, + None, ); let tx_env = || crate::factory::TxEnv { diff --git a/crates/ev-revm/src/handler.rs b/crates/ev-revm/src/handler.rs index af2a350..d569869 100644 --- a/crates/ev-revm/src/handler.rs +++ b/crates/ev-revm/src/handler.rs @@ -1,11 +1,15 @@ //! Execution handler extensions for EV-specific fee policies. -use crate::base_fee::{BaseFeeRedirect, BaseFeeRedirectError}; +use crate::{ + base_fee::{BaseFeeRedirect, BaseFeeRedirectError}, + deploy::DeployAllowlistSettings, +}; +use alloy_primitives::TxKind; use reth_revm::{ inspector::{Inspector, InspectorEvmTr, InspectorHandler}, revm::{ context::result::ExecutionResult, - context_interface::{result::HaltReason, ContextTr, JournalTr}, + context_interface::{result::HaltReason, Block, ContextTr, JournalTr, Transaction}, handler::{ post_execution, EthFrame, EvmTr, EvmTrError, FrameResult, FrameTr, Handler, MainnetHandler, @@ -22,14 +26,19 @@ use reth_revm::{ pub struct EvHandler { inner: MainnetHandler, redirect: Option, + deploy_allowlist: Option, } impl EvHandler { /// Creates a new handler wrapper with the provided redirect policy. - pub fn new(redirect: Option) -> Self { + pub fn new( + redirect: Option, + deploy_allowlist: Option, + ) -> Self { Self { inner: MainnetHandler::default(), redirect, + deploy_allowlist, } } @@ -37,6 +46,41 @@ impl EvHandler { pub const fn redirect(&self) -> Option { self.redirect } + + const fn deploy_allowlist_for_block( + &self, + block_number: u64, + ) -> Option<&DeployAllowlistSettings> { + match self.deploy_allowlist.as_ref() { + Some(settings) if settings.is_active(block_number) => Some(settings), + _ => None, + } + } + + fn ensure_deploy_allowed(&self, evm: &EVM) -> Result<(), ERROR> + where + EVM: EvmTr>>, + ERROR: EvmTrError, + { + let block_number = evm + .ctx_ref() + .block() + .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(), + ), + ); + } + Ok(()) + } } impl Handler for EvHandler @@ -69,6 +113,7 @@ where &self, evm: &mut Self::Evm, ) -> Result<(), Self::Error> { + self.ensure_deploy_allowed(evm)?; self.inner.validate_against_state_and_deduct_caller(evm) } @@ -165,7 +210,7 @@ where mod tests { use super::*; use crate::EvEvm; - use alloy_primitives::{address, Address, Bytes, U256}; + use alloy_primitives::{address, Address, Bytes, TxKind, U256}; use reth_revm::{ inspector::NoOpInspector, revm::{ @@ -239,6 +284,26 @@ mod tests { assert!(beneficiary_balance.is_zero()); } + #[test] + fn reject_deploy_for_non_allowlisted_caller() { + let allowlisted = address!("0x00000000000000000000000000000000000000aa"); + let caller = address!("0x00000000000000000000000000000000000000bb"); + let allowlist = DeployAllowlistSettings::new(vec![allowlisted], 0); + + let mut ctx = Context::mainnet().with_db(EmptyDB::default()); + ctx.block.number = U256::from(1); + ctx.cfg.spec = SpecId::CANCUN; + ctx.tx.caller = caller; + ctx.tx.kind = TxKind::Create; + ctx.tx.gas_limit = 1_000_000; + + let mut evm = EvEvm::new(ctx, NoOpInspector, None); + let handler: TestHandler = EvHandler::new(None, Some(allowlist)); + + let result = handler.validate_against_state_and_deduct_caller(&mut evm); + assert!(matches!(result, Err(EVMError::Custom(_)))); + } + fn setup_evm(redirect: BaseFeeRedirect, beneficiary: Address) -> (TestEvm, TestHandler) { let mut ctx = Context::mainnet().with_db(EmptyDB::default()); ctx.block.basefee = BASE_FEE; @@ -255,7 +320,7 @@ mod tests { journal.load_account(beneficiary).unwrap(); } - let handler: TestHandler = EvHandler::new(Some(redirect)); + let handler: TestHandler = EvHandler::new(Some(redirect), None); (evm, handler) } diff --git a/crates/ev-revm/src/lib.rs b/crates/ev-revm/src/lib.rs index da8401f..11fe505 100644 --- a/crates/ev-revm/src/lib.rs +++ b/crates/ev-revm/src/lib.rs @@ -3,6 +3,8 @@ pub mod api; pub mod base_fee; pub mod config; +/// Deploy allowlist configuration helpers. +pub mod deploy; pub mod evm; pub mod factory; pub mod handler; @@ -10,6 +12,7 @@ pub mod handler; pub use api::EvBuilder; pub use base_fee::{BaseFeeRedirect, BaseFeeRedirectError}; pub use config::{BaseFeeConfig, ConfigError}; +pub use deploy::DeployAllowlistSettings; pub use evm::{DefaultEvEvm, EvEvm}; pub use factory::{ with_ev_handler, BaseFeeRedirectSettings, ContractSizeLimitSettings, EvEvmFactory, diff --git a/crates/node/src/config.rs b/crates/node/src/config.rs index e8bb18b..e12a5fe 100644 --- a/crates/node/src/config.rs +++ b/crates/node/src/config.rs @@ -1,9 +1,12 @@ use alloy_primitives::Address; use reth_chainspec::ChainSpec; use serde::{Deserialize, Serialize}; +use std::collections::HashSet; /// Default contract size limit in bytes (24KB per EIP-170). pub const DEFAULT_CONTRACT_SIZE_LIMIT: usize = 24 * 1024; +/// Maximum number of addresses allowed in the deploy allowlist. +pub const MAX_DEPLOY_ALLOWLIST_LEN: usize = 1024; #[derive(Debug, Clone, Serialize, Deserialize, Default)] struct ChainspecEvolveConfig { @@ -21,6 +24,12 @@ struct ChainspecEvolveConfig { /// Block height at which the custom contract size limit activates. #[serde(default, rename = "contractSizeLimitActivationHeight")] pub contract_size_limit_activation_height: Option, + /// Optional allowlist of addresses permitted to deploy contracts. + #[serde(default, rename = "deployAllowlist")] + pub deploy_allowlist: Option>, + /// Block height at which deploy allowlist enforcement activates. + #[serde(default, rename = "deployAllowlistActivationHeight")] + pub deploy_allowlist_activation_height: Option, } /// Configuration for the Evolve payload builder @@ -44,6 +53,12 @@ pub struct EvolvePayloadBuilderConfig { /// Block height at which the custom contract size limit activates. #[serde(default)] pub contract_size_limit_activation_height: Option, + /// Allowlist of addresses permitted to deploy contracts. + #[serde(default)] + pub deploy_allowlist: Vec
, + /// Block height at which deploy allowlist enforcement activates. + #[serde(default)] + pub deploy_allowlist_activation_height: Option, } impl EvolvePayloadBuilderConfig { @@ -56,6 +71,8 @@ impl EvolvePayloadBuilderConfig { mint_precompile_activation_height: None, contract_size_limit: None, contract_size_limit_activation_height: None, + deploy_allowlist: Vec::new(), + deploy_allowlist_activation_height: None, } } @@ -90,6 +107,17 @@ impl EvolvePayloadBuilderConfig { config.contract_size_limit = extras.contract_size_limit; config.contract_size_limit_activation_height = extras.contract_size_limit_activation_height; + + if let Some(allowlist) = extras.deploy_allowlist { + config.deploy_allowlist = allowlist; + config.deploy_allowlist_activation_height = + extras.deploy_allowlist_activation_height; + if !config.deploy_allowlist.is_empty() + && config.deploy_allowlist_activation_height.is_none() + { + config.deploy_allowlist_activation_height = Some(0); + } + } } Ok(config) } @@ -117,8 +145,43 @@ impl EvolvePayloadBuilderConfig { .unwrap_or(DEFAULT_CONTRACT_SIZE_LIMIT) } + /// Returns the deploy allowlist and activation height (defaulting to 0) if configured. + pub fn deploy_allowlist_settings(&self) -> Option<(Vec
, u64)> { + if self.deploy_allowlist.is_empty() { + None + } else { + let activation = self.deploy_allowlist_activation_height.unwrap_or(0); + Some((self.deploy_allowlist.clone(), activation)) + } + } + /// Validates the configuration - pub const fn validate(&self) -> Result<(), ConfigError> { + pub fn validate(&self) -> Result<(), ConfigError> { + self.validate_deploy_allowlist() + } + + fn validate_deploy_allowlist(&self) -> Result<(), ConfigError> { + let allowlist_len = self.deploy_allowlist.len(); + if allowlist_len > MAX_DEPLOY_ALLOWLIST_LEN { + return Err(ConfigError::InvalidDeployAllowlist(format!( + "deployAllowlist has {allowlist_len} entries (max {MAX_DEPLOY_ALLOWLIST_LEN})" + ))); + } + + let mut seen = HashSet::with_capacity(allowlist_len); + for addr in &self.deploy_allowlist { + if addr.is_zero() { + return Err(ConfigError::InvalidDeployAllowlist( + "deployAllowlist contains zero address".to_string(), + )); + } + if !seen.insert(*addr) { + return Err(ConfigError::InvalidDeployAllowlist( + "deployAllowlist contains duplicate entries".to_string(), + )); + } + } + Ok(()) } @@ -154,13 +217,16 @@ pub enum ConfigError { /// Chainspec extras contained invalid values #[error("Invalid evolve extras in chainspec: {0}")] InvalidExtras(#[from] serde_json::Error), + /// Deploy allowlist configuration invalid + #[error("Invalid deploy allowlist configuration: {0}")] + InvalidDeployAllowlist(String), } #[cfg(test)] mod tests { use super::*; use alloy_genesis::Genesis; - use alloy_primitives::address; + use alloy_primitives::{address, Address}; use reth_chainspec::ChainSpecBuilder; use serde_json::json; @@ -306,6 +372,8 @@ mod tests { assert_eq!(config.mint_admin, None); assert_eq!(config.base_fee_redirect_activation_height, None); assert_eq!(config.mint_precompile_activation_height, None); + assert!(config.deploy_allowlist.is_empty()); + assert_eq!(config.deploy_allowlist_activation_height, None); } #[test] @@ -317,11 +385,13 @@ mod tests { assert_eq!(config.base_fee_redirect_activation_height, None); assert_eq!(config.mint_precompile_activation_height, None); assert_eq!(config.contract_size_limit, None); + assert!(config.deploy_allowlist.is_empty()); + assert_eq!(config.deploy_allowlist_activation_height, None); } #[test] fn test_validate_always_ok() { - // Test that validate always returns Ok for now + // Test that validate returns Ok for defaults let config = EvolvePayloadBuilderConfig::new(); assert!(config.validate().is_ok()); @@ -332,10 +402,88 @@ mod tests { mint_precompile_activation_height: Some(0), contract_size_limit: None, contract_size_limit_activation_height: None, + deploy_allowlist: Vec::new(), + deploy_allowlist_activation_height: None, }; assert!(config_with_sink.validate().is_ok()); } + #[test] + fn test_deploy_allowlist_defaults_activation_to_zero() { + let allowlist = vec![ + address!("00000000000000000000000000000000000000aa"), + address!("00000000000000000000000000000000000000bb"), + ]; + let extras = json!({ + "deployAllowlist": allowlist + }); + + let chainspec = create_test_chainspec_with_extras(Some(extras)); + let config = EvolvePayloadBuilderConfig::from_chain_spec(&chainspec).unwrap(); + + assert_eq!(config.deploy_allowlist.len(), 2); + assert_eq!(config.deploy_allowlist_activation_height, Some(0)); + } + + #[test] + fn test_deploy_allowlist_rejects_zero_address() { + let extras = json!({ + "deployAllowlist": [ + "0x0000000000000000000000000000000000000000" + ] + }); + + let chainspec = create_test_chainspec_with_extras(Some(extras)); + let config = EvolvePayloadBuilderConfig::from_chain_spec(&chainspec).unwrap(); + + assert!(matches!( + config.validate(), + Err(ConfigError::InvalidDeployAllowlist(_)) + )); + } + + #[test] + fn test_deploy_allowlist_rejects_duplicates() { + let dup = address!("00000000000000000000000000000000000000aa"); + let extras = json!({ + "deployAllowlist": [dup, dup] + }); + + let chainspec = create_test_chainspec_with_extras(Some(extras)); + let config = EvolvePayloadBuilderConfig::from_chain_spec(&chainspec).unwrap(); + + assert!(matches!( + config.validate(), + Err(ConfigError::InvalidDeployAllowlist(_)) + )); + } + + #[test] + fn test_deploy_allowlist_rejects_too_many_entries() { + let mut allowlist = Vec::new(); + for i in 0..=MAX_DEPLOY_ALLOWLIST_LEN { + let mut bytes = [0u8; 20]; + bytes[12..].copy_from_slice(&(i as u64 + 1).to_be_bytes()); + let addr = Address::new(bytes); + allowlist.push(addr); + } + let config = EvolvePayloadBuilderConfig { + base_fee_sink: None, + mint_admin: None, + base_fee_redirect_activation_height: None, + mint_precompile_activation_height: None, + contract_size_limit: None, + contract_size_limit_activation_height: None, + deploy_allowlist: allowlist, + deploy_allowlist_activation_height: Some(0), + }; + + assert!(matches!( + config.validate(), + Err(ConfigError::InvalidDeployAllowlist(_)) + )); + } + #[test] fn test_base_fee_sink_for_block() { let sink = address!("0000000000000000000000000000000000000003"); @@ -346,6 +494,8 @@ mod tests { mint_precompile_activation_height: None, contract_size_limit: None, contract_size_limit_activation_height: None, + deploy_allowlist: Vec::new(), + deploy_allowlist_activation_height: None, }; assert_eq!(config.base_fee_sink_for_block(4), None); diff --git a/crates/node/src/executor.rs b/crates/node/src/executor.rs index 5bcbc0c..5c136b9 100644 --- a/crates/node/src/executor.rs +++ b/crates/node/src/executor.rs @@ -3,7 +3,7 @@ use alloy_evm::eth::{spec::EthExecutorSpec, EthEvmFactory}; use ev_revm::{ with_ev_handler, BaseFeeRedirect, BaseFeeRedirectSettings, ContractSizeLimitSettings, - EvEvmFactory, MintPrecompileSettings, + DeployAllowlistSettings, EvEvmFactory, MintPrecompileSettings, }; use reth_chainspec::ChainSpec; use reth_ethereum::{ @@ -65,10 +65,24 @@ where ContractSizeLimitSettings::new(limit, activation) }); + let deploy_allowlist = + evolve_config + .deploy_allowlist_settings() + .map(|(allowlist, activation)| { + info!( + target = "ev-reth::executor", + allowlist_len = allowlist.len(), + activation_height = activation, + "Deploy allowlist enabled" + ); + DeployAllowlistSettings::new(allowlist, activation) + }); + Ok(with_ev_handler( base_config, redirect, mint_precompile, + deploy_allowlist, contract_size_limit, )) } diff --git a/crates/tests/src/common.rs b/crates/tests/src/common.rs index e671ec9..328ff55 100644 --- a/crates/tests/src/common.rs +++ b/crates/tests/src/common.rs @@ -10,7 +10,7 @@ use alloy_genesis::Genesis; use alloy_primitives::{Address, Bytes, ChainId, Signature, TxKind, B256, U256}; use ev_revm::{ with_ev_handler, BaseFeeRedirect, BaseFeeRedirectSettings, ContractSizeLimitSettings, - MintPrecompileSettings, + DeployAllowlistSettings, MintPrecompileSettings, }; use eyre::Result; use reth_chainspec::{ChainSpec, ChainSpecBuilder}; @@ -43,27 +43,28 @@ pub const TEST_BASE_FEE: u64 = 0; /// Creates a reusable chain specification for tests. pub fn create_test_chain_spec() -> Arc { - create_test_chain_spec_with_extras(None, None) + create_test_chain_spec_with_extras(None, None, None) } /// Creates a reusable chain specification with an optional base fee sink address. pub fn create_test_chain_spec_with_base_fee_sink(base_fee_sink: Option
) -> Arc { - create_test_chain_spec_with_extras(base_fee_sink, None) + create_test_chain_spec_with_extras(base_fee_sink, None, None) } /// Creates a reusable chain specification with a configured mint admin address. pub fn create_test_chain_spec_with_mint_admin(mint_admin: Address) -> Arc { - create_test_chain_spec_with_extras(None, Some(mint_admin)) + create_test_chain_spec_with_extras(None, Some(mint_admin), None) } fn create_test_chain_spec_with_extras( base_fee_sink: Option
, mint_admin: Option
, + deploy_allowlist: Option>, ) -> Arc { let mut genesis: Genesis = serde_json::from_str(include_str!("../assets/genesis.json")).expect("valid genesis"); - if base_fee_sink.is_some() || mint_admin.is_some() { + if base_fee_sink.is_some() || mint_admin.is_some() || deploy_allowlist.is_some() { let mut extras = serde_json::Map::new(); if let Some(sink) = base_fee_sink { extras.insert("baseFeeSink".to_string(), json!(sink)); @@ -71,6 +72,9 @@ fn create_test_chain_spec_with_extras( if let Some(admin) = mint_admin { extras.insert("mintAdmin".to_string(), json!(admin)); } + if let Some(allowlist) = deploy_allowlist { + extras.insert("deployAllowlist".to_string(), json!(allowlist)); + } genesis .config .extra_fields @@ -86,6 +90,13 @@ fn create_test_chain_spec_with_extras( ) } +/// Creates a reusable chain specification with a configured deploy allowlist. +pub fn create_test_chain_spec_with_deploy_allowlist( + deploy_allowlist: Vec
, +) -> Arc { + create_test_chain_spec_with_extras(None, None, Some(deploy_allowlist)) +} + /// Shared test fixture for evolve payload builder tests #[derive(Debug)] pub struct EvolveTestFixture { @@ -143,10 +154,14 @@ impl EvolveTestFixture { let contract_size_limit = config .contract_size_limit_settings() .map(|(limit, activation)| ContractSizeLimitSettings::new(limit, activation)); + let deploy_allowlist = config + .deploy_allowlist_settings() + .map(|(allowlist, activation)| DeployAllowlistSettings::new(allowlist, activation)); let wrapped_evm = with_ev_handler( evm_config, base_fee_redirect, mint_precompile, + deploy_allowlist, contract_size_limit, ); diff --git a/crates/tests/src/e2e_tests.rs b/crates/tests/src/e2e_tests.rs index 21a8ef5..9afd697 100644 --- a/crates/tests/src/e2e_tests.rs +++ b/crates/tests/src/e2e_tests.rs @@ -26,7 +26,8 @@ use reth_rpc_api::clients::{EngineApiClient, EthApiClient}; use crate::common::{ create_test_chain_spec, create_test_chain_spec_with_base_fee_sink, - create_test_chain_spec_with_mint_admin, TEST_CHAIN_ID, + create_test_chain_spec_with_deploy_allowlist, create_test_chain_spec_with_mint_admin, + TEST_CHAIN_ID, }; use ev_precompiles::mint::MINT_PRECOMPILE_ADDR; @@ -1058,3 +1059,149 @@ async fn test_e2e_mint_precompile_via_contract() -> Result<()> { Ok(()) } + +/// Tests that deploy allowlist prevents unauthorized contract creation. +#[tokio::test(flavor = "multi_thread")] +async fn test_e2e_deploy_allowlist_blocks_unauthorized_deploys() -> Result<()> { + reth_tracing::init_test_tracing(); + + let mut wallets = Wallet::new(2).with_chain_id(TEST_CHAIN_ID).wallet_gen(); + let allowed_deployer = wallets.remove(0); + let denied_deployer = wallets.remove(0); + + let chain_spec = create_test_chain_spec_with_deploy_allowlist(vec![allowed_deployer.address()]); + let chain_id = chain_spec.chain().id(); + + let mut setup = Setup::::default() + .with_chain_spec(chain_spec) + .with_network(NetworkSetup::single_node()) + .with_dev_mode(true); + + let mut env = Environment::::default(); + setup.apply::(&mut env).await?; + + let parent_block = env.node_clients[0] + .get_block_by_number(BlockNumberOrTag::Latest) + .await? + .expect("parent block should exist"); + let mut parent_hash = parent_block.header.hash; + let mut parent_timestamp = parent_block.header.inner.timestamp; + let mut parent_number = parent_block.header.inner.number; + let gas_limit = parent_block.header.inner.gas_limit; + + let denied_deploy_tx = TransactionRequest { + nonce: Some(0), + gas: Some(1_000_000), + max_fee_per_gas: Some(20_000_000_000), + max_priority_fee_per_gas: Some(2_000_000_000), + chain_id: Some(chain_id), + value: Some(U256::ZERO), + to: Some(TxKind::Create), + input: TransactionInput { + input: None, + data: Some(Bytes::from_static(&ADMIN_PROXY_INITCODE)), + }, + ..Default::default() + }; + + let denied_envelope = + TransactionTestContext::sign_tx(denied_deployer.clone(), denied_deploy_tx).await; + let denied_raw: Bytes = denied_envelope.encoded_2718().into(); + + build_block_with_transactions( + &mut env, + &mut parent_hash, + &mut parent_number, + &mut parent_timestamp, + Some(gas_limit), + vec![denied_raw], + Address::ZERO, + ) + .await?; + + let latest_block = env.node_clients[0] + .get_block_by_number(BlockNumberOrTag::Latest) + .await? + .expect("latest block available after denied deploy"); + let denied_tx_count = match latest_block.transactions { + BlockTransactions::Full(ref txs) => txs.len(), + BlockTransactions::Hashes(ref hashes) => hashes.len(), + BlockTransactions::Uncle => 0, + }; + assert_eq!( + denied_tx_count, 0, + "denied deploy transaction should be excluded from the block" + ); + + let denied_address = contract_address_from_nonce(denied_deployer.address(), 0); + let denied_code = + EthApiClient::::get_code( + &env.node_clients[0].rpc, + denied_address, + Some(BlockId::latest()), + ) + .await?; + assert!( + denied_code.is_empty(), + "unauthorized deploy should not create contract code" + ); + + let allowed_deploy_tx = TransactionRequest { + nonce: Some(0), + gas: Some(1_000_000), + max_fee_per_gas: Some(20_000_000_000), + max_priority_fee_per_gas: Some(2_000_000_000), + chain_id: Some(chain_id), + value: Some(U256::ZERO), + to: Some(TxKind::Create), + input: TransactionInput { + input: None, + data: Some(Bytes::from_static(&ADMIN_PROXY_INITCODE)), + }, + ..Default::default() + }; + + let allowed_envelope = + TransactionTestContext::sign_tx(allowed_deployer.clone(), allowed_deploy_tx).await; + let allowed_raw: Bytes = allowed_envelope.encoded_2718().into(); + + build_block_with_transactions( + &mut env, + &mut parent_hash, + &mut parent_number, + &mut parent_timestamp, + Some(gas_limit), + vec![allowed_raw], + Address::ZERO, + ) + .await?; + + let latest_block = env.node_clients[0] + .get_block_by_number(BlockNumberOrTag::Latest) + .await? + .expect("latest block available after allowed deploy"); + let allowed_tx_count = match latest_block.transactions { + BlockTransactions::Full(ref txs) => txs.len(), + BlockTransactions::Hashes(ref hashes) => hashes.len(), + BlockTransactions::Uncle => 0, + }; + assert_eq!( + allowed_tx_count, 1, + "allowlisted deploy transaction should be included in the block" + ); + + let allowed_address = contract_address_from_nonce(allowed_deployer.address(), 0); + let allowed_code = + EthApiClient::::get_code( + &env.node_clients[0].rpc, + allowed_address, + Some(BlockId::latest()), + ) + .await?; + assert!( + !allowed_code.is_empty(), + "allowlisted deploy should create contract code" + ); + + Ok(()) +} diff --git a/docs/contracts/admin_proxy.md b/docs/contracts/admin_proxy.md index b9ae946..e9c2069 100644 --- a/docs/contracts/admin_proxy.md +++ b/docs/contracts/admin_proxy.md @@ -156,6 +156,10 @@ In this example, the owner EOA is `0xYourEOAAddressHere` (replace with your actu "baseFeeRedirectActivationHeight": 0, "mintAdmin": "0x000000000000000000000000000000000000Ad00", "mintPrecompileActivationHeight": 0, + "deployAllowlist": [ + "0xYourEOAAddressHere" + ], + "deployAllowlistActivationHeight": 0, "contractSizeLimit": 131072, "contractSizeLimitActivationHeight": 0 } diff --git a/docs/guide/permissioned-evm.md b/docs/guide/permissioned-evm.md new file mode 100644 index 0000000..941500a --- /dev/null +++ b/docs/guide/permissioned-evm.md @@ -0,0 +1,56 @@ +# Permissioned EVM Guide: Contract Deployment Allowlist + +## Overview + +This guide covers the deploy allowlist: a chainspec-controlled guardrail that restricts +top-level contract creation transactions to a set of approved EOAs. It does not restrict +regular call transactions and is not a full transaction allowlist. + +## Deploy Allowlist (execution layer) + +**Purpose**: Restrict contract deployment to a known set of EOAs. + +**Mechanics**: + +- Enforcement happens in the EVM handler before execution. +- Only top-level contract creation transactions are checked. +- Contract-to-contract `CREATE/CREATE2` is still allowed (by design). +- If no allowlist is configured, behavior matches standard Ethereum. + +**Chainspec configuration** (inside `config.evolve`): + +```json +"evolve": { + "deployAllowlist": [ + "0xYourDeployerAddressHere", + "0xAnotherDeployerAddressHere" + ], + "deployAllowlistActivationHeight": 0 +} +``` + +## Activation and Validation Rules + +- If `deployAllowlist` is set and `deployAllowlistActivationHeight` is omitted, activation + defaults to `0`. +- If the allowlist is empty or missing, contract deployment is unrestricted. +- Duplicate entries or the zero address are rejected at startup. +- The list is capped at 1024 addresses. + +## Security and Limitations + +- This is not a general permissioned chain; it only gates top-level contract creation. +- Non-allowlisted EOAs can still deploy contracts via existing factory contracts if those + factories allow it. +- If you need stricter control, only deploy factories with explicit access control and avoid + deploying open factories. + +## Operational Notes + +- The allowlist is static; changes require a chainspec update and node restart. +- For existing networks, use an activation height to coordinate rollouts. + +References: + +- `crates/node/src/config.rs` +- `crates/ev-revm/src/handler.rs`