diff --git a/crates/ev-revm/src/factory.rs b/crates/ev-revm/src/factory.rs index ae4d72e..4eb4917 100644 --- a/crates/ev-revm/src/factory.rs +++ b/crates/ev-revm/src/factory.rs @@ -271,6 +271,7 @@ mod tests { const ADMIN_PROXY_RUNTIME: [u8; 42] = alloy_primitives::hex!( "36600060003760006000366000600073000000000000000000000000000000000000f1005af1600080f3" ); + const OVERSIZED_RUNTIME_SIZE: usize = 32 * 1024; fn empty_state() -> State> { State::builder() @@ -279,6 +280,39 @@ mod tests { .build() } + fn oversized_initcode(runtime_size: usize) -> Bytes { + const INIT_PREFIX_LEN: usize = 14; + + assert!( + runtime_size <= u16::MAX as usize, + "runtime size must fit in PUSH2" + ); + + let size_hi = ((runtime_size >> 8) & 0xff) as u8; + let size_lo = (runtime_size & 0xff) as u8; + let mut initcode = Vec::with_capacity(INIT_PREFIX_LEN + runtime_size); + + initcode.extend_from_slice(&[ + 0x61, + size_hi, + size_lo, // PUSH2 size + 0x60, + INIT_PREFIX_LEN as u8, // PUSH1 offset + 0x60, + 0x00, // PUSH1 0 + 0x39, // CODECOPY + 0x61, + size_hi, + size_lo, // PUSH2 size + 0x60, + 0x00, // PUSH1 0 + 0xf3, // RETURN + ]); + initcode.resize(INIT_PREFIX_LEN + runtime_size, 0u8); + + Bytes::from(initcode) + } + #[test] fn factory_applies_base_fee_redirect() { let sink = address!("0x00000000000000000000000000000000000000fe"); @@ -563,4 +597,116 @@ mod tests { .expect("mint precompile should mint after activation"); assert_eq!(mintee_account.info.balance, amount); } + + #[test] + fn contract_size_limit_rejects_oversized_code() { + let caller = address!("0x0000000000000000000000000000000000000abc"); + let initcode = oversized_initcode(OVERSIZED_RUNTIME_SIZE); + + let mut state = empty_state(); + state.insert_account( + caller, + AccountInfo { + balance: U256::from(10_000_000_000u64), + nonce: 0, + code_hash: KECCAK_EMPTY, + code: None, + }, + ); + + let mut evm_env: alloy_evm::EvmEnv = EvmEnv::default(); + evm_env.cfg_env.chain_id = 1; + evm_env.cfg_env.spec = SpecId::CANCUN; + evm_env.block_env.number = U256::from(1); + evm_env.block_env.basefee = 1; + evm_env.block_env.gas_limit = 30_000_000; + + let mut evm = EvEvmFactory::new(alloy_evm::eth::EthEvmFactory::default(), None, None, None) + .create_evm(state, evm_env); + + let tx = crate::factory::TxEnv { + caller, + kind: TxKind::Create, + gas_limit: 15_000_000, + gas_price: 1, + value: U256::ZERO, + data: initcode, + ..Default::default() + }; + + let result_and_state = evm + .transact_raw(tx) + .expect("oversized create executes to a halt"); + + let ExecutionResult::Halt { reason, .. } = result_and_state.result else { + panic!("expected oversized code to halt"); + }; + assert!( + matches!(reason, HaltReason::CreateContractSizeLimit), + "expected create code size limit halt" + ); + } + + #[test] + fn contract_size_limit_allows_oversized_code_when_configured() { + let caller = address!("0x0000000000000000000000000000000000000def"); + let initcode = oversized_initcode(OVERSIZED_RUNTIME_SIZE); + + let mut state = empty_state(); + state.insert_account( + caller, + AccountInfo { + balance: U256::from(10_000_000_000u64), + nonce: 0, + code_hash: KECCAK_EMPTY, + code: None, + }, + ); + + let mut evm_env: alloy_evm::EvmEnv = EvmEnv::default(); + evm_env.cfg_env.chain_id = 1; + evm_env.cfg_env.spec = SpecId::CANCUN; + evm_env.block_env.number = U256::from(1); + evm_env.block_env.basefee = 1; + evm_env.block_env.gas_limit = 30_000_000; + + let mut evm = EvEvmFactory::new( + alloy_evm::eth::EthEvmFactory::default(), + None, + None, + Some(ContractSizeLimitSettings::new(64 * 1024, 0)), + ) + .create_evm(state, evm_env); + + let tx = crate::factory::TxEnv { + caller, + kind: TxKind::Create, + gas_limit: 15_000_000, + gas_price: 1, + value: U256::ZERO, + data: initcode, + ..Default::default() + }; + + let result_and_state = evm.transact_raw(tx).expect("oversized create executes"); + + let ExecutionResult::Success { .. } = result_and_state.result else { + panic!("expected oversized code to deploy with custom limit"); + }; + + let created_address = result_and_state + .result + .created_address() + .expect("created address available"); + let state: EvmState = result_and_state.state; + let created_account = state + .get(&created_address) + .expect("created contract must be in state"); + let code = created_account.info.code.as_ref().expect("code stored"); + assert_eq!( + code.len(), + OVERSIZED_RUNTIME_SIZE, + "contract runtime size should match initcode payload" + ); + } } diff --git a/crates/node/src/config.rs b/crates/node/src/config.rs index e8bb18b..668de8a 100644 --- a/crates/node/src/config.rs +++ b/crates/node/src/config.rs @@ -119,7 +119,13 @@ impl EvolvePayloadBuilderConfig { /// Validates the configuration pub const fn validate(&self) -> Result<(), ConfigError> { - Ok(()) + match ( + self.contract_size_limit, + self.contract_size_limit_activation_height, + ) { + (Some(0), _) | (None, Some(_)) => Err(ConfigError::InvalidConfig), + _ => Ok(()), + } } /// Returns the configured base-fee redirect sink and activation height (defaulting to 0). @@ -320,8 +326,8 @@ mod tests { } #[test] - fn test_validate_always_ok() { - // Test that validate always returns Ok for now + fn test_validate_accepts_defaults() { + // Test that validate accepts default configurations let config = EvolvePayloadBuilderConfig::new(); assert!(config.validate().is_ok()); @@ -475,4 +481,26 @@ mod tests { DEFAULT_CONTRACT_SIZE_LIMIT ); } + + #[test] + fn test_contract_size_limit_zero_invalid() { + let config = EvolvePayloadBuilderConfig { + contract_size_limit: Some(0), + contract_size_limit_activation_height: Some(0), + ..EvolvePayloadBuilderConfig::new() + }; + + assert!(matches!(config.validate(), Err(ConfigError::InvalidConfig))); + } + + #[test] + fn test_contract_size_limit_activation_without_limit_invalid() { + let config = EvolvePayloadBuilderConfig { + contract_size_limit: None, + contract_size_limit_activation_height: Some(10), + ..EvolvePayloadBuilderConfig::new() + }; + + assert!(matches!(config.validate(), Err(ConfigError::InvalidConfig))); + } } diff --git a/crates/tests/src/common.rs b/crates/tests/src/common.rs index e671ec9..d0a571f 100644 --- a/crates/tests/src/common.rs +++ b/crates/tests/src/common.rs @@ -56,6 +56,37 @@ pub fn create_test_chain_spec_with_mint_admin(mint_admin: Address) -> Arc, +) -> Arc { + let mut genesis: Genesis = + serde_json::from_str(include_str!("../assets/genesis.json")).expect("valid genesis"); + + let mut extras = serde_json::Map::new(); + extras.insert("contractSizeLimit".to_string(), json!(contract_size_limit)); + if let Some(height) = activation_height { + extras.insert( + "contractSizeLimitActivationHeight".to_string(), + json!(height), + ); + } + + genesis + .config + .extra_fields + .insert("evolve".to_string(), serde_json::Value::Object(extras)); + + Arc::new( + ChainSpecBuilder::default() + .chain(reth_chainspec::Chain::from_id(TEST_CHAIN_ID)) + .genesis(genesis) + .cancun_activated() + .build(), + ) +} + fn create_test_chain_spec_with_extras( base_fee_sink: Option
, mint_admin: Option
, diff --git a/crates/tests/src/e2e_tests.rs b/crates/tests/src/e2e_tests.rs index 21a8ef5..76ff71f 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_contract_size_limit, create_test_chain_spec_with_mint_admin, + TEST_CHAIN_ID, }; use ev_precompiles::mint::MINT_PRECOMPILE_ADDR; @@ -61,6 +62,7 @@ const ADMIN_PROXY_INITCODE: [u8; 54] = alloy_primitives::hex!( /// Test recipient address used in mint/burn tests. const TEST_MINT_RECIPIENT: Address = address!("0x0101010101010101010101010101010101010101"); +const OVERSIZED_RUNTIME_SIZE: usize = 32 * 1024; /// Computes the contract address that will be created by a deployer at a given nonce. /// @@ -76,6 +78,39 @@ fn contract_address_from_nonce(deployer: Address, nonce: u64) -> Address { deployer.create(nonce) } +fn oversized_initcode(runtime_size: usize) -> Bytes { + const INIT_PREFIX_LEN: usize = 14; + + assert!( + runtime_size <= u16::MAX as usize, + "runtime size must fit in PUSH2" + ); + + let size_hi = ((runtime_size >> 8) & 0xff) as u8; + let size_lo = (runtime_size & 0xff) as u8; + let mut initcode = Vec::with_capacity(INIT_PREFIX_LEN + runtime_size); + + initcode.extend_from_slice(&[ + 0x61, + size_hi, + size_lo, // PUSH2 size + 0x60, + INIT_PREFIX_LEN as u8, // PUSH1 offset + 0x60, + 0x00, // PUSH1 0 + 0x39, // CODECOPY + 0x61, + size_hi, + size_lo, // PUSH2 size + 0x60, + 0x00, // PUSH1 0 + 0xf3, // RETURN + ]); + initcode.resize(INIT_PREFIX_LEN + runtime_size, 0u8); + + Bytes::from(initcode) +} + /// Builds and submits a block containing the specified transactions via the Engine API. /// /// This helper function orchestrates the complete block building process: @@ -374,6 +409,88 @@ async fn test_e2e_base_fee_sink_receives_base_fee() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread")] +async fn test_e2e_contract_size_limit_allows_oversized_deploy() -> Result<()> { + reth_tracing::init_test_tracing(); + + let chain_id = TEST_CHAIN_ID; + let mut wallets = Wallet::new(1).with_chain_id(chain_id).wallet_gen(); + let deployer = wallets.remove(0); + let deployer_address = deployer.address(); + let contract_address = contract_address_from_nonce(deployer_address, 0); + + let chain_spec = create_test_chain_spec_with_contract_size_limit(64 * 1024, Some(0)); + let evolve_config = + EvolvePayloadBuilderConfig::from_chain_spec(chain_spec.as_ref()).expect("valid config"); + assert_eq!( + evolve_config.contract_size_limit, + Some(64 * 1024), + "chainspec should propagate custom contract size limit" + ); + + let mut setup = Setup::::default() + .with_chain_spec(chain_spec.clone()) + .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 deploy_tx = TransactionRequest { + nonce: Some(0), + gas: Some(15_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(oversized_initcode(OVERSIZED_RUNTIME_SIZE)), + }, + ..Default::default() + }; + + let deploy_envelope = TransactionTestContext::sign_tx(deployer, deploy_tx).await; + let deploy_raw: Bytes = deploy_envelope.encoded_2718().into(); + + build_block_with_transactions( + &mut env, + &mut parent_hash, + &mut parent_number, + &mut parent_timestamp, + Some(gas_limit), + vec![deploy_raw], + Address::ZERO, + ) + .await?; + + let code = EthApiClient::::get_code( + &env.node_clients[0].rpc, + contract_address, + Some(BlockId::latest()), + ) + .await?; + + assert_eq!( + code.len(), + OVERSIZED_RUNTIME_SIZE, + "deployed contract should exceed the default size limit" + ); + assert!(code.len() > 24 * 1024); + + Ok(()) +} + /// Tests minting and burning tokens to/from a dynamically generated wallet not in genesis. /// /// # Test Flow