diff --git a/crates/ev-revm/src/deploy.rs b/crates/ev-revm/src/deploy.rs index 620697f..d516d8f 100644 --- a/crates/ev-revm/src/deploy.rs +++ b/crates/ev-revm/src/deploy.rs @@ -80,3 +80,60 @@ pub fn check_deploy_allowed( Err(DeployCheckError::NotAllowed) } } + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::address; + + #[test] + fn empty_allowlist_allows_any_caller() { + let settings = DeployAllowlistSettings::new(vec![], 0); + let caller = address!("0x00000000000000000000000000000000000000aa"); + assert!(settings.is_allowed(caller)); + } + + #[test] + fn check_deploy_allowed_with_empty_settings_allows() { + let settings = DeployAllowlistSettings::new(vec![], 0); + let caller = address!("0x00000000000000000000000000000000000000bb"); + let result = check_deploy_allowed(Some(&settings), caller, true, 0); + assert!(result.is_ok()); + } + + #[test] + fn check_deploy_allowed_with_none_settings_allows() { + let caller = address!("0x00000000000000000000000000000000000000cc"); + let result = check_deploy_allowed(None, caller, true, 0); + assert!(result.is_ok()); + } + + #[test] + fn allowlisted_caller_is_allowed() { + let caller = address!("0x00000000000000000000000000000000000000aa"); + let settings = DeployAllowlistSettings::new(vec![caller], 0); + assert!(settings.is_allowed(caller)); + let result = check_deploy_allowed(Some(&settings), caller, true, 0); + assert!(result.is_ok()); + } + + #[test] + fn non_allowlisted_caller_is_denied() { + let allowed = address!("0x00000000000000000000000000000000000000aa"); + let caller = address!("0x00000000000000000000000000000000000000bb"); + let settings = DeployAllowlistSettings::new(vec![allowed], 0); + assert!(!settings.is_allowed(caller)); + let result = check_deploy_allowed(Some(&settings), caller, true, 0); + assert_eq!(result, Err(DeployCheckError::NotAllowed)); + } + + #[test] + fn call_tx_always_allowed_regardless_of_allowlist() { + let allowed = address!("0x00000000000000000000000000000000000000aa"); + let caller = address!("0x00000000000000000000000000000000000000bb"); + let settings = DeployAllowlistSettings::new(vec![allowed], 0); + // caller is not in the allowlist, but is_top_level_create=false so it's allowed + let result = check_deploy_allowed(Some(&settings), caller, false, 0); + assert!(result.is_ok()); + } +} diff --git a/crates/ev-revm/src/handler.rs b/crates/ev-revm/src/handler.rs index e6f87d8..0751e43 100644 --- a/crates/ev-revm/src/handler.rs +++ b/crates/ev-revm/src/handler.rs @@ -1334,6 +1334,103 @@ mod tests { assert!(matches!(result, Err(EVMError::Custom(_)))); } + #[test] + fn allow_deploy_when_allowlist_is_empty() { + let caller = address!("0x00000000000000000000000000000000000000cc"); + let allowlist = DeployAllowlistSettings::new(vec![], 0); + + let mut ctx = Context::mainnet().with_db(EmptyDB::default()); + ctx.block.number = U256::from(1); + ctx.cfg.spec = SpecId::CANCUN; + ctx.cfg.disable_nonce_check = true; + ctx.tx.caller = caller; + ctx.tx.kind = TxKind::Create; + ctx.tx.gas_limit = 1_000_000; + // gas_price=0 so no balance is required + ctx.tx.gas_price = 0; + + 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!( + result.is_ok(), + "empty allowlist should allow any caller to deploy, got: {result:?}" + ); + } + + #[test] + fn allow_deploy_when_allowlist_is_none() { + let caller = address!("0x00000000000000000000000000000000000000dd"); + + let mut ctx = Context::mainnet().with_db(EmptyDB::default()); + ctx.block.number = U256::from(1); + ctx.cfg.spec = SpecId::CANCUN; + ctx.cfg.disable_nonce_check = true; + ctx.tx.caller = caller; + ctx.tx.kind = TxKind::Create; + ctx.tx.gas_limit = 1_000_000; + ctx.tx.gas_price = 0; + + let mut evm = EvEvm::new(ctx, NoOpInspector, None); + let handler: TestHandler = EvHandler::new(None, None); + + let result = handler.validate_against_state_and_deduct_caller(&mut evm); + assert!( + result.is_ok(), + "no allowlist configured should allow any caller to deploy, got: {result:?}" + ); + } + + #[test] + fn allow_deploy_for_allowlisted_caller() { + let caller = address!("0x00000000000000000000000000000000000000ee"); + let allowlist = DeployAllowlistSettings::new(vec![caller], 0); + + let mut ctx = Context::mainnet().with_db(EmptyDB::default()); + ctx.block.number = U256::from(1); + ctx.cfg.spec = SpecId::CANCUN; + ctx.cfg.disable_nonce_check = true; + ctx.tx.caller = caller; + ctx.tx.kind = TxKind::Create; + ctx.tx.gas_limit = 1_000_000; + ctx.tx.gas_price = 0; + + 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!( + result.is_ok(), + "allowlisted caller should be allowed to deploy, got: {result:?}" + ); + } + + #[test] + fn call_tx_allowed_for_non_allowlisted_caller() { + let allowed = address!("0x00000000000000000000000000000000000000aa"); + let caller = address!("0x00000000000000000000000000000000000000ff"); + let allowlist = DeployAllowlistSettings::new(vec![allowed], 0); + + let mut ctx = Context::mainnet().with_db(EmptyDB::default()); + ctx.block.number = U256::from(1); + ctx.cfg.spec = SpecId::CANCUN; + ctx.cfg.disable_nonce_check = true; + ctx.tx.caller = caller; + ctx.tx.kind = TxKind::Call(Address::ZERO); + ctx.tx.gas_limit = 1_000_000; + ctx.tx.gas_price = 0; + + 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!( + result.is_ok(), + "CALL tx should be allowed regardless of allowlist, got: {result:?}" + ); + } + fn setup_evm(redirect: BaseFeeRedirect, beneficiary: Address) -> (TestEvm, TestHandler) { let mut ctx = Context::mainnet().with_db(EmptyDB::default()); ctx.block.basefee = BASE_FEE; diff --git a/crates/node/src/txpool.rs b/crates/node/src/txpool.rs index b39c75c..7d7eeb6 100644 --- a/crates/node/src/txpool.rs +++ b/crates/node/src/txpool.rs @@ -809,6 +809,28 @@ mod tests { ); } + #[test] + fn evnode_create_allowed_when_allowlist_is_empty() { + let settings = ev_revm::deploy::DeployAllowlistSettings::new(vec![], 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([0x33u8; 20]); + 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_ok(), + "empty allowlist should allow any caller to deploy, got: {result:?}" + ); + } + /// Tests that non-sponsored `EvNode` transactions with non-zero call values /// require the sender to cover both gas and value. #[test] @@ -881,4 +903,71 @@ mod tests { assert!(matches!(err, InvalidPoolTransactionError::Other(_))); } } + + #[test] + fn evnode_create_allowed_when_allowlist_is_none() { + let validator = create_test_validator(None); + + 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([0x44u8; 20]); + 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_ok(), + "no allowlist configured should allow any caller to deploy, got: {result:?}" + ); + } + + #[test] + fn evnode_create_allowed_for_allowlisted_caller() { + let signer = Address::from([0x55u8; 20]); + let settings = ev_revm::deploy::DeployAllowlistSettings::new(vec![signer], 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 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_ok(), + "allowlisted caller should be allowed to deploy, got: {result:?}" + ); + } + + #[test] + fn evnode_call_allowed_for_non_allowlisted_caller() { + 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 = 21_000u64; + let max_fee_per_gas = 1_000_000_000u128; + // CALL tx, not CREATE + let signed_tx = create_non_sponsored_evnode_tx(gas_limit, max_fee_per_gas); + + let signer = Address::from([0x66u8; 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_ok(), + "CALL tx should be allowed regardless of allowlist, got: {result:?}" + ); + } } diff --git a/crates/tests/src/e2e_tests.rs b/crates/tests/src/e2e_tests.rs index f197768..18ed5b5 100644 --- a/crates/tests/src/e2e_tests.rs +++ b/crates/tests/src/e2e_tests.rs @@ -80,7 +80,7 @@ const REVERT_INITCODE: [u8; 17] = [ /// /// # Returns /// The deterministic contract address that will be created -fn contract_address_from_nonce(deployer: Address, nonce: u64) -> Address { +pub(crate) fn contract_address_from_nonce(deployer: Address, nonce: u64) -> Address { deployer.create(nonce) } diff --git a/crates/tests/src/lib.rs b/crates/tests/src/lib.rs index 0db0a58..1022d18 100644 --- a/crates/tests/src/lib.rs +++ b/crates/tests/src/lib.rs @@ -8,6 +8,8 @@ pub mod common; #[cfg(test)] pub(crate) mod e2e_tests; #[cfg(test)] +mod test_deploy_allowlist; +#[cfg(test)] mod test_evolve_engine_api; // Re-export common test utilities diff --git a/crates/tests/src/test_deploy_allowlist.rs b/crates/tests/src/test_deploy_allowlist.rs new file mode 100644 index 0000000..8a7b026 --- /dev/null +++ b/crates/tests/src/test_deploy_allowlist.rs @@ -0,0 +1,269 @@ +use alloy_consensus::TxReceipt; +use alloy_eips::{eip2718::Encodable2718, BlockNumberOrTag}; +use alloy_primitives::{Address, Bytes, TxKind, B256, U256}; +use alloy_rpc_types::{ + eth::{ + Block, BlockTransactions, Header, Receipt, Transaction, TransactionInput, + TransactionRequest, + }, + BlockId, +}; +use eyre::Result; +use reth_e2e_test_utils::{ + testsuite::{ + setup::{NetworkSetup, Setup}, + Environment, + }, + transaction::TransactionTestContext, + wallet::Wallet, +}; +use reth_rpc_api::clients::EthApiClient; + +use crate::{ + common::{create_test_chain_spec_with_deploy_allowlist, e2e_test_tree_config, TEST_CHAIN_ID}, + e2e_tests::{build_block_with_transactions, contract_address_from_nonce}, +}; +use ev_node::{EvolveEngineTypes, EvolveNode}; + +/// Initcode for a minimal CREATE2 factory contract. +/// +/// Runtime behavior: reads salt from calldata[0:32] and child initcode from +/// calldata[32:], then deploys the child using CREATE2 with value=0. +/// Returns the deployed child address as a 32-byte value. +const CREATE2_FACTORY_INITCODE: [u8; 40] = alloy_primitives::hex!( + "601c600c600039601c6000f3" // initcode: copies 28-byte runtime to memory and returns it + "36602090038060206000376000359060006000f560005260206000f3" // runtime +); + +/// Initcode for a minimal child contract deployed via the CREATE2 factory. +/// +/// The deployed contract stores 0x42 in memory and returns it (32 bytes) when called. +const CREATE2_CHILD_INITCODE: [u8; 22] = alloy_primitives::hex!( + "600a600c600039600a6000f3" // initcode: copies 10-byte runtime to memory and returns it + "604260005260206000f3" // runtime: returns 0x42 +); + +/// Tests that a non-allowlisted account can deploy contracts indirectly through a +/// factory contract using CREATE2, even when the deploy allowlist is active. +/// +/// # Test Flow +/// 1. Create a deploy allowlist containing only `allowed_deployer` +/// 2. `non_allowlisted` attempts a direct top-level CREATE — rejected by the allowlist +/// 3. `allowed_deployer` deploys a CREATE2 factory contract (top-level CREATE, allowed) +/// 4. `non_allowlisted` account calls the factory, which internally uses CREATE2 +/// 5. Verify the child contract is deployed at the expected deterministic address +/// +/// # What It Tests +/// - deploy allowlist blocks top-level CREATE from non-allowlisted accounts +/// - deploy allowlist permits top-level CREATE from allowlisted accounts +/// - contract-to-contract CREATE2 bypasses the allowlist (by design) +/// - the factory pattern works as an indirect deployment mechanism +/// +/// # Success Criteria +/// - non-allowlisted top-level CREATE is excluded from the block +/// - factory deploys successfully from allowlisted account +/// - non-allowlisted account's call to the factory succeeds +/// - child contract appears at the predicted CREATE2 address +#[tokio::test(flavor = "multi_thread")] +async fn test_e2e_deploy_allowlist_permits_create2_via_factory() -> 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 non_allowlisted = 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) + .with_tree_config(e2e_test_tree_config()); + + 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; + + // non-allowlisted account attempts a direct top-level CREATE — should be rejected + 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(&CREATE2_FACTORY_INITCODE)), + }, + ..Default::default() + }; + + let denied_envelope = + TransactionTestContext::sign_tx(non_allowlisted.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, + "non-allowlisted top-level CREATE should be excluded from the block" + ); + + let denied_address = contract_address_from_nonce(non_allowlisted.address(), 0); + let denied_code = + EthApiClient::::get_code( + &env.node_clients[0].rpc, + denied_address, + Some(BlockId::latest()), + ) + .await?; + assert!( + denied_code.is_empty(), + "non-allowlisted deploy should not create contract code" + ); + + // allowlisted account deploys the factory via top-level CREATE + let deploy_factory_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(&CREATE2_FACTORY_INITCODE)), + }, + ..Default::default() + }; + + let factory_envelope = + TransactionTestContext::sign_tx(allowed_deployer.clone(), deploy_factory_tx).await; + let factory_raw: Bytes = factory_envelope.encoded_2718().into(); + + build_block_with_transactions( + &mut env, + &mut parent_hash, + &mut parent_number, + &mut parent_timestamp, + Some(gas_limit), + vec![factory_raw], + Address::ZERO, + ) + .await?; + + let factory_address = contract_address_from_nonce(allowed_deployer.address(), 0); + let factory_code = + EthApiClient::::get_code( + &env.node_clients[0].rpc, + factory_address, + Some(BlockId::latest()), + ) + .await?; + assert!( + !factory_code.is_empty(), + "factory contract should be deployed by allowlisted account" + ); + + // non-allowlisted account calls the factory to deploy a child via CREATE2 + let salt = B256::left_padding_from(&[42u8]); + let mut calldata = Vec::new(); + calldata.extend_from_slice(salt.as_slice()); + calldata.extend_from_slice(&CREATE2_CHILD_INITCODE); + + let deploy_child_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::Call(factory_address)), + input: TransactionInput { + input: None, + data: Some(Bytes::from(calldata)), + }, + ..Default::default() + }; + + let child_envelope = + TransactionTestContext::sign_tx(non_allowlisted.clone(), deploy_child_tx).await; + let child_raw: Bytes = child_envelope.encoded_2718().into(); + let child_tx_hash = *child_envelope.tx_hash(); + + build_block_with_transactions( + &mut env, + &mut parent_hash, + &mut parent_number, + &mut parent_timestamp, + Some(gas_limit), + vec![child_raw], + Address::ZERO, + ) + .await?; + + // verify the transaction was included and succeeded + let child_receipt = EthApiClient::< + TransactionRequest, + Transaction, + Block, + Receipt, + Header, + Bytes, + >::transaction_receipt(&env.node_clients[0].rpc, child_tx_hash) + .await? + .expect("factory call receipt should be available"); + assert!( + child_receipt.status(), + "non-allowlisted account calling factory should succeed" + ); + + // verify child contract exists at the expected CREATE2 address + let init_code_hash = alloy_primitives::keccak256(CREATE2_CHILD_INITCODE); + let expected_child_address = factory_address.create2(salt, init_code_hash); + let child_code = + EthApiClient::::get_code( + &env.node_clients[0].rpc, + expected_child_address, + Some(BlockId::latest()), + ) + .await?; + assert!( + !child_code.is_empty(), + "child contract should be deployed via CREATE2 despite caller not being on the allowlist" + ); + + drop(setup); + + Ok(()) +}