From 148665ced6d9d7dc2caafdf68ca12e1a2a6a9807 Mon Sep 17 00:00:00 2001 From: tac0turtle Date: Tue, 25 Aug 2026 16:45:55 +0200 Subject: [PATCH 1/5] feat: add dynamic deployment permissions precompile --- CHANGELOG.md | 12 + README.md | 28 +- crates/ev-precompiles/README.md | 30 +- .../ev-precompiles/src/deploy_permissions.rs | 856 ++++++++++++++++++ crates/ev-precompiles/src/lib.rs | 1 + crates/ev-revm/src/deploy.rs | 43 + crates/ev-revm/src/factory.rs | 296 +++++- crates/ev-revm/src/handler.rs | 168 +++- crates/ev-revm/src/lib.rs | 5 +- crates/evolve/src/rpc/txpool.rs | 5 + crates/node/src/config.rs | 169 +++- crates/node/src/executor.rs | 32 +- crates/node/src/proposer_rpc.rs | 5 + crates/node/src/txpool.rs | 47 +- crates/tests/src/common.rs | 18 +- docs/UPGRADE-v0.6.0.md | 65 ++ ...ADR-0005-dynamic-deployment-permissions.md | 113 +++ docs/guide/permissioned-evm.md | 76 +- etc/ev-reth-genesis.json | 8 +- 19 files changed, 1921 insertions(+), 56 deletions(-) create mode 100644 crates/ev-precompiles/src/deploy_permissions.rs create mode 100644 docs/UPGRADE-v0.6.0.md create mode 100644 docs/adr/ADR-0005-dynamic-deployment-permissions.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c71e246..7f5f274 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Optional state-backed deployment-permissions precompile at `0xF102`, enabled by + `deployAllowlistAdmin` with independent `deployAllowlistPrecompileActivationHeight`. The fixed + admin can add or remove deployers and reversibly pause enforcement while preserving policy. + +### Changed + +- Dynamic deployment-permission chains use execution state as the authoritative admission check, + including transaction-order updates within a block. Chains without a non-zero admin retain the + existing static allowlist and txpool behavior. + ## [0.5.0] - 2026-08-17 ### Added diff --git a/README.md b/README.md index cc03200..31bc0c9 100644 --- a/README.md +++ b/README.md @@ -456,7 +456,8 @@ This design ensures safe upgrades for existing networks: contracts that were pre ### Restricting Contract Deployment -If you want a permissioned chain where only specific EOAs can deploy contracts, configure a deploy allowlist in the chainspec: +If you want a permissioned chain where only specific accounts can submit top-level deployments, +configure a deploy allowlist in the chainspec. Without an admin, the list remains static: ```json "config": { @@ -484,6 +485,31 @@ Operational notes: - The allowlist is static and must be changed via a chainspec update. - Duplicate entries or the zero address are rejected at startup. +To manage permissions on-chain, configure the deployment-permissions admin and its independent +activation height: + +```json +"config": { + ..., + "evolve": { + "deployAllowlist": [ + "0xInitialDeployerAddress" + ], + "deployAllowlistActivationHeight": 0, + "deployAllowlistAdmin": "0xAdminProxyOrGovernanceAddress", + "deployAllowlistPrecompileActivationHeight": 20000000 + } +} +``` + +At the dynamic activation height, `deployAllowlist` becomes the baseline for the state-backed +precompile at `0x000000000000000000000000000000000000F102`. Enforcement is enabled by default. +The fixed admin can add or remove deployers and can temporarily disable enforcement; disabling is +fail-open for top-level deployments and preserves the policy for later re-enablement. An empty +baseline therefore means deny-all while enabled, not “feature disabled.” Use standard `eth_call` +for inspection. See the [permissioned EVM guide](docs/guide/permissioned-evm.md) for the interface, +activation constraints, and rollout procedure. + ### Payload Builder Configuration The payload builder can be configured with: diff --git a/crates/ev-precompiles/README.md b/crates/ev-precompiles/README.md index 29c60ef..5a91d7a 100644 --- a/crates/ev-precompiles/README.md +++ b/crates/ev-precompiles/README.md @@ -1,6 +1,7 @@ # ev-precompiles -Custom EVM precompiles for Evolve, providing native token supply management functionality. +Custom EVM precompiles for Evolve, providing native token supply management, proposer control, and +state-backed deployment permissions. ## Overview @@ -295,3 +296,30 @@ Invalid ABI data also halts the precompile. None of these emit logs. - Compromise of the admin (or AdminProxy owner) is compromise of sequencer selection. - `evolve_getNextProposer` is a public read of execution state. It is not registered when the precompile is disabled, so ev-node treats method-not-found as "feature off". + +## Deployment Permissions Precompile + +The optional deployment-permissions precompile is installed at +`0x000000000000000000000000000000000000f102` when `deployAllowlistAdmin` is configured and +`deployAllowlistPrecompileActivationHeight` is reached. + +```solidity +interface IDeployPermissions { + function addDeployer(address account) external; + function removeDeployer(address account) external; + function setEnabled(bool enabled) external; + function isDeployerAllowed(address account) external view returns (bool); + function isEnabled() external view returns (bool); + function deployerCount() external view returns (uint256); + function admin() external view returns (address); +} +``` + +The genesis `deployAllowlist` is the baseline. Enforcement is enabled when its state flag is unset, +so no bootstrap call is required. `setEnabled(false)` allows all top-level deployments without +discarding membership. Member changes are permitted while disabled and apply when enforcement is +re-enabled. The active set is capped at 1024 and excludes the zero address. + +The admin is fixed by chainspec and should normally be an AdminProxy, multisig, or governance +contract. Read the interface through standard `eth_call`; no custom RPC is required. See the +[permissioned EVM guide](../../docs/guide/permissioned-evm.md) for rollout and security details. diff --git a/crates/ev-precompiles/src/deploy_permissions.rs b/crates/ev-precompiles/src/deploy_permissions.rs new file mode 100644 index 0000000..0b6e9d1 --- /dev/null +++ b/crates/ev-precompiles/src/deploy_permissions.rs @@ -0,0 +1,856 @@ +//! Stateful deployment-permissions precompile. + +use alloy::{ + sol, + sol_types::{SolInterface, SolValue}, +}; +use alloy_evm::{ + precompiles::{Precompile, PrecompileInput}, + revm::precompile::{PrecompileError, PrecompileId, PrecompileResult}, + EvmInternals, EvmInternalsError, +}; +use alloy_primitives::{address, keccak256, Address, Bytes, U256}; +use revm::{ + bytecode::Bytecode, + precompile::{PrecompileHalt, PrecompileOutput}, +}; +use std::sync::{Arc, OnceLock}; + +sol! { + interface IDeployPermissions { + function addDeployer(address account) external; + function removeDeployer(address account) external; + function setEnabled(bool enabled) external; + function isDeployerAllowed(address account) external view returns (bool); + function isEnabled() external view returns (bool); + function deployerCount() external view returns (uint256); + function admin() external view returns (address); + } +} + +/// Address of the deployment-permissions precompile. +pub const DEPLOY_PERMISSIONS_PRECOMPILE_ADDR: Address = + address!("0x000000000000000000000000000000000000F102"); + +/// Maximum number of active deployers. +pub const MAX_DEPLOYERS: usize = 1024; + +const ENTRY_ALLOWED: U256 = U256::from_limbs([1, 0, 0, 0]); +const ENTRY_DENIED: U256 = U256::from_limbs([2, 0, 0, 0]); + +fn domain_slot(domain: &'static [u8]) -> U256 { + U256::from_be_bytes(keccak256(domain).0) +} + +/// Storage slot recording whether enforcement is disabled. +pub fn disabled_slot() -> U256 { + static SLOT: OnceLock = OnceLock::new(); + *SLOT.get_or_init(|| domain_slot(b"ev-reth.deploy-permissions.disabled.v1")) +} + +/// Storage slot containing the encoded active-deployer count. +pub fn deployer_count_slot() -> U256 { + static SLOT: OnceLock = OnceLock::new(); + *SLOT.get_or_init(|| domain_slot(b"ev-reth.deploy-permissions.count.v1")) +} + +/// Returns the domain-separated storage slot for an address override. +pub fn deployer_override_slot(account: Address) -> U256 { + static DOMAIN: OnceLock<[u8; 32]> = OnceLock::new(); + let domain = DOMAIN.get_or_init(|| keccak256(b"ev-reth.deploy-permissions.member.v1").0); + let mut input = [0u8; 64]; + input[..32].copy_from_slice(domain); + input[32..].copy_from_slice(account.into_word().as_slice()); + U256::from_be_bytes(keccak256(input).0) +} + +/// Decodes a stored override against genesis baseline membership. +pub fn resolve_deployer_override(value: U256, baseline_member: bool) -> bool { + if value == ENTRY_ALLOWED { + true + } else if value == ENTRY_DENIED { + false + } else { + baseline_member + } +} + +/// A precompile that manages state-backed top-level deployment permissions. +#[derive(Clone, Debug, Default)] +pub struct DeployPermissionsPrecompile { + admin: Address, + baseline: Arc<[Address]>, +} + +#[derive(Debug)] +enum DeployPermissionsError { + Fatal(PrecompileError), + Halt(PrecompileHalt), +} + +type DeployPermissionsResult = Result; + +impl DeployPermissionsError { + fn fatal(err: EvmInternalsError) -> Self { + Self::Fatal(PrecompileError::Fatal(err.to_string())) + } + + const fn halt(reason: &'static str) -> Self { + Self::Halt(PrecompileHalt::other_static(reason)) + } +} + +impl DeployPermissionsPrecompile { + /// Returns the stable custom precompile identifier. + pub fn id() -> &'static PrecompileId { + static ID: OnceLock = OnceLock::new(); + ID.get_or_init(|| PrecompileId::custom("deploy_permissions")) + } + + fn bytecode() -> &'static Bytecode { + static BYTECODE: OnceLock = OnceLock::new(); + BYTECODE.get_or_init(|| Bytecode::new_raw(Bytes::from_static(&[0xFE]))) + } + + /// Creates a precompile using the fixed admin and genesis baseline. + pub fn new(admin: Address, mut baseline: Vec
) -> Self { + baseline.sort_unstable(); + baseline.dedup(); + Self { + admin, + baseline: Arc::from(baseline), + } + } + + fn map_internals_error(err: EvmInternalsError) -> DeployPermissionsError { + DeployPermissionsError::fatal(err) + } + + fn is_baseline_member(&self, account: Address) -> bool { + self.baseline.binary_search(&account).is_ok() + } + + fn ensure_admin(&self, caller: Address) -> DeployPermissionsResult<()> { + if caller == self.admin { + Ok(()) + } else { + Err(DeployPermissionsError::halt("unauthorized caller")) + } + } + + fn ensure_account_created(internals: &mut EvmInternals<'_>) -> DeployPermissionsResult<()> { + let account = internals + .load_account(DEPLOY_PERMISSIONS_PRECOMPILE_ADDR) + .map_err(Self::map_internals_error)?; + let needs_code = account.info.code_hash == alloy_primitives::KECCAK256_EMPTY; + let needs_nonce = account.info.nonce == 0; + if needs_code { + internals + .set_code(DEPLOY_PERMISSIONS_PRECOMPILE_ADDR, Self::bytecode().clone()) + .map_err(Self::map_internals_error)?; + } + if needs_nonce { + internals + .load_account_mut(DEPLOY_PERMISSIONS_PRECOMPILE_ADDR) + .map_err(Self::map_internals_error)? + .set_nonce(1); + } + if needs_code || needs_nonce { + internals + .touch_account(DEPLOY_PERMISSIONS_PRECOMPILE_ADDR) + .map_err(Self::map_internals_error)?; + } + Ok(()) + } + + fn read_slot(internals: &mut EvmInternals<'_>, slot: U256) -> DeployPermissionsResult { + let value = internals + .sload(DEPLOY_PERMISSIONS_PRECOMPILE_ADDR, slot) + .map_err(Self::map_internals_error)?; + Ok(*value) + } + + fn write_slot( + internals: &mut EvmInternals<'_>, + slot: U256, + value: U256, + ) -> DeployPermissionsResult<()> { + Self::ensure_account_created(internals)?; + internals + .sstore(DEPLOY_PERMISSIONS_PRECOMPILE_ADDR, slot, value) + .map_err(Self::map_internals_error)?; + internals + .touch_account(DEPLOY_PERMISSIONS_PRECOMPILE_ADDR) + .map_err(Self::map_internals_error)?; + Ok(()) + } + + fn is_enabled(internals: &mut EvmInternals<'_>) -> DeployPermissionsResult { + Ok(Self::read_slot(internals, disabled_slot())?.is_zero()) + } + + fn deployer_count(&self, internals: &mut EvmInternals<'_>) -> DeployPermissionsResult { + let encoded = Self::read_slot(internals, deployer_count_slot())?; + if encoded.is_zero() { + return Ok(self.baseline.len()); + } + let count = usize::try_from(encoded - U256::from(1)) + .map_err(|_| DeployPermissionsError::halt("invalid deployment-permissions state"))?; + if count > MAX_DEPLOYERS { + return Err(DeployPermissionsError::halt( + "invalid deployment-permissions state", + )); + } + Ok(count) + } + + fn set_deployer_count( + internals: &mut EvmInternals<'_>, + count: usize, + ) -> DeployPermissionsResult<()> { + let encoded = U256::from(count) + U256::from(1); + Self::write_slot(internals, deployer_count_slot(), encoded) + } + + fn is_deployer_allowed( + &self, + internals: &mut EvmInternals<'_>, + account: Address, + ) -> DeployPermissionsResult { + if account.is_zero() { + return Ok(false); + } + let value = Self::read_slot(internals, deployer_override_slot(account))?; + Ok(resolve_deployer_override( + value, + self.is_baseline_member(account), + )) + } + + fn add_deployer( + &self, + internals: &mut EvmInternals<'_>, + account: Address, + ) -> DeployPermissionsResult<()> { + if account.is_zero() { + return Err(DeployPermissionsError::halt("deployer cannot be zero")); + } + if self.is_deployer_allowed(internals, account)? { + return Ok(()); + } + let count = self.deployer_count(internals)?; + if count >= MAX_DEPLOYERS { + return Err(DeployPermissionsError::halt("deployer limit reached")); + } + let value = if self.is_baseline_member(account) { + U256::ZERO + } else { + ENTRY_ALLOWED + }; + Self::write_slot(internals, deployer_override_slot(account), value)?; + Self::set_deployer_count(internals, count + 1) + } + + fn remove_deployer( + &self, + internals: &mut EvmInternals<'_>, + account: Address, + ) -> DeployPermissionsResult<()> { + if account.is_zero() { + return Err(DeployPermissionsError::halt("deployer cannot be zero")); + } + if !self.is_deployer_allowed(internals, account)? { + return Ok(()); + } + let count = self.deployer_count(internals)?; + let value = if self.is_baseline_member(account) { + ENTRY_DENIED + } else { + U256::ZERO + }; + let next_count = count + .checked_sub(1) + .ok_or_else(|| DeployPermissionsError::halt("invalid deployment-permissions state"))?; + Self::write_slot(internals, deployer_override_slot(account), value)?; + Self::set_deployer_count(internals, next_count) + } + + fn set_enabled(internals: &mut EvmInternals<'_>, enabled: bool) -> DeployPermissionsResult<()> { + let currently_enabled = Self::is_enabled(internals)?; + if currently_enabled == enabled { + return Ok(()); + } + let disabled = if enabled { U256::ZERO } else { U256::from(1) }; + Self::write_slot(internals, disabled_slot(), disabled) + } +} + +impl Precompile for DeployPermissionsPrecompile { + fn precompile_id(&self) -> &PrecompileId { + Self::id() + } + + fn call(&self, mut input: PrecompileInput<'_>) -> PrecompileResult { + let caller = input.caller; + let reservoir = input.reservoir; + let is_static = input.is_static; + let decoded = match IDeployPermissions::IDeployPermissionsCalls::abi_decode(input.data) { + Ok(value) => value, + Err(err) => { + return Ok(PrecompileOutput::halt( + PrecompileHalt::other(err.to_string()), + reservoir, + )) + } + }; + let internals = input.internals_mut(); + + let result = (|| -> DeployPermissionsResult { + match decoded { + IDeployPermissions::IDeployPermissionsCalls::addDeployer(call) => { + if is_static { + return Err(DeployPermissionsError::halt( + "state change during static call", + )); + } + self.ensure_admin(caller)?; + self.add_deployer(internals, call.account)?; + Ok(Bytes::new()) + } + IDeployPermissions::IDeployPermissionsCalls::removeDeployer(call) => { + if is_static { + return Err(DeployPermissionsError::halt( + "state change during static call", + )); + } + self.ensure_admin(caller)?; + self.remove_deployer(internals, call.account)?; + Ok(Bytes::new()) + } + IDeployPermissions::IDeployPermissionsCalls::setEnabled(call) => { + if is_static { + return Err(DeployPermissionsError::halt( + "state change during static call", + )); + } + self.ensure_admin(caller)?; + Self::set_enabled(internals, call.enabled)?; + Ok(Bytes::new()) + } + IDeployPermissions::IDeployPermissionsCalls::isDeployerAllowed(call) => Ok(self + .is_deployer_allowed(internals, call.account)? + .abi_encode() + .into()), + IDeployPermissions::IDeployPermissionsCalls::isEnabled(_) => { + Ok(Self::is_enabled(internals)?.abi_encode().into()) + } + IDeployPermissions::IDeployPermissionsCalls::deployerCount(_) => { + Ok(U256::from(self.deployer_count(internals)?) + .abi_encode() + .into()) + } + IDeployPermissions::IDeployPermissionsCalls::admin(_) => { + Ok(self.admin.abi_encode().into()) + } + } + })(); + + match result { + Ok(bytes) => Ok(PrecompileOutput::new(0, bytes, reservoir)), + Err(DeployPermissionsError::Halt(reason)) => { + Ok(PrecompileOutput::halt(reason, reservoir)) + } + Err(DeployPermissionsError::Fatal(err)) => Err(err), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy::sol_types::SolCall; + use alloy_primitives::address; + use revm::{ + context::{ + journal::{Journal, JournalInner}, + BlockEnv, CfgEnv, TxEnv, + }, + context_interface::JournalTr, + database::{CacheDB, EmptyDB}, + primitives::hardfork::SpecId, + }; + + type TestJournal = Journal>; + const GAS_LIMIT: u64 = 1_000_000; + + fn setup_context() -> (TestJournal, BlockEnv, CfgEnv, TxEnv) { + let mut journal = Journal::new_with_inner(CacheDB::default(), JournalInner::new()); + journal.inner.set_spec_id(SpecId::PRAGUE); + ( + journal, + BlockEnv::default(), + CfgEnv::default(), + TxEnv::default(), + ) + } + + #[expect( + clippy::too_many_arguments, + reason = "test helper mirrors the complete stateful precompile call context" + )] + fn run_call<'a>( + journal: &'a mut TestJournal, + block_env: &'a BlockEnv, + cfg_env: &'a CfgEnv, + tx_env: &'a TxEnv, + precompile: &DeployPermissionsPrecompile, + caller: Address, + data: &'a [u8], + is_static: bool, + ) -> PrecompileResult { + precompile.call(PrecompileInput { + data, + gas: GAS_LIMIT, + reservoir: 0, + caller, + value: U256::ZERO, + target_address: DEPLOY_PERMISSIONS_PRECOMPILE_ADDR, + is_static, + bytecode_address: DEPLOY_PERMISSIONS_PRECOMPILE_ADDR, + internals: EvmInternals::new(journal, block_env, cfg_env, tx_env), + }) + } + + fn output_bytes(result: PrecompileResult) -> Bytes { + match result { + Ok(output) if !output.is_halt() => output.bytes.clone(), + Ok(output) => panic!("expected success, got halt {output:?}"), + Err(err) => panic!("expected success, got fatal error {err:?}"), + } + } + + fn assert_halt(result: PrecompileResult, expected: &str) { + match result { + Ok(output) => match output.halt_reason() { + Some(PrecompileHalt::Other(message)) => assert_eq!(message.as_ref(), expected), + other => panic!("expected custom halt, got {other:?}"), + }, + Err(err) => panic!("expected halt, got fatal error {err:?}"), + } + } + + fn call_bool( + journal: &mut TestJournal, + block_env: &BlockEnv, + cfg_env: &CfgEnv, + tx_env: &TxEnv, + precompile: &DeployPermissionsPrecompile, + data: &[u8], + ) -> bool { + bool::abi_decode(&output_bytes(run_call( + journal, + block_env, + cfg_env, + tx_env, + precompile, + Address::ZERO, + data, + true, + ))) + .expect("bool output decodes") + } + + fn count( + journal: &mut TestJournal, + block_env: &BlockEnv, + cfg_env: &CfgEnv, + tx_env: &TxEnv, + precompile: &DeployPermissionsPrecompile, + ) -> U256 { + U256::abi_decode(&output_bytes(run_call( + journal, + block_env, + cfg_env, + tx_env, + precompile, + Address::ZERO, + &IDeployPermissions::deployerCountCall {}.abi_encode(), + true, + ))) + .expect("count output decodes") + } + + #[test] + fn configured_policy_is_enabled_by_default_and_uses_baseline() { + let admin = address!("0x00000000000000000000000000000000000000aa"); + let baseline = address!("0x00000000000000000000000000000000000000bb"); + let other = address!("0x00000000000000000000000000000000000000cc"); + let precompile = DeployPermissionsPrecompile::new(admin, vec![baseline]); + let (mut journal, block, cfg, tx) = setup_context(); + + assert!(call_bool( + &mut journal, + &block, + &cfg, + &tx, + &precompile, + &IDeployPermissions::isEnabledCall {}.abi_encode(), + )); + assert!(call_bool( + &mut journal, + &block, + &cfg, + &tx, + &precompile, + &IDeployPermissions::isDeployerAllowedCall { account: baseline }.abi_encode(), + )); + assert!(!call_bool( + &mut journal, + &block, + &cfg, + &tx, + &precompile, + &IDeployPermissions::isDeployerAllowedCall { account: other }.abi_encode(), + )); + assert_eq!( + count(&mut journal, &block, &cfg, &tx, &precompile), + U256::from(1) + ); + } + + #[test] + fn admin_can_disable_edit_policy_and_reenable() { + let admin = address!("0x00000000000000000000000000000000000000aa"); + let baseline = address!("0x00000000000000000000000000000000000000bb"); + let added = address!("0x00000000000000000000000000000000000000cc"); + let precompile = DeployPermissionsPrecompile::new(admin, vec![baseline]); + let (mut journal, block, cfg, tx) = setup_context(); + + for data in [ + IDeployPermissions::setEnabledCall { enabled: false }.abi_encode(), + IDeployPermissions::removeDeployerCall { account: baseline }.abi_encode(), + IDeployPermissions::addDeployerCall { account: added }.abi_encode(), + ] { + output_bytes(run_call( + &mut journal, + &block, + &cfg, + &tx, + &precompile, + admin, + &data, + false, + )); + } + + assert!(!call_bool( + &mut journal, + &block, + &cfg, + &tx, + &precompile, + &IDeployPermissions::isEnabledCall {}.abi_encode(), + )); + assert!(!call_bool( + &mut journal, + &block, + &cfg, + &tx, + &precompile, + &IDeployPermissions::isDeployerAllowedCall { account: baseline }.abi_encode(), + )); + assert!(call_bool( + &mut journal, + &block, + &cfg, + &tx, + &precompile, + &IDeployPermissions::isDeployerAllowedCall { account: added }.abi_encode(), + )); + assert_eq!( + count(&mut journal, &block, &cfg, &tx, &precompile), + U256::from(1) + ); + + let enable = IDeployPermissions::setEnabledCall { enabled: true }.abi_encode(); + output_bytes(run_call( + &mut journal, + &block, + &cfg, + &tx, + &precompile, + admin, + &enable, + false, + )); + assert!(call_bool( + &mut journal, + &block, + &cfg, + &tx, + &precompile, + &IDeployPermissions::isEnabledCall {}.abi_encode(), + )); + } + + #[test] + fn mutations_are_authorized_idempotent_and_reject_zero() { + let admin = address!("0x00000000000000000000000000000000000000aa"); + let caller = address!("0x00000000000000000000000000000000000000bb"); + let account = address!("0x00000000000000000000000000000000000000cc"); + let precompile = DeployPermissionsPrecompile::new(admin, Vec::new()); + let (mut journal, block, cfg, tx) = setup_context(); + let add = IDeployPermissions::addDeployerCall { account }.abi_encode(); + + assert_halt( + run_call( + &mut journal, + &block, + &cfg, + &tx, + &precompile, + caller, + &add, + false, + ), + "unauthorized caller", + ); + assert_halt( + run_call( + &mut journal, + &block, + &cfg, + &tx, + &precompile, + admin, + &add, + true, + ), + "state change during static call", + ); + for _ in 0..2 { + output_bytes(run_call( + &mut journal, + &block, + &cfg, + &tx, + &precompile, + admin, + &add, + false, + )); + } + assert_eq!( + count(&mut journal, &block, &cfg, &tx, &precompile), + U256::from(1) + ); + + let remove = IDeployPermissions::removeDeployerCall { account }.abi_encode(); + for _ in 0..2 { + output_bytes(run_call( + &mut journal, + &block, + &cfg, + &tx, + &precompile, + admin, + &remove, + false, + )); + } + assert_eq!( + count(&mut journal, &block, &cfg, &tx, &precompile), + U256::ZERO + ); + + let zero = IDeployPermissions::addDeployerCall { + account: Address::ZERO, + } + .abi_encode(); + assert_halt( + run_call( + &mut journal, + &block, + &cfg, + &tx, + &precompile, + admin, + &zero, + false, + ), + "deployer cannot be zero", + ); + } + + #[test] + fn removals_clear_dynamic_entries_and_only_baseline_removals_leave_tombstones() { + let admin = address!("0x00000000000000000000000000000000000000aa"); + let baseline = address!("0x00000000000000000000000000000000000000bb"); + let dynamic = address!("0x00000000000000000000000000000000000000cc"); + let precompile = DeployPermissionsPrecompile::new(admin, vec![baseline]); + let (mut journal, block, cfg, tx) = setup_context(); + + for data in [ + IDeployPermissions::removeDeployerCall { account: baseline }.abi_encode(), + IDeployPermissions::addDeployerCall { account: dynamic }.abi_encode(), + IDeployPermissions::removeDeployerCall { account: dynamic }.abi_encode(), + ] { + output_bytes(run_call( + &mut journal, + &block, + &cfg, + &tx, + &precompile, + admin, + &data, + false, + )); + } + + let account = journal + .inner + .state + .get(&DEPLOY_PERMISSIONS_PRECOMPILE_ADDR) + .expect("precompile account exists"); + assert_eq!(account.info.nonce, 1); + assert_ne!(account.info.code_hash, alloy_primitives::KECCAK256_EMPTY); + assert_eq!( + account.storage[&deployer_override_slot(baseline)].present_value, + ENTRY_DENIED + ); + assert_eq!( + account.storage[&deployer_override_slot(dynamic)].present_value, + U256::ZERO, + "removed non-baseline entries must be cleared" + ); + + let readd = IDeployPermissions::addDeployerCall { account: baseline }.abi_encode(); + output_bytes(run_call( + &mut journal, + &block, + &cfg, + &tx, + &precompile, + admin, + &readd, + false, + )); + let account = journal + .inner + .state + .get(&DEPLOY_PERMISSIONS_PRECOMPILE_ADDR) + .expect("precompile account exists"); + assert_eq!( + account.storage[&deployer_override_slot(baseline)].present_value, + U256::ZERO, + "re-adding a baseline member must clear its tombstone" + ); + } + + #[test] + fn reverted_control_call_restores_policy_and_enabled_flag() { + let admin = address!("0x00000000000000000000000000000000000000aa"); + let account = address!("0x00000000000000000000000000000000000000bb"); + let precompile = DeployPermissionsPrecompile::new(admin, Vec::new()); + let (mut journal, block, cfg, tx) = setup_context(); + let checkpoint = journal.checkpoint(); + + for data in [ + IDeployPermissions::addDeployerCall { account }.abi_encode(), + IDeployPermissions::setEnabledCall { enabled: false }.abi_encode(), + ] { + output_bytes(run_call( + &mut journal, + &block, + &cfg, + &tx, + &precompile, + admin, + &data, + false, + )); + } + journal.checkpoint_revert(checkpoint); + + assert!(call_bool( + &mut journal, + &block, + &cfg, + &tx, + &precompile, + &IDeployPermissions::isEnabledCall {}.abi_encode(), + )); + assert!(!call_bool( + &mut journal, + &block, + &cfg, + &tx, + &precompile, + &IDeployPermissions::isDeployerAllowedCall { account }.abi_encode(), + )); + assert_eq!( + count(&mut journal, &block, &cfg, &tx, &precompile), + U256::ZERO + ); + } + + #[test] + fn exposes_fixed_admin_and_rejects_malformed_calldata() { + let admin = address!("0x00000000000000000000000000000000000000aa"); + let precompile = DeployPermissionsPrecompile::new(admin, Vec::new()); + let (mut journal, block, cfg, tx) = setup_context(); + let bytes = output_bytes(run_call( + &mut journal, + &block, + &cfg, + &tx, + &precompile, + Address::ZERO, + &IDeployPermissions::adminCall {}.abi_encode(), + true, + )); + assert_eq!( + Address::abi_decode(&bytes).expect("admin output decodes"), + admin + ); + + let malformed = run_call( + &mut journal, + &block, + &cfg, + &tx, + &precompile, + admin, + &[0xde, 0xad, 0xbe, 0xef], + false, + ) + .expect("malformed calldata produces a halt output"); + assert!(malformed.is_halt()); + } + + #[test] + fn rejects_addition_beyond_cap() { + let admin = address!("0x00000000000000000000000000000000000000aa"); + let baseline: Vec<_> = (1..=MAX_DEPLOYERS) + .map(|value| Address::from_word(U256::from(value).into())) + .collect(); + let precompile = DeployPermissionsPrecompile::new(admin, baseline); + let extra = address!("0x000000000000000000000000000000000000ffff"); + let (mut journal, block, cfg, tx) = setup_context(); + let data = IDeployPermissions::addDeployerCall { account: extra }.abi_encode(); + + assert_halt( + run_call( + &mut journal, + &block, + &cfg, + &tx, + &precompile, + admin, + &data, + false, + ), + "deployer limit reached", + ); + } +} diff --git a/crates/ev-precompiles/src/lib.rs b/crates/ev-precompiles/src/lib.rs index 1d71c3d..e2652e0 100644 --- a/crates/ev-precompiles/src/lib.rs +++ b/crates/ev-precompiles/src/lib.rs @@ -1,2 +1,3 @@ +pub mod deploy_permissions; pub mod mint; pub mod proposer; diff --git a/crates/ev-revm/src/deploy.rs b/crates/ev-revm/src/deploy.rs index d516d8f..6ad37a9 100644 --- a/crates/ev-revm/src/deploy.rs +++ b/crates/ev-revm/src/deploy.rs @@ -8,6 +8,8 @@ use std::sync::Arc; pub struct DeployAllowlistSettings { allowlist: Arc<[Address]>, activation_height: u64, + dynamic_admin: Option
, + dynamic_activation_height: u64, } impl DeployAllowlistSettings { @@ -16,12 +18,28 @@ impl DeployAllowlistSettings { pub fn new(allowlist: Vec
, activation_height: u64) -> Self { let mut allowlist = allowlist; allowlist.sort_unstable(); + allowlist.dedup(); Self { allowlist: Arc::from(allowlist), activation_height, + dynamic_admin: None, + dynamic_activation_height: 0, } } + /// Creates deployment settings that transition from static to dynamic enforcement. + pub fn new_dynamic( + allowlist: Vec
, + activation_height: u64, + admin: Address, + dynamic_activation_height: u64, + ) -> Self { + let mut settings = Self::new(allowlist, activation_height); + settings.dynamic_admin = Some(admin); + settings.dynamic_activation_height = dynamic_activation_height; + settings + } + /// Returns the activation height for deploy allowlist enforcement. pub const fn activation_height(&self) -> u64 { self.activation_height @@ -44,6 +62,31 @@ impl DeployAllowlistSettings { } self.allowlist.binary_search(&caller).is_ok() } + + /// Returns whether the caller belongs to the genesis baseline. + pub fn is_baseline_member(&self, caller: Address) -> bool { + self.allowlist.binary_search(&caller).is_ok() + } + + /// Returns the configured dynamic-permissions admin, if dynamic mode is enabled. + pub const fn dynamic_admin(&self) -> Option
{ + self.dynamic_admin + } + + /// Returns the dynamic-permissions activation height. + pub const fn dynamic_activation_height(&self) -> u64 { + self.dynamic_activation_height + } + + /// Returns whether this chain uses state-backed deployment permissions. + pub const fn is_dynamic(&self) -> bool { + self.dynamic_admin.is_some() + } + + /// Returns whether dynamic deployment permissions are active in this block. + pub const fn is_dynamic_active(&self, block_number: u64) -> bool { + self.is_dynamic() && block_number >= self.dynamic_activation_height + } } /// Error returned by deploy allowlist checks. diff --git a/crates/ev-revm/src/factory.rs b/crates/ev-revm/src/factory.rs index 3b5d0d6..b8186d0 100644 --- a/crates/ev-revm/src/factory.rs +++ b/crates/ev-revm/src/factory.rs @@ -11,6 +11,7 @@ use alloy_evm::{ }; use alloy_primitives::{Address, B256, U256}; use ev_precompiles::{ + deploy_permissions::{DeployPermissionsPrecompile, DEPLOY_PERMISSIONS_PRECOMPILE_ADDR}, mint::{MintPrecompile, MINT_PRECOMPILE_ADDR}, proposer::{ProposerControlPrecompile, PROPOSER_CONTROL_PRECOMPILE_ADDR}, }; @@ -226,6 +227,35 @@ impl EvEvmFactory { }); } + fn install_deploy_permissions_precompile( + &self, + precompiles: &mut PrecompilesMap, + block_number: U256, + ) { + let Some(settings) = self.deploy_allowlist.as_ref() else { + return; + }; + let Some(admin) = settings.dynamic_admin() else { + return; + }; + if block_number < U256::from(settings.dynamic_activation_height()) { + return; + } + + let deploy_permissions = Arc::new(DeployPermissionsPrecompile::new( + admin, + settings.allowlist().to_vec(), + )); + let id = DeployPermissionsPrecompile::id().clone(); + precompiles.apply_precompile(&DEPLOY_PERMISSIONS_PRECOMPILE_ADDR, move |_| { + let deploy_permissions_for_call = Arc::clone(&deploy_permissions); + let id_for_call = id; + Some(DynPrecompile::new_stateful(id_for_call, move |input| { + deploy_permissions_for_call.call(input) + })) + }); + } + fn redirect_for_block(&self, block_number: U256) -> Option { self.redirect.and_then(|settings| { if block_number >= U256::from(settings.activation_height()) { @@ -269,6 +299,7 @@ impl EvmFactory for EvEvmFactory { let inner = evm.inner_mut(); self.install_mint_precompile(&mut inner.precompiles, block_number); self.install_proposer_control_precompile(&mut inner.precompiles, block_number); + self.install_deploy_permissions_precompile(&mut inner.precompiles, block_number); } evm } @@ -295,6 +326,7 @@ impl EvmFactory for EvEvmFactory { let inner = evm.inner_mut(); self.install_mint_precompile(&mut inner.precompiles, block_number); self.install_proposer_control_precompile(&mut inner.precompiles, block_number); + self.install_deploy_permissions_precompile(&mut inner.precompiles, block_number); } evm } @@ -399,6 +431,35 @@ impl EvTxEvmFactory { }); } + fn install_deploy_permissions_precompile( + &self, + precompiles: &mut PrecompilesMap, + block_number: U256, + ) { + let Some(settings) = self.deploy_allowlist.as_ref() else { + return; + }; + let Some(admin) = settings.dynamic_admin() else { + return; + }; + if block_number < U256::from(settings.dynamic_activation_height()) { + return; + } + + let deploy_permissions = Arc::new(DeployPermissionsPrecompile::new( + admin, + settings.allowlist().to_vec(), + )); + let id = DeployPermissionsPrecompile::id().clone(); + precompiles.apply_precompile(&DEPLOY_PERMISSIONS_PRECOMPILE_ADDR, move |_| { + let deploy_permissions_for_call = Arc::clone(&deploy_permissions); + let id_for_call = id; + Some(DynPrecompile::new_stateful(id_for_call, move |input| { + deploy_permissions_for_call.call(input) + })) + }); + } + fn redirect_for_block(&self, block_number: U256) -> Option { self.redirect.and_then(|settings| { if block_number >= U256::from(settings.activation_height()) { @@ -473,6 +534,7 @@ impl EvmFactory for EvTxEvmFactory { let inner = evm.inner_mut(); self.install_mint_precompile(&mut inner.precompiles, block_number); self.install_proposer_control_precompile(&mut inner.precompiles, block_number); + self.install_deploy_permissions_precompile(&mut inner.precompiles, block_number); } evm } @@ -498,6 +560,7 @@ impl EvmFactory for EvTxEvmFactory { let inner = evm.inner_mut(); self.install_mint_precompile(&mut inner.precompiles, block_number); self.install_proposer_control_precompile(&mut inner.precompiles, block_number); + self.install_deploy_permissions_precompile(&mut inner.precompiles, block_number); } evm } @@ -545,7 +608,12 @@ mod tests { use alloy_evm::{Evm, EvmEnv}; use alloy_primitives::{address, keccak256, Address, Bytes, TxKind, U256}; use alloy_sol_types::{sol, SolCall}; - use ev_precompiles::proposer::PROPOSER_CONTROL_PRECOMPILE_ADDR; + use ev_precompiles::{ + deploy_permissions::{ + disabled_slot, IDeployPermissions, DEPLOY_PERMISSIONS_PRECOMPILE_ADDR, + }, + proposer::PROPOSER_CONTROL_PRECOMPILE_ADDR, + }; use reth_revm::{ revm::{ bytecode::Bytecode as RevmBytecode, @@ -963,4 +1031,230 @@ mod tests { .expect("next proposer slot should be written"); assert_eq!(slot.present_value, U256::from_be_bytes(next.0)); } + + fn permission_test_state(accounts: &[Address]) -> State> { + let mut state = State::builder() + .with_database(CacheDB::::default()) + .with_bundle_update() + .build(); + for account in accounts { + state.insert_account( + *account, + AccountInfo { + balance: U256::from(10_000_000_000u64), + nonce: 0, + code_hash: KECCAK_EMPTY, + code: None, + account_id: None, + }, + ); + } + state + } + + fn permission_test_env(block_number: u64) -> EvmEnv { + let mut env: EvmEnv = EvmEnv::default(); + env.cfg_env.chain_id = 1; + env.cfg_env.spec = SpecId::CANCUN; + env.block_env.number = U256::from(block_number); + env.block_env.basefee = 1; + env.block_env.gas_limit = 30_000_000; + env + } + + fn permission_call(caller: Address, nonce: u64, data: Vec) -> TxEnv { + TxEnv { + caller, + kind: TxKind::Call(DEPLOY_PERMISSIONS_PRECOMPILE_ADDR), + nonce, + gas_limit: 500_000, + gas_price: 1, + data: data.into(), + ..Default::default() + } + } + + fn deploy_tx(caller: Address, nonce: u64) -> TxEnv { + TxEnv { + caller, + kind: TxKind::Create, + nonce, + gas_limit: 500_000, + gas_price: 1, + data: Bytes::from_static(&[0x00]), + ..Default::default() + } + } + + #[test] + fn permission_changes_affect_later_transactions_in_order() { + let admin = address!("0x00000000000000000000000000000000000000aa"); + let deployer = address!("0x00000000000000000000000000000000000000bb"); + let settings = DeployAllowlistSettings::new_dynamic(Vec::new(), 0, admin, 0); + let factory = EvEvmFactory::new( + alloy_evm::eth::EthEvmFactory::default(), + None, + None, + None, + Some(settings), + None, + ); + let mut evm = factory.create_evm( + permission_test_state(&[admin, deployer]), + permission_test_env(1), + ); + + assert!(evm.transact_raw(deploy_tx(deployer, 0)).is_err()); + let disable = IDeployPermissions::setEnabledCall { enabled: false }.abi_encode(); + assert!(evm + .transact_commit(permission_call(admin, 0, disable)) + .expect("disable transaction is valid") + .is_success()); + assert!(evm + .transact_commit(deploy_tx(deployer, 0)) + .expect("disabled policy allows deployment") + .is_success()); + + let enable = IDeployPermissions::setEnabledCall { enabled: true }.abi_encode(); + assert!(evm + .transact_commit(permission_call(admin, 1, enable)) + .expect("enable transaction is valid") + .is_success()); + assert!( + evm.transact_raw(deploy_tx(deployer, 1)).is_err(), + "re-enabling must restore the preserved empty policy" + ); + } + + #[test] + fn add_and_remove_affect_later_deployments_in_order() { + let admin = address!("0x00000000000000000000000000000000000000aa"); + let deployer = address!("0x00000000000000000000000000000000000000bb"); + + let add_settings = DeployAllowlistSettings::new_dynamic(Vec::new(), 0, admin, 0); + let add_factory = EvEvmFactory::new( + alloy_evm::eth::EthEvmFactory::default(), + None, + None, + None, + Some(add_settings), + None, + ); + let mut add_evm = add_factory.create_evm( + permission_test_state(&[admin, deployer]), + permission_test_env(1), + ); + let add = IDeployPermissions::addDeployerCall { account: deployer }.abi_encode(); + assert!(add_evm + .transact_commit(permission_call(admin, 0, add)) + .expect("add transaction is valid") + .is_success()); + assert!(add_evm + .transact_commit(deploy_tx(deployer, 0)) + .expect("new member can deploy later in the block") + .is_success()); + + let remove_settings = DeployAllowlistSettings::new_dynamic(vec![deployer], 0, admin, 0); + let remove_factory = EvEvmFactory::new( + alloy_evm::eth::EthEvmFactory::default(), + None, + None, + None, + Some(remove_settings), + None, + ); + let mut remove_evm = remove_factory.create_evm( + permission_test_state(&[admin, deployer]), + permission_test_env(1), + ); + let remove = IDeployPermissions::removeDeployerCall { account: deployer }.abi_encode(); + assert!(remove_evm + .transact_commit(permission_call(admin, 0, remove)) + .expect("remove transaction is valid") + .is_success()); + assert!( + remove_evm.transact_raw(deploy_tx(deployer, 0)).is_err(), + "removed member cannot deploy later in the block" + ); + } + + #[test] + fn deploy_permissions_precompile_respects_activation_height() { + let admin = address!("0x00000000000000000000000000000000000000aa"); + let settings = DeployAllowlistSettings::new_dynamic(Vec::new(), 0, admin, 3); + let factory = EvEvmFactory::new( + alloy_evm::eth::EthEvmFactory::default(), + None, + None, + None, + Some(settings), + None, + ); + let disable = || IDeployPermissions::setEnabledCall { enabled: false }.abi_encode(); + + let mut before = + factory.create_evm(permission_test_state(&[admin]), permission_test_env(2)); + let before_result = before + .transact_raw(permission_call(admin, 0, disable())) + .expect("pre-activation call executes as an ordinary account call"); + assert!(before_result + .state + .get(&DEPLOY_PERMISSIONS_PRECOMPILE_ADDR) + .and_then(|account| account.storage.get(&disabled_slot())) + .is_none()); + + let mut active = + factory.create_evm(permission_test_state(&[admin]), permission_test_env(3)); + let active_result = active + .transact_raw(permission_call(admin, 0, disable())) + .expect("activation-height precompile call executes"); + let disabled = active_result + .state + .get(&DEPLOY_PERMISSIONS_PRECOMPILE_ADDR) + .and_then(|account| account.storage.get(&disabled_slot())) + .expect("active precompile writes disabled state"); + assert_eq!(disabled.present_value, U256::from(1)); + } + + #[test] + fn permission_reads_do_not_change_deployment_gas_accounting() { + let admin = address!("0x00000000000000000000000000000000000000aa"); + let deployer = address!("0x00000000000000000000000000000000000000bb"); + let unrestricted = EvEvmFactory::new( + alloy_evm::eth::EthEvmFactory::default(), + None, + None, + None, + None, + None, + ); + let dynamic = EvEvmFactory::new( + alloy_evm::eth::EthEvmFactory::default(), + None, + None, + None, + Some(DeployAllowlistSettings::new_dynamic( + vec![deployer], + 0, + admin, + 0, + )), + None, + ); + + let unrestricted_result = unrestricted + .create_evm(permission_test_state(&[deployer]), permission_test_env(1)) + .transact_raw(deploy_tx(deployer, 0)) + .expect("unrestricted deployment executes"); + let dynamic_result = dynamic + .create_evm(permission_test_state(&[deployer]), permission_test_env(1)) + .transact_raw(deploy_tx(deployer, 0)) + .expect("dynamically authorized deployment executes"); + + assert_eq!( + dynamic_result.result.tx_gas_used(), + unrestricted_result.result.tx_gas_used(), + "consensus permission reads must not charge EVM gas" + ); + } } diff --git a/crates/ev-revm/src/handler.rs b/crates/ev-revm/src/handler.rs index ea0f51d..7b6274f 100644 --- a/crates/ev-revm/src/handler.rs +++ b/crates/ev-revm/src/handler.rs @@ -6,6 +6,10 @@ use crate::{ tx_env::{BatchCallsTx, SponsorPayerTx}, }; use alloy_primitives::{TxKind, U256}; +use ev_precompiles::deploy_permissions::{ + deployer_override_slot, disabled_slot, resolve_deployer_override, + DEPLOY_PERMISSIONS_PRECOMPILE_ADDR, +}; use reth_revm::{ inspector::{Inspector, InspectorEvmTr, InspectorHandler}, revm::{ @@ -15,7 +19,7 @@ use reth_revm::{ journaled_state::{account::JournaledAccountTr, JournalCheckpoint}, result::HaltReason, transaction::{AccessListItemTr, TransactionType}, - Block, Cfg, ContextTr, JournalTr, Transaction, + Block, Cfg, ContextTr, Database, JournalTr, Transaction, }, handler::{ post_execution, EthFrame, EvmTr, EvmTrError, FrameResult, FrameTr, Handler, @@ -72,7 +76,7 @@ impl EvHandler { } } - fn ensure_deploy_allowed(&self, evm: &EVM) -> Result<(), ERROR> + fn ensure_deploy_allowed(&self, evm: &mut EVM) -> Result<(), ERROR> where EVM: EvmTr>>, ERROR: EvmTrError, @@ -83,13 +87,48 @@ impl EvHandler { .number() .try_into() .unwrap_or(u64::MAX); - let tx = evm.ctx_ref().tx(); - let caller = tx.caller(); - let is_create = matches!(tx.kind(), TxKind::Create); + let (caller, is_create) = { + let tx = evm.ctx_ref().tx(); + (tx.caller(), matches!(tx.kind(), TxKind::Create)) + }; + + if !is_create { + return Ok(()); + } + + let configured_settings = self.deploy_allowlist.as_ref(); + if let Some(settings) = + configured_settings.filter(|settings| settings.is_dynamic_active(block_number)) + { + // Read through the execution database so state committed by earlier transactions in + // this block is visible. This intentionally bypasses the journal's SLOAD path and + // therefore does not warm the precompile account or its storage slots. + let db = evm.ctx_mut().db_mut(); + let disabled = db + .storage(DEPLOY_PERMISSIONS_PRECOMPILE_ADDR, disabled_slot()) + .map_err(ERROR::from)?; + if !disabled.is_zero() { + return Ok(()); + } + let entry = db + .storage( + DEPLOY_PERMISSIONS_PRECOMPILE_ADDR, + deployer_override_slot(caller), + ) + .map_err(ERROR::from)?; + if resolve_deployer_override(entry, settings.is_baseline_member(caller)) { + return Ok(()); + } + return Err( + ::from_string( + "contract deployment not allowed".to_string(), + ), + ); + } - let settings = self.deploy_allowlist_for_block(block_number); + let static_settings = self.deploy_allowlist_for_block(block_number); if let Err(_e) = - crate::deploy::check_deploy_allowed(settings, caller, is_create, block_number) + crate::deploy::check_deploy_allowed(static_settings, caller, is_create, block_number) { return Err( ::from_string( @@ -796,6 +835,9 @@ mod tests { type TestEvm = EvEvm; type TestError = EVMError; type TestHandler = EvHandler>; + type CacheTestContext = Context, CacheDB>; + type CacheTestEvm = EvEvm; + type CacheTestHandler = EvHandler>; use alloy_evm::{Evm, EvmEnv, EvmFactory}; use reth_revm::revm::{ @@ -1720,6 +1762,118 @@ mod tests { ); } + fn dynamic_deploy_evm( + caller: Address, + block_number: u64, + db: CacheDB, + ) -> CacheTestEvm { + let mut ctx = Context::mainnet().with_db(db); + ctx.block.number = U256::from(block_number); + 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 inner = ctx.build_mainnet_with_inspector(NoOpInspector); + EvEvm::from_inner(inner, None, None, false) + } + + fn validate_dynamic_deploy( + evm: &mut CacheTestEvm, + settings: DeployAllowlistSettings, + ) -> Result<(), TestError> { + let handler: CacheTestHandler = EvHandler::new(None, Some(settings)); + handler.validate_against_state_and_deduct_caller(evm, &mut InitialAndFloorGas::default()) + } + + #[test] + fn dynamic_empty_baseline_denies_by_default_without_warming_permission_state() { + let caller = address!("0x00000000000000000000000000000000000000aa"); + let admin = address!("0x00000000000000000000000000000000000000bb"); + let settings = DeployAllowlistSettings::new_dynamic(Vec::new(), 0, admin, 0); + let mut evm = dynamic_deploy_evm(caller, 1, CacheDB::default()); + + let result = validate_dynamic_deploy(&mut evm, settings); + + assert!(matches!(result, Err(EVMError::Custom(_)))); + assert!( + !evm.ctx() + .journal() + .evm_state() + .contains_key(&DEPLOY_PERMISSIONS_PRECOMPILE_ADDR), + "consensus permission reads must not warm or journal the precompile account" + ); + } + + #[test] + fn disabled_dynamic_enforcement_allows_any_deployer() { + let caller = address!("0x00000000000000000000000000000000000000aa"); + let admin = address!("0x00000000000000000000000000000000000000bb"); + let settings = DeployAllowlistSettings::new_dynamic(Vec::new(), 0, admin, 0); + let mut db = CacheDB::default(); + db.insert_account_storage( + DEPLOY_PERMISSIONS_PRECOMPILE_ADDR, + disabled_slot(), + U256::from(1), + ) + .expect("in-memory storage write succeeds"); + let mut evm = dynamic_deploy_evm(caller, 1, db); + + let result = validate_dynamic_deploy(&mut evm, settings); + + assert!( + result.is_ok(), + "disabled enforcement must fail open: {result:?}" + ); + } + + #[test] + fn dynamic_overrides_add_and_remove_genesis_members() { + let baseline = address!("0x00000000000000000000000000000000000000aa"); + let added = address!("0x00000000000000000000000000000000000000bb"); + let admin = address!("0x00000000000000000000000000000000000000cc"); + let settings = DeployAllowlistSettings::new_dynamic(vec![baseline], 0, admin, 0); + + let mut baseline_evm = dynamic_deploy_evm(baseline, 1, CacheDB::default()); + assert!(validate_dynamic_deploy(&mut baseline_evm, settings.clone()).is_ok()); + + let mut removed_db = CacheDB::default(); + removed_db + .insert_account_storage( + DEPLOY_PERMISSIONS_PRECOMPILE_ADDR, + deployer_override_slot(baseline), + U256::from(2), + ) + .expect("in-memory storage write succeeds"); + let mut removed_evm = dynamic_deploy_evm(baseline, 1, removed_db); + assert!(validate_dynamic_deploy(&mut removed_evm, settings.clone()).is_err()); + + let mut added_db = CacheDB::default(); + added_db + .insert_account_storage( + DEPLOY_PERMISSIONS_PRECOMPILE_ADDR, + deployer_override_slot(added), + U256::from(1), + ) + .expect("in-memory storage write succeeds"); + let mut added_evm = dynamic_deploy_evm(added, 1, added_db); + assert!(validate_dynamic_deploy(&mut added_evm, settings).is_ok()); + } + + #[test] + fn dynamic_activation_preserves_pre_activation_static_behavior() { + let caller = address!("0x00000000000000000000000000000000000000aa"); + let admin = address!("0x00000000000000000000000000000000000000bb"); + let settings = DeployAllowlistSettings::new_dynamic(Vec::new(), 0, admin, 2); + + let mut before = dynamic_deploy_evm(caller, 1, CacheDB::default()); + assert!(validate_dynamic_deploy(&mut before, settings.clone()).is_ok()); + + let mut at_activation = dynamic_deploy_evm(caller, 2, CacheDB::default()); + assert!(validate_dynamic_deploy(&mut at_activation, settings).is_err()); + } + #[test] fn call_tx_allowed_for_non_allowlisted_caller() { let allowed = address!("0x00000000000000000000000000000000000000aa"); diff --git a/crates/ev-revm/src/lib.rs b/crates/ev-revm/src/lib.rs index c72dc42..8efe5a7 100644 --- a/crates/ev-revm/src/lib.rs +++ b/crates/ev-revm/src/lib.rs @@ -15,7 +15,10 @@ pub use api::EvBuilder; pub use base_fee::{BaseFeeRedirect, BaseFeeRedirectError}; pub use config::{BaseFeeConfig, ConfigError}; pub use deploy::DeployAllowlistSettings; -pub use ev_precompiles::proposer::PROPOSER_CONTROL_PRECOMPILE_ADDR; +pub use ev_precompiles::{ + deploy_permissions::{DEPLOY_PERMISSIONS_PRECOMPILE_ADDR, MAX_DEPLOYERS}, + proposer::PROPOSER_CONTROL_PRECOMPILE_ADDR, +}; pub use evm::{DefaultEvEvm, EvEvm}; pub use factory::{ with_ev_handler, BaseFeeRedirectSettings, ContractSizeLimitSettings, EvEvmFactory, diff --git a/crates/evolve/src/rpc/txpool.rs b/crates/evolve/src/rpc/txpool.rs index 10d4bae..f0277b4 100644 --- a/crates/evolve/src/rpc/txpool.rs +++ b/crates/evolve/src/rpc/txpool.rs @@ -1,3 +1,8 @@ +#![allow( + clippy::double_must_use, + reason = "jsonrpsee's async trait expansion adds must_use to an already must-use future" +)] + use crate::config::current_block_gas_limit; use alloy_primitives::Bytes; use async_trait::async_trait; diff --git a/crates/node/src/config.rs b/crates/node/src/config.rs index 3efd3c9..35bb137 100644 --- a/crates/node/src/config.rs +++ b/crates/node/src/config.rs @@ -6,7 +6,27 @@ 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; +pub const MAX_DEPLOY_ALLOWLIST_LEN: usize = ev_revm::MAX_DEPLOYERS; + +/// State-backed deployment-permissions configuration. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DynamicDeployAllowlistConfig { + /// Fixed chainspec admin. + pub admin: Address, + /// Block height at which the precompile and dynamic enforcement activate. + pub activation_height: u64, +} + +/// Deployment allowlist configuration derived from the chainspec. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeployAllowlistConfig { + /// Genesis deployer baseline. + pub baseline: Vec
, + /// Block height at which legacy static enforcement activates. + pub static_activation_height: u64, + /// Optional state-backed policy configuration. + pub dynamic: Option, +} #[derive(Debug, Clone, Serialize, Deserialize, Default)] struct ChainspecEvolveConfig { @@ -36,6 +56,12 @@ struct ChainspecEvolveConfig { /// Block height at which deploy allowlist enforcement activates. #[serde(default, rename = "deployAllowlistActivationHeight")] pub deploy_allowlist_activation_height: Option, + /// Fixed admin for the state-backed deployment-permissions precompile. + #[serde(default, rename = "deployAllowlistAdmin")] + pub deploy_allowlist_admin: Option
, + /// Block height at which dynamic deployment permissions activate. + #[serde(default, rename = "deployAllowlistPrecompileActivationHeight")] + pub deploy_allowlist_precompile_activation_height: Option, } /// Configuration for the Evolve payload builder @@ -74,6 +100,12 @@ pub struct EvolvePayloadBuilderConfig { /// Block height at which deploy allowlist enforcement activates. #[serde(default)] pub deploy_allowlist_activation_height: Option, + /// Fixed admin for dynamic deployment permissions. + #[serde(default)] + pub deploy_allowlist_admin: Option
, + /// Block height at which dynamic deployment permissions activate. + #[serde(default)] + pub deploy_allowlist_precompile_activation_height: Option, } impl EvolvePayloadBuilderConfig { @@ -91,6 +123,8 @@ impl EvolvePayloadBuilderConfig { contract_size_limit_activation_height: None, deploy_allowlist: Vec::new(), deploy_allowlist_activation_height: None, + deploy_allowlist_admin: None, + deploy_allowlist_precompile_activation_height: None, } } @@ -135,15 +169,23 @@ impl EvolvePayloadBuilderConfig { config.contract_size_limit_activation_height = extras.contract_size_limit_activation_height; + config.deploy_allowlist_admin = + extras.deploy_allowlist_admin.filter(|addr| !addr.is_zero()); + config.deploy_allowlist_precompile_activation_height = + config.deploy_allowlist_admin.map(|_| { + extras + .deploy_allowlist_precompile_activation_height + .unwrap_or(0) + }); + 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); - } + } + 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); } } @@ -173,13 +215,24 @@ 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() { + /// Returns the genesis baseline, static activation, and optional dynamic settings. + pub fn deploy_allowlist_settings(&self) -> Option { + if self.deploy_allowlist.is_empty() && self.deploy_allowlist_admin.is_none() { None } else { - let activation = self.deploy_allowlist_activation_height.unwrap_or(0); - Some((self.deploy_allowlist.clone(), activation)) + let dynamic = self + .deploy_allowlist_admin + .map(|admin| DynamicDeployAllowlistConfig { + admin, + activation_height: self + .deploy_allowlist_precompile_activation_height + .unwrap_or(0), + }); + Some(DeployAllowlistConfig { + baseline: self.deploy_allowlist.clone(), + static_activation_height: self.deploy_allowlist_activation_height.unwrap_or(0), + dynamic, + }) } } @@ -210,6 +263,18 @@ impl EvolvePayloadBuilderConfig { } } + if !self.deploy_allowlist.is_empty() && self.deploy_allowlist_admin.is_some() { + let dynamic_activation = self + .deploy_allowlist_precompile_activation_height + .unwrap_or(0); + let static_activation = self.deploy_allowlist_activation_height.unwrap_or(0); + if dynamic_activation < static_activation { + return Err(ConfigError::InvalidDeployAllowlist(format!( + "deployAllowlistPrecompileActivationHeight ({dynamic_activation}) must be at or after deployAllowlistActivationHeight ({static_activation})" + ))); + } + } + Ok(()) } @@ -474,6 +539,8 @@ mod tests { assert_eq!(config.proposer_control_activation_height, None); assert!(config.deploy_allowlist.is_empty()); assert_eq!(config.deploy_allowlist_activation_height, None); + assert_eq!(config.deploy_allowlist_admin, None); + assert_eq!(config.deploy_allowlist_precompile_activation_height, None); } #[test] @@ -489,6 +556,8 @@ mod tests { assert_eq!(config.contract_size_limit, None); assert!(config.deploy_allowlist.is_empty()); assert_eq!(config.deploy_allowlist_activation_height, None); + assert_eq!(config.deploy_allowlist_admin, None); + assert_eq!(config.deploy_allowlist_precompile_activation_height, None); } #[test] @@ -526,6 +595,80 @@ mod tests { assert_eq!(config.deploy_allowlist_activation_height, Some(0)); } + #[test] + fn test_dynamic_deploy_permissions_default_activation_and_empty_baseline() { + let admin = address!("00000000000000000000000000000000000000aa"); + let extras = json!({ + "deployAllowlistAdmin": admin + }); + + let chainspec = create_test_chainspec_with_extras(Some(extras)); + let config = EvolvePayloadBuilderConfig::from_chain_spec(&chainspec).unwrap(); + + assert!(config.deploy_allowlist.is_empty()); + assert_eq!(config.deploy_allowlist_admin, Some(admin)); + assert_eq!( + config.deploy_allowlist_precompile_activation_height, + Some(0) + ); + assert_eq!( + config.deploy_allowlist_settings(), + Some(DeployAllowlistConfig { + baseline: Vec::new(), + static_activation_height: 0, + dynamic: Some(DynamicDeployAllowlistConfig { + admin, + activation_height: 0, + }), + }) + ); + assert!(config.validate().is_ok()); + } + + #[test] + fn test_zero_dynamic_admin_preserves_legacy_mode() { + let allowed = address!("00000000000000000000000000000000000000aa"); + let extras = json!({ + "deployAllowlist": [allowed], + "deployAllowlistAdmin": Address::ZERO, + "deployAllowlistPrecompileActivationHeight": 42 + }); + + let chainspec = create_test_chainspec_with_extras(Some(extras)); + let config = EvolvePayloadBuilderConfig::from_chain_spec(&chainspec).unwrap(); + + assert_eq!(config.deploy_allowlist_admin, None); + assert_eq!(config.deploy_allowlist_precompile_activation_height, None); + assert_eq!( + config.deploy_allowlist_settings(), + Some(DeployAllowlistConfig { + baseline: vec![allowed], + static_activation_height: 0, + dynamic: None, + }) + ); + } + + #[test] + fn test_dynamic_activation_cannot_precede_nonempty_static_baseline() { + let allowed = address!("00000000000000000000000000000000000000aa"); + let admin = address!("00000000000000000000000000000000000000bb"); + let extras = json!({ + "deployAllowlist": [allowed], + "deployAllowlistActivationHeight": 20, + "deployAllowlistAdmin": admin, + "deployAllowlistPrecompileActivationHeight": 19 + }); + + 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_zero_address() { let extras = json!({ diff --git a/crates/node/src/executor.rs b/crates/node/src/executor.rs index 535e454..4e94f74 100644 --- a/crates/node/src/executor.rs +++ b/crates/node/src/executor.rs @@ -448,18 +448,26 @@ 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) - }); + let deploy_allowlist = evolve_config.deploy_allowlist_settings().map(|settings| { + info!( + target = "ev-reth::executor", + allowlist_len = settings.baseline.len(), + activation_height = settings.static_activation_height, + dynamic = settings.dynamic.is_some(), + "Deploy allowlist enabled" + ); + match settings.dynamic { + Some(dynamic) => DeployAllowlistSettings::new_dynamic( + settings.baseline, + settings.static_activation_height, + dynamic.admin, + dynamic.activation_height, + ), + None => { + DeployAllowlistSettings::new(settings.baseline, settings.static_activation_height) + } + } + }); let factory = EvTxEvmFactory::new( redirect, diff --git a/crates/node/src/proposer_rpc.rs b/crates/node/src/proposer_rpc.rs index 1555333..80317ed 100644 --- a/crates/node/src/proposer_rpc.rs +++ b/crates/node/src/proposer_rpc.rs @@ -1,3 +1,8 @@ +#![allow( + clippy::double_must_use, + reason = "jsonrpsee's async trait expansion adds must_use to an already must-use future" +)] + //! RPC accessors for Evolve proposer control state. use alloy_eips::BlockNumberOrTag; diff --git a/crates/node/src/txpool.rs b/crates/node/src/txpool.rs index 52e7577..d8833fc 100644 --- a/crates/node/src/txpool.rs +++ b/crates/node/src/txpool.rs @@ -434,7 +434,11 @@ where { let _duration = RecordDurationOnDrop::new(); // Unified deploy allowlist check (covers both Ethereum and EvNode txs). - if let Some(settings) = &self.deploy_allowlist { + if let Some(settings) = self + .deploy_allowlist + .as_ref() + .filter(|settings| !settings.is_dynamic()) + { let is_top_level_create = match pooled.transaction().inner() { EvTxEnvelope::Ethereum(tx) => alloy_consensus::Transaction::is_create(tx), EvTxEnvelope::EvNode(ref signed) => { @@ -621,12 +625,20 @@ where ); Default::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(|settings| { + match settings.dynamic { + Some(dynamic) => ev_revm::deploy::DeployAllowlistSettings::new_dynamic( + settings.baseline, + settings.static_activation_height, + dynamic.admin, + dynamic.activation_height, + ), + None => ev_revm::deploy::DeployAllowlistSettings::new( + settings.baseline, + settings.static_activation_height, + ), + } + }); EvTransactionValidator::new(inner, deploy_allowlist) }); @@ -917,6 +929,27 @@ mod tests { } } + #[test] + fn dynamic_mode_does_not_authoritatively_reject_create() { + let allowed = Address::from([0x11u8; 20]); + let admin = Address::from([0xaau8; 20]); + let settings = + ev_revm::deploy::DeployAllowlistSettings::new_dynamic(vec![allowed], 0, admin, 0); + let validator = create_test_validator(Some(settings)); + + let signed_tx = create_non_sponsored_evnode_create_tx(200_000, 1_000_000_000); + let signer = Address::from([0x22u8; 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(), + "dynamic state can change before execution, so pool admission must not reject: {result:?}" + ); + } + #[test] fn validate_evnode_span_has_expected_fields() { use crate::test_utils::SpanCollector; diff --git a/crates/tests/src/common.rs b/crates/tests/src/common.rs index f359433..ec16840 100644 --- a/crates/tests/src/common.rs +++ b/crates/tests/src/common.rs @@ -227,9 +227,21 @@ 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 deploy_allowlist = + config + .deploy_allowlist_settings() + .map(|settings| match settings.dynamic { + Some(dynamic) => DeployAllowlistSettings::new_dynamic( + settings.baseline, + settings.static_activation_height, + dynamic.admin, + dynamic.activation_height, + ), + None => DeployAllowlistSettings::new( + settings.baseline, + settings.static_activation_height, + ), + }); let evm_factory = EvTxEvmFactory::new( base_fee_redirect, mint_precompile, diff --git a/docs/UPGRADE-v0.6.0.md b/docs/UPGRADE-v0.6.0.md new file mode 100644 index 0000000..b52391c --- /dev/null +++ b/docs/UPGRADE-v0.6.0.md @@ -0,0 +1,65 @@ +# Upgrade Guide: v0.6.0 + +This guide covers rollout of the optional dynamic deployment-permissions precompile. Existing +networks that do not configure `deployAllowlistAdmin` require no chainspec changes and retain their +static deployment behavior. + +## Dynamic Deployment Permissions + +The precompile is installed at `0x000000000000000000000000000000000000F102` at the configured +activation block. Add these fields inside `config.evolve`: + +```json +"deployAllowlist": [ + "0xInitialDeployerAddress" +], +"deployAllowlistActivationHeight": 0, +"deployAllowlistAdmin": "0xAdminProxyOrGovernanceAddress", +"deployAllowlistPrecompileActivationHeight": 20000000 +``` + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `deployAllowlist` | `address[]` | empty | Genesis baseline for dynamic membership. Empty means deny-all while enabled. | +| `deployAllowlistActivationHeight` | `u64` | `0` for a non-empty list | Activation for legacy static enforcement before dynamic activation. | +| `deployAllowlistAdmin` | `address` | -- | Enables dynamic mode and authorizes mutations. Zero or omitted preserves legacy behavior. | +| `deployAllowlistPrecompileActivationHeight` | `u64` | `0` | Block where `F102` and dynamic enforcement activate. | + +When the baseline is non-empty, the precompile activation height must be at or after the static +activation height. The feature is enabled by default at activation. Calling `setEnabled(false)` +allows all top-level deployments until the admin re-enables the preserved policy. + +## Existing-Network Rollout + +1. Choose a future activation height with enough time for every validator and sequencer to upgrade. +2. Set `deployAllowlistAdmin` to an existing `AdminProxy`, multisig, or governance contract. Do not + use a disposable EOA for production authority. +3. Keep `deployAllowlist` equal to the policy that should be active at the transition. +4. Distribute the identical chainspec and upgraded binary to every validating node before activation. +5. At activation, verify `isEnabled()`, `deployerCount()`, `admin()`, and representative + `isDeployerAllowed(address)` calls with standard `eth_call`. +6. Exercise add/remove and pause transactions only after activation; calls sent before activation + are ordinary empty-account calls and write no permission state. + +Do not rely on txpool rejection as a dynamic policy check. A transaction admitted while allowed may +be rejected at execution after a policy change, and a transaction admitted while currently denied +may become valid before execution. Block execution is authoritative. + +## Rollback + +Before activation, roll back by restoring the previous binary and chainspec. After activation has +produced blocks, changing or removing the feature requires a coordinated consensus upgrade; do not +silently move the activation height. Operationally, the configured admin can call `setEnabled(false)` +to fail open deployment while preserving membership for later recovery. + +## Checklist + +- [ ] All validators use the same admin, baseline, and activation heights +- [ ] Dynamic activation does not precede non-empty static-list activation +- [ ] The admin contract and its recovery process are tested +- [ ] Every validating node upgrades before the activation block +- [ ] Read calls at `F102` are verified at activation +- [ ] Monitoring distinguishes policy membership from the global enabled flag + +See [ADR 0005](adr/ADR-0005-dynamic-deployment-permissions.md) and the +[permissioned EVM guide](guide/permissioned-evm.md). diff --git a/docs/adr/ADR-0005-dynamic-deployment-permissions.md b/docs/adr/ADR-0005-dynamic-deployment-permissions.md new file mode 100644 index 0000000..f1c5ea5 --- /dev/null +++ b/docs/adr/ADR-0005-dynamic-deployment-permissions.md @@ -0,0 +1,113 @@ +# ADR 0005: Dynamic Deployment Permissions Precompile + +## Changelog + +* 2026-08-25: Accepted and implemented. + +## Status + +ACCEPTED + +## Abstract + +Chains need to update deployment permissions without coordinating a hard fork for every membership +change. We add an optional state-backed precompile at `0xF102`. A fixed chainspec admin can add and +remove top-level deployers or temporarily disable enforcement. The genesis `deployAllowlist` is the +initial policy, and legacy chains remain unchanged unless they configure a non-zero admin. + +## Context + +The existing deployment allowlist is static chainspec data. It is simple and deterministic, but an +operational membership change requires every validator to adopt identical configuration at the same +height. Deployment authorization is consensus-critical, so stale local caches and txpool snapshots +cannot be authoritative once policy becomes mutable. The policy must also survive rollback, replay, +and reorganization exactly like other execution state. + +The restriction intentionally covers only top-level `CREATE` transactions. Calls and internal +`CREATE/CREATE2` remain available, so operators must separately control factory contracts if they +need stronger permissioning. + +## Alternatives + +### Continue using chainspec-only configuration + +This has the smallest implementation surface but makes routine membership changes hard forks and +does not provide an emergency pause. + +### Deploy a Solidity registry + +A registry provides events and familiar tooling, but introduces deployable bytecode, upgrade, and +storage-layout concerns for consensus-critical logic. It also requires a bootstrap allocation or +deployment process. + +### Make txpool state authoritative + +The txpool can reject stale or currently unauthorized deployments, but mutable permission state may +change before execution. Permanent pool rejection would incorrectly discard transactions that can +become valid. Execution must remain authoritative. + +## Decision + +We install `IDeployPermissions` at `0x000000000000000000000000000000000000F102` +when a non-zero `deployAllowlistAdmin` is configured and the independent +`deployAllowlistPrecompileActivationHeight` is reached. The activation defaults to block zero. + +Before dynamic activation, the existing static policy applies. At activation, the genesis list is +the baseline. An unset enabled flag means enabled, avoiding a bootstrap transaction. A stored +disabled flag makes top-level deployment fail open while leaving the precompile callable. Re-enabling +clears that flag and restores the preserved policy. + +Membership uses domain-separated hashed storage keys and tri-state address entries: unset falls back +to the genesis baseline, allowed adds a non-baseline member, and denied removes a baseline member. +Removing a non-baseline member clears its slot; only removed baseline members retain tombstones. An +encoded active-member count distinguishes an uninitialized count from zero. The active set is capped +at 1024 and excludes the zero address. The precompile account receives sentinel code and nonce so +written storage cannot be pruned as an empty account. + +The EVM handler reads the disabled flag and caller override directly through the current execution +database. It bypasses the journal SLOAD path so permission checks do not warm accounts or storage or +change transaction gas. State committed by an earlier transaction in the same block is therefore +visible to later deployments. Reverted transactions and reorganized blocks naturally restore the +prior policy. + +Dynamic-mode txpool validation does not permanently reject deployments based on permission state. +Legacy mode retains the existing static rejection. Standard `eth_call` provides inspection; no +custom RPC is added. + +## Consequences + +### Backwards Compatibility + +Chains without a non-zero admin use the exact legacy static behavior. Existing chains enabling the +feature must coordinate a future activation height after all validating nodes upgrade. When the +baseline is non-empty, dynamic activation must not precede static-list activation. + +### Positive + +* Membership and emergency pause changes no longer require hard forks. +* State transitions are ordered, replayable, revertible, and reorg-safe. +* Disabling enforcement does not destroy policy or prevent recovery transactions. +* The genesis list remains useful as a zero-transaction bootstrap baseline. + +### Negative + +* A compromised admin can allow arbitrary top-level deployments or fail open enforcement. +* The fixed chainspec admin cannot rotate without a hard fork; production deployments need an + `AdminProxy`, multisig, or governance contract at that address. +* Native precompile operations emit no events in v1. + +### Neutral + +* Internal factory deployment remains outside this control surface. +* Batching and native event support are deferred. + +## Test Cases + +Tests cover default-enabled and disabled behavior, authorization, static-call rejection, baseline +fallback, overrides, idempotence, the member cap, activation, direct database reads without journal +warming, transaction ordering, legacy compatibility, and dynamic txpool admission. + +## References + +* [Permissioned EVM guide](../guide/permissioned-evm.md) +* [AdminProxy](../contracts/admin_proxy.md) diff --git a/docs/guide/permissioned-evm.md b/docs/guide/permissioned-evm.md index c80b091..26c6307 100644 --- a/docs/guide/permissioned-evm.md +++ b/docs/guide/permissioned-evm.md @@ -2,21 +2,20 @@ ## 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. +This guide covers static and state-backed deployment permissions. They restrict top-level contract +creation transactions to approved callers. They do not restrict regular calls and are not a full +transaction allowlist. ## Deploy Allowlist (execution layer) -**Purpose**: Restrict contract deployment to a known set of EOAs. +**Purpose**: Restrict contract deployment to a known set of accounts. **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. -- An empty allowlist is treated as disabled and allows all deployers. +- If no allowlist or dynamic admin is configured, behavior matches standard Ethereum. **Chainspec configuration** (inside `config.evolve`): @@ -34,10 +33,62 @@ regular call transactions and is not a full transaction allowlist. - If `deployAllowlist` is set and `deployAllowlistActivationHeight` is omitted, activation defaults to `0`. -- If the allowlist is empty or missing, contract deployment is unrestricted (treated as disabled). +- In legacy static mode, an empty or missing list leaves deployment unrestricted. - Duplicate entries or the zero address are rejected at startup. - The list is capped at 1024 addresses. +## Dynamic Deployment Permissions + +Set `deployAllowlistAdmin` to enable the state-backed precompile at +`0x000000000000000000000000000000000000F102`: + +```json +"evolve": { + "deployAllowlist": [ + "0xInitialDeployerAddress" + ], + "deployAllowlistActivationHeight": 0, + "deployAllowlistAdmin": "0xAdminProxyOrGovernanceAddress", + "deployAllowlistPrecompileActivationHeight": 20000000 +} +``` + +`deployAllowlistPrecompileActivationHeight` defaults to `0` when a non-zero admin is set. Before +that block, static behavior is unchanged. At and after that block, the genesis list is the dynamic +baseline and state-backed enforcement is enabled by default. If the baseline is non-empty, dynamic +activation must be at or after `deployAllowlistActivationHeight`. + +A configured empty baseline is intentionally different from legacy mode: it denies every top-level +deployment while enforcement is enabled. Setting the admin to zero is the same as omitting it and +preserves legacy static behavior. + +### Interface + +```solidity +interface IDeployPermissions { + function addDeployer(address account) external; + function removeDeployer(address account) external; + function setEnabled(bool enabled) external; + function isDeployerAllowed(address account) external view returns (bool); + function isEnabled() external view returns (bool); + function deployerCount() external view returns (uint256); + function admin() external view returns (address); +} +``` + +Only the fixed chainspec admin can mutate state. `addDeployer`, `removeDeployer`, and `setEnabled` +are idempotent and reject unauthorized callers; member mutations also reject the zero address. +There can be at most 1024 active deployers. + +`setEnabled(false)` is a reversible fail-open pause: every top-level deployment is allowed. It does +not disable the precompile or discard the allowlist. The admin can edit membership while enforcement +is disabled and then call `setEnabled(true)` to enforce the updated policy. `isDeployerAllowed` +reports membership in that preserved policy; combine it with `isEnabled` to determine current +enforcement behavior. + +Use standard `eth_call` against `F102` for reads. No custom RPC method is registered. The precompile +emits no native events in v1. + ## Security and Limitations - This is not a general permissioned chain; it only gates top-level contract creation. @@ -48,10 +99,17 @@ regular call transactions and is not a full transaction allowlist. ## Operational Notes -- The allowlist is static; changes require a chainspec update and node restart. -- For existing networks, use an activation height to coordinate rollouts. +- Without `deployAllowlistAdmin`, changes require a chainspec update and coordinated activation. +- With an admin, membership and enforcement state are ordinary execution state and follow normal + transaction rollback and chain-reorganization semantics. +- For production, point the fixed admin at an `AdminProxy`, multisig, or governance contract. Admin + rotation should happen behind that contract; changing the chainspec admin requires a hard fork. +- Existing networks must use a coordinated future `deployAllowlistPrecompileActivationHeight` and + upgrade all validating nodes before it. References: - `crates/node/src/config.rs` - `crates/ev-revm/src/handler.rs` +- `crates/ev-precompiles/src/deploy_permissions.rs` +- `docs/adr/ADR-0005-dynamic-deployment-permissions.md` diff --git a/etc/ev-reth-genesis.json b/etc/ev-reth-genesis.json index 63d82cd..c172034 100644 --- a/etc/ev-reth-genesis.json +++ b/etc/ev-reth-genesis.json @@ -27,7 +27,13 @@ "proposerControlAdmin": "0x000000000000000000000000000000000000Ad00", "proposerControlActivationHeight": 0, "contractSizeLimit": 131072, - "contractSizeLimitActivationHeight": 0 + "contractSizeLimitActivationHeight": 0, + "deployAllowlist": [ + "0x000000000000000000000000000000000000Ad00" + ], + "deployAllowlistActivationHeight": 0, + "deployAllowlistAdmin": "0x000000000000000000000000000000000000Ad00", + "deployAllowlistPrecompileActivationHeight": 0 } }, "difficulty": "0x1", From 104629df31f47654f5430c73a1cc62159283bd5d Mon Sep 17 00:00:00 2001 From: tac0turtle Date: Tue, 25 Aug 2026 16:57:50 +0200 Subject: [PATCH 2/5] refactor: unify deploy permission activation height --- CHANGELOG.md | 4 +- README.md | 15 ++- crates/ev-precompiles/README.md | 2 +- crates/ev-revm/src/deploy.rs | 19 +--- crates/ev-revm/src/factory.rs | 13 ++- crates/ev-revm/src/handler.rs | 10 +- crates/node/src/config.rs | 96 +++++-------------- crates/node/src/executor.rs | 17 ++-- crates/node/src/txpool.rs | 13 ++- crates/tests/src/common.rs | 16 ++-- docs/UPGRADE-v0.6.0.md | 28 +++--- ...ADR-0005-dynamic-deployment-permissions.md | 17 ++-- docs/guide/permissioned-evm.md | 18 ++-- etc/ev-reth-genesis.json | 3 +- 14 files changed, 102 insertions(+), 169 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f5f274..e64b425 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Optional state-backed deployment-permissions precompile at `0xF102`, enabled by - `deployAllowlistAdmin` with independent `deployAllowlistPrecompileActivationHeight`. The fixed - admin can add or remove deployers and reversibly pause enforcement while preserving policy. + `deployAllowlistAdmin` at `deployAllowlistActivationHeight`. The fixed admin can add or remove + deployers and reversibly pause enforcement while preserving policy. ### Changed diff --git a/README.md b/README.md index 31bc0c9..4307930 100644 --- a/README.md +++ b/README.md @@ -485,8 +485,7 @@ Operational notes: - The allowlist is static and must be changed via a chainspec update. - Duplicate entries or the zero address are rejected at startup. -To manage permissions on-chain, configure the deployment-permissions admin and its independent -activation height: +To manage permissions on-chain, configure the deployment-permissions admin: ```json "config": { @@ -495,20 +494,20 @@ activation height: "deployAllowlist": [ "0xInitialDeployerAddress" ], - "deployAllowlistActivationHeight": 0, - "deployAllowlistAdmin": "0xAdminProxyOrGovernanceAddress", - "deployAllowlistPrecompileActivationHeight": 20000000 + "deployAllowlistActivationHeight": 20000000, + "deployAllowlistAdmin": "0xAdminProxyOrGovernanceAddress" } } ``` -At the dynamic activation height, `deployAllowlist` becomes the baseline for the state-backed +At `deployAllowlistActivationHeight`, `deployAllowlist` becomes the baseline for the state-backed precompile at `0x000000000000000000000000000000000000F102`. Enforcement is enabled by default. The fixed admin can add or remove deployers and can temporarily disable enforcement; disabling is fail-open for top-level deployments and preserves the policy for later re-enablement. An empty baseline therefore means deny-all while enabled, not “feature disabled.” Use standard `eth_call` -for inspection. See the [permissioned EVM guide](docs/guide/permissioned-evm.md) for the interface, -activation constraints, and rollout procedure. +for inspection. See the [permissioned EVM guide](docs/guide/permissioned-evm.md) for the interface +and rollout procedure. Existing networks can opt in only while their configured activation height +is still in the future; v1 intentionally has no second activation field. ### Payload Builder Configuration diff --git a/crates/ev-precompiles/README.md b/crates/ev-precompiles/README.md index 5a91d7a..3744781 100644 --- a/crates/ev-precompiles/README.md +++ b/crates/ev-precompiles/README.md @@ -301,7 +301,7 @@ Invalid ABI data also halts the precompile. None of these emit logs. The optional deployment-permissions precompile is installed at `0x000000000000000000000000000000000000f102` when `deployAllowlistAdmin` is configured and -`deployAllowlistPrecompileActivationHeight` is reached. +`deployAllowlistActivationHeight` is reached. ```solidity interface IDeployPermissions { diff --git a/crates/ev-revm/src/deploy.rs b/crates/ev-revm/src/deploy.rs index 6ad37a9..d32716a 100644 --- a/crates/ev-revm/src/deploy.rs +++ b/crates/ev-revm/src/deploy.rs @@ -9,7 +9,6 @@ pub struct DeployAllowlistSettings { allowlist: Arc<[Address]>, activation_height: u64, dynamic_admin: Option
, - dynamic_activation_height: u64, } impl DeployAllowlistSettings { @@ -23,20 +22,13 @@ impl DeployAllowlistSettings { allowlist: Arc::from(allowlist), activation_height, dynamic_admin: None, - dynamic_activation_height: 0, } } - /// Creates deployment settings that transition from static to dynamic enforcement. - pub fn new_dynamic( - allowlist: Vec
, - activation_height: u64, - admin: Address, - dynamic_activation_height: u64, - ) -> Self { + /// Creates state-backed deployment settings with a fixed admin. + pub fn new_dynamic(allowlist: Vec
, activation_height: u64, admin: Address) -> Self { let mut settings = Self::new(allowlist, activation_height); settings.dynamic_admin = Some(admin); - settings.dynamic_activation_height = dynamic_activation_height; settings } @@ -73,11 +65,6 @@ impl DeployAllowlistSettings { self.dynamic_admin } - /// Returns the dynamic-permissions activation height. - pub const fn dynamic_activation_height(&self) -> u64 { - self.dynamic_activation_height - } - /// Returns whether this chain uses state-backed deployment permissions. pub const fn is_dynamic(&self) -> bool { self.dynamic_admin.is_some() @@ -85,7 +72,7 @@ impl DeployAllowlistSettings { /// Returns whether dynamic deployment permissions are active in this block. pub const fn is_dynamic_active(&self, block_number: u64) -> bool { - self.is_dynamic() && block_number >= self.dynamic_activation_height + self.is_dynamic() && self.is_active(block_number) } } diff --git a/crates/ev-revm/src/factory.rs b/crates/ev-revm/src/factory.rs index b8186d0..21f602f 100644 --- a/crates/ev-revm/src/factory.rs +++ b/crates/ev-revm/src/factory.rs @@ -238,7 +238,7 @@ impl EvEvmFactory { let Some(admin) = settings.dynamic_admin() else { return; }; - if block_number < U256::from(settings.dynamic_activation_height()) { + if block_number < U256::from(settings.activation_height()) { return; } @@ -442,7 +442,7 @@ impl EvTxEvmFactory { let Some(admin) = settings.dynamic_admin() else { return; }; - if block_number < U256::from(settings.dynamic_activation_height()) { + if block_number < U256::from(settings.activation_height()) { return; } @@ -1090,7 +1090,7 @@ mod tests { fn permission_changes_affect_later_transactions_in_order() { let admin = address!("0x00000000000000000000000000000000000000aa"); let deployer = address!("0x00000000000000000000000000000000000000bb"); - let settings = DeployAllowlistSettings::new_dynamic(Vec::new(), 0, admin, 0); + let settings = DeployAllowlistSettings::new_dynamic(Vec::new(), 0, admin); let factory = EvEvmFactory::new( alloy_evm::eth::EthEvmFactory::default(), None, @@ -1131,7 +1131,7 @@ mod tests { let admin = address!("0x00000000000000000000000000000000000000aa"); let deployer = address!("0x00000000000000000000000000000000000000bb"); - let add_settings = DeployAllowlistSettings::new_dynamic(Vec::new(), 0, admin, 0); + let add_settings = DeployAllowlistSettings::new_dynamic(Vec::new(), 0, admin); let add_factory = EvEvmFactory::new( alloy_evm::eth::EthEvmFactory::default(), None, @@ -1154,7 +1154,7 @@ mod tests { .expect("new member can deploy later in the block") .is_success()); - let remove_settings = DeployAllowlistSettings::new_dynamic(vec![deployer], 0, admin, 0); + let remove_settings = DeployAllowlistSettings::new_dynamic(vec![deployer], 0, admin); let remove_factory = EvEvmFactory::new( alloy_evm::eth::EthEvmFactory::default(), None, @@ -1181,7 +1181,7 @@ mod tests { #[test] fn deploy_permissions_precompile_respects_activation_height() { let admin = address!("0x00000000000000000000000000000000000000aa"); - let settings = DeployAllowlistSettings::new_dynamic(Vec::new(), 0, admin, 3); + let settings = DeployAllowlistSettings::new_dynamic(Vec::new(), 3, admin); let factory = EvEvmFactory::new( alloy_evm::eth::EthEvmFactory::default(), None, @@ -1237,7 +1237,6 @@ mod tests { vec![deployer], 0, admin, - 0, )), None, ); diff --git a/crates/ev-revm/src/handler.rs b/crates/ev-revm/src/handler.rs index 7b6274f..c5807c2 100644 --- a/crates/ev-revm/src/handler.rs +++ b/crates/ev-revm/src/handler.rs @@ -1791,7 +1791,7 @@ mod tests { fn dynamic_empty_baseline_denies_by_default_without_warming_permission_state() { let caller = address!("0x00000000000000000000000000000000000000aa"); let admin = address!("0x00000000000000000000000000000000000000bb"); - let settings = DeployAllowlistSettings::new_dynamic(Vec::new(), 0, admin, 0); + let settings = DeployAllowlistSettings::new_dynamic(Vec::new(), 0, admin); let mut evm = dynamic_deploy_evm(caller, 1, CacheDB::default()); let result = validate_dynamic_deploy(&mut evm, settings); @@ -1810,7 +1810,7 @@ mod tests { fn disabled_dynamic_enforcement_allows_any_deployer() { let caller = address!("0x00000000000000000000000000000000000000aa"); let admin = address!("0x00000000000000000000000000000000000000bb"); - let settings = DeployAllowlistSettings::new_dynamic(Vec::new(), 0, admin, 0); + let settings = DeployAllowlistSettings::new_dynamic(Vec::new(), 0, admin); let mut db = CacheDB::default(); db.insert_account_storage( DEPLOY_PERMISSIONS_PRECOMPILE_ADDR, @@ -1833,7 +1833,7 @@ mod tests { let baseline = address!("0x00000000000000000000000000000000000000aa"); let added = address!("0x00000000000000000000000000000000000000bb"); let admin = address!("0x00000000000000000000000000000000000000cc"); - let settings = DeployAllowlistSettings::new_dynamic(vec![baseline], 0, admin, 0); + let settings = DeployAllowlistSettings::new_dynamic(vec![baseline], 0, admin); let mut baseline_evm = dynamic_deploy_evm(baseline, 1, CacheDB::default()); assert!(validate_dynamic_deploy(&mut baseline_evm, settings.clone()).is_ok()); @@ -1862,10 +1862,10 @@ mod tests { } #[test] - fn dynamic_activation_preserves_pre_activation_static_behavior() { + fn dynamic_permissions_activate_at_allowlist_height() { let caller = address!("0x00000000000000000000000000000000000000aa"); let admin = address!("0x00000000000000000000000000000000000000bb"); - let settings = DeployAllowlistSettings::new_dynamic(Vec::new(), 0, admin, 2); + let settings = DeployAllowlistSettings::new_dynamic(Vec::new(), 2, admin); let mut before = dynamic_deploy_evm(caller, 1, CacheDB::default()); assert!(validate_dynamic_deploy(&mut before, settings.clone()).is_ok()); diff --git a/crates/node/src/config.rs b/crates/node/src/config.rs index 35bb137..314f228 100644 --- a/crates/node/src/config.rs +++ b/crates/node/src/config.rs @@ -8,24 +8,15 @@ 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 = ev_revm::MAX_DEPLOYERS; -/// State-backed deployment-permissions configuration. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct DynamicDeployAllowlistConfig { - /// Fixed chainspec admin. - pub admin: Address, - /// Block height at which the precompile and dynamic enforcement activate. - pub activation_height: u64, -} - /// Deployment allowlist configuration derived from the chainspec. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DeployAllowlistConfig { /// Genesis deployer baseline. pub baseline: Vec
, - /// Block height at which legacy static enforcement activates. - pub static_activation_height: u64, - /// Optional state-backed policy configuration. - pub dynamic: Option, + /// Block height at which deployment restrictions activate. + pub activation_height: u64, + /// Optional fixed admin for state-backed policy management. + pub admin: Option
, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -59,9 +50,6 @@ struct ChainspecEvolveConfig { /// Fixed admin for the state-backed deployment-permissions precompile. #[serde(default, rename = "deployAllowlistAdmin")] pub deploy_allowlist_admin: Option
, - /// Block height at which dynamic deployment permissions activate. - #[serde(default, rename = "deployAllowlistPrecompileActivationHeight")] - pub deploy_allowlist_precompile_activation_height: Option, } /// Configuration for the Evolve payload builder @@ -103,9 +91,6 @@ pub struct EvolvePayloadBuilderConfig { /// Fixed admin for dynamic deployment permissions. #[serde(default)] pub deploy_allowlist_admin: Option
, - /// Block height at which dynamic deployment permissions activate. - #[serde(default)] - pub deploy_allowlist_precompile_activation_height: Option, } impl EvolvePayloadBuilderConfig { @@ -124,7 +109,6 @@ impl EvolvePayloadBuilderConfig { deploy_allowlist: Vec::new(), deploy_allowlist_activation_height: None, deploy_allowlist_admin: None, - deploy_allowlist_precompile_activation_height: None, } } @@ -171,12 +155,6 @@ impl EvolvePayloadBuilderConfig { config.deploy_allowlist_admin = extras.deploy_allowlist_admin.filter(|addr| !addr.is_zero()); - config.deploy_allowlist_precompile_activation_height = - config.deploy_allowlist_admin.map(|_| { - extras - .deploy_allowlist_precompile_activation_height - .unwrap_or(0) - }); if let Some(allowlist) = extras.deploy_allowlist { config.deploy_allowlist = allowlist; @@ -215,23 +193,15 @@ impl EvolvePayloadBuilderConfig { .unwrap_or(DEFAULT_CONTRACT_SIZE_LIMIT) } - /// Returns the genesis baseline, static activation, and optional dynamic settings. + /// Returns the genesis baseline, activation height, and optional dynamic admin. pub fn deploy_allowlist_settings(&self) -> Option { if self.deploy_allowlist.is_empty() && self.deploy_allowlist_admin.is_none() { None } else { - let dynamic = self - .deploy_allowlist_admin - .map(|admin| DynamicDeployAllowlistConfig { - admin, - activation_height: self - .deploy_allowlist_precompile_activation_height - .unwrap_or(0), - }); Some(DeployAllowlistConfig { baseline: self.deploy_allowlist.clone(), - static_activation_height: self.deploy_allowlist_activation_height.unwrap_or(0), - dynamic, + activation_height: self.deploy_allowlist_activation_height.unwrap_or(0), + admin: self.deploy_allowlist_admin, }) } } @@ -263,18 +233,6 @@ impl EvolvePayloadBuilderConfig { } } - if !self.deploy_allowlist.is_empty() && self.deploy_allowlist_admin.is_some() { - let dynamic_activation = self - .deploy_allowlist_precompile_activation_height - .unwrap_or(0); - let static_activation = self.deploy_allowlist_activation_height.unwrap_or(0); - if dynamic_activation < static_activation { - return Err(ConfigError::InvalidDeployAllowlist(format!( - "deployAllowlistPrecompileActivationHeight ({dynamic_activation}) must be at or after deployAllowlistActivationHeight ({static_activation})" - ))); - } - } - Ok(()) } @@ -540,7 +498,6 @@ mod tests { assert!(config.deploy_allowlist.is_empty()); assert_eq!(config.deploy_allowlist_activation_height, None); assert_eq!(config.deploy_allowlist_admin, None); - assert_eq!(config.deploy_allowlist_precompile_activation_height, None); } #[test] @@ -557,7 +514,6 @@ mod tests { assert!(config.deploy_allowlist.is_empty()); assert_eq!(config.deploy_allowlist_activation_height, None); assert_eq!(config.deploy_allowlist_admin, None); - assert_eq!(config.deploy_allowlist_precompile_activation_height, None); } #[test] @@ -607,19 +563,13 @@ mod tests { assert!(config.deploy_allowlist.is_empty()); assert_eq!(config.deploy_allowlist_admin, Some(admin)); - assert_eq!( - config.deploy_allowlist_precompile_activation_height, - Some(0) - ); + assert_eq!(config.deploy_allowlist_activation_height, None); assert_eq!( config.deploy_allowlist_settings(), Some(DeployAllowlistConfig { baseline: Vec::new(), - static_activation_height: 0, - dynamic: Some(DynamicDeployAllowlistConfig { - admin, - activation_height: 0, - }), + activation_height: 0, + admin: Some(admin), }) ); assert!(config.validate().is_ok()); @@ -630,43 +580,45 @@ mod tests { let allowed = address!("00000000000000000000000000000000000000aa"); let extras = json!({ "deployAllowlist": [allowed], - "deployAllowlistAdmin": Address::ZERO, - "deployAllowlistPrecompileActivationHeight": 42 + "deployAllowlistAdmin": Address::ZERO }); let chainspec = create_test_chainspec_with_extras(Some(extras)); let config = EvolvePayloadBuilderConfig::from_chain_spec(&chainspec).unwrap(); assert_eq!(config.deploy_allowlist_admin, None); - assert_eq!(config.deploy_allowlist_precompile_activation_height, None); assert_eq!( config.deploy_allowlist_settings(), Some(DeployAllowlistConfig { baseline: vec![allowed], - static_activation_height: 0, - dynamic: None, + activation_height: 0, + admin: None, }) ); } #[test] - fn test_dynamic_activation_cannot_precede_nonempty_static_baseline() { + fn test_dynamic_permissions_use_deploy_allowlist_activation_height() { let allowed = address!("00000000000000000000000000000000000000aa"); let admin = address!("00000000000000000000000000000000000000bb"); let extras = json!({ "deployAllowlist": [allowed], "deployAllowlistActivationHeight": 20, - "deployAllowlistAdmin": admin, - "deployAllowlistPrecompileActivationHeight": 19 + "deployAllowlistAdmin": admin }); let chainspec = create_test_chainspec_with_extras(Some(extras)); let config = EvolvePayloadBuilderConfig::from_chain_spec(&chainspec).unwrap(); - assert!(matches!( - config.validate(), - Err(ConfigError::InvalidDeployAllowlist(_)) - )); + assert_eq!( + config.deploy_allowlist_settings(), + Some(DeployAllowlistConfig { + baseline: vec![allowed], + activation_height: 20, + admin: Some(admin), + }) + ); + assert!(config.validate().is_ok()); } #[test] diff --git a/crates/node/src/executor.rs b/crates/node/src/executor.rs index 4e94f74..70994e7 100644 --- a/crates/node/src/executor.rs +++ b/crates/node/src/executor.rs @@ -452,20 +452,17 @@ where info!( target = "ev-reth::executor", allowlist_len = settings.baseline.len(), - activation_height = settings.static_activation_height, - dynamic = settings.dynamic.is_some(), + activation_height = settings.activation_height, + dynamic = settings.admin.is_some(), "Deploy allowlist enabled" ); - match settings.dynamic { - Some(dynamic) => DeployAllowlistSettings::new_dynamic( + match settings.admin { + Some(admin) => DeployAllowlistSettings::new_dynamic( settings.baseline, - settings.static_activation_height, - dynamic.admin, - dynamic.activation_height, + settings.activation_height, + admin, ), - None => { - DeployAllowlistSettings::new(settings.baseline, settings.static_activation_height) - } + None => DeployAllowlistSettings::new(settings.baseline, settings.activation_height), } }); diff --git a/crates/node/src/txpool.rs b/crates/node/src/txpool.rs index d8833fc..a6e1056 100644 --- a/crates/node/src/txpool.rs +++ b/crates/node/src/txpool.rs @@ -626,16 +626,15 @@ where Default::default() }); let deploy_allowlist = evolve_config.deploy_allowlist_settings().map(|settings| { - match settings.dynamic { - Some(dynamic) => ev_revm::deploy::DeployAllowlistSettings::new_dynamic( + match settings.admin { + Some(admin) => ev_revm::deploy::DeployAllowlistSettings::new_dynamic( settings.baseline, - settings.static_activation_height, - dynamic.admin, - dynamic.activation_height, + settings.activation_height, + admin, ), None => ev_revm::deploy::DeployAllowlistSettings::new( settings.baseline, - settings.static_activation_height, + settings.activation_height, ), } }); @@ -934,7 +933,7 @@ mod tests { let allowed = Address::from([0x11u8; 20]); let admin = Address::from([0xaau8; 20]); let settings = - ev_revm::deploy::DeployAllowlistSettings::new_dynamic(vec![allowed], 0, admin, 0); + ev_revm::deploy::DeployAllowlistSettings::new_dynamic(vec![allowed], 0, admin); let validator = create_test_validator(Some(settings)); let signed_tx = create_non_sponsored_evnode_create_tx(200_000, 1_000_000_000); diff --git a/crates/tests/src/common.rs b/crates/tests/src/common.rs index ec16840..afd7398 100644 --- a/crates/tests/src/common.rs +++ b/crates/tests/src/common.rs @@ -230,17 +230,15 @@ impl EvolveTestFixture { let deploy_allowlist = config .deploy_allowlist_settings() - .map(|settings| match settings.dynamic { - Some(dynamic) => DeployAllowlistSettings::new_dynamic( + .map(|settings| match settings.admin { + Some(admin) => DeployAllowlistSettings::new_dynamic( settings.baseline, - settings.static_activation_height, - dynamic.admin, - dynamic.activation_height, - ), - None => DeployAllowlistSettings::new( - settings.baseline, - settings.static_activation_height, + settings.activation_height, + admin, ), + None => { + DeployAllowlistSettings::new(settings.baseline, settings.activation_height) + } }); let evm_factory = EvTxEvmFactory::new( base_fee_redirect, diff --git a/docs/UPGRADE-v0.6.0.md b/docs/UPGRADE-v0.6.0.md index b52391c..a0838b7 100644 --- a/docs/UPGRADE-v0.6.0.md +++ b/docs/UPGRADE-v0.6.0.md @@ -2,7 +2,10 @@ This guide covers rollout of the optional dynamic deployment-permissions precompile. Existing networks that do not configure `deployAllowlistAdmin` require no chainspec changes and retain their -static deployment behavior. +static deployment behavior. A network can opt into dynamic permissions only while its configured +`deployAllowlistActivationHeight` is still in the future. Networks whose static activation has +already passed need a separate coordinated consensus upgrade mechanism; this version does not +expose a second activation height. ## Dynamic Deployment Permissions @@ -13,25 +16,23 @@ activation block. Add these fields inside `config.evolve`: "deployAllowlist": [ "0xInitialDeployerAddress" ], -"deployAllowlistActivationHeight": 0, -"deployAllowlistAdmin": "0xAdminProxyOrGovernanceAddress", -"deployAllowlistPrecompileActivationHeight": 20000000 +"deployAllowlistActivationHeight": 20000000, +"deployAllowlistAdmin": "0xAdminProxyOrGovernanceAddress" ``` | Field | Type | Default | Description | | --- | --- | --- | --- | | `deployAllowlist` | `address[]` | empty | Genesis baseline for dynamic membership. Empty means deny-all while enabled. | -| `deployAllowlistActivationHeight` | `u64` | `0` for a non-empty list | Activation for legacy static enforcement before dynamic activation. | +| `deployAllowlistActivationHeight` | `u64` | `0` | Block where deployment restrictions and `F102` activate. | | `deployAllowlistAdmin` | `address` | -- | Enables dynamic mode and authorizes mutations. Zero or omitted preserves legacy behavior. | -| `deployAllowlistPrecompileActivationHeight` | `u64` | `0` | Block where `F102` and dynamic enforcement activate. | -When the baseline is non-empty, the precompile activation height must be at or after the static -activation height. The feature is enabled by default at activation. Calling `setEnabled(false)` -allows all top-level deployments until the admin re-enables the preserved policy. +The feature is enabled by default at activation. Calling `setEnabled(false)` allows all top-level +deployments until the admin re-enables the preserved policy. -## Existing-Network Rollout +## Rollout Before Activation -1. Choose a future activation height with enough time for every validator and sequencer to upgrade. +1. Confirm the existing `deployAllowlistActivationHeight` is still in the future and leaves enough + time for every validator and sequencer to upgrade. 2. Set `deployAllowlistAdmin` to an existing `AdminProxy`, multisig, or governance contract. Do not use a disposable EOA for production authority. 3. Keep `deployAllowlist` equal to the policy that should be active at the transition. @@ -54,8 +55,9 @@ to fail open deployment while preserving membership for later recovery. ## Checklist -- [ ] All validators use the same admin, baseline, and activation heights -- [ ] Dynamic activation does not precede non-empty static-list activation +- [ ] All validators use the same admin, baseline, and activation height +- [ ] The configured activation height is still in the future +- [ ] The activation height leaves enough time for every validator to upgrade - [ ] The admin contract and its recovery process are tested - [ ] Every validating node upgrades before the activation block - [ ] Read calls at `F102` are verified at activation diff --git a/docs/adr/ADR-0005-dynamic-deployment-permissions.md b/docs/adr/ADR-0005-dynamic-deployment-permissions.md index f1c5ea5..fca063e 100644 --- a/docs/adr/ADR-0005-dynamic-deployment-permissions.md +++ b/docs/adr/ADR-0005-dynamic-deployment-permissions.md @@ -49,13 +49,13 @@ become valid. Execution must remain authoritative. ## Decision We install `IDeployPermissions` at `0x000000000000000000000000000000000000F102` -when a non-zero `deployAllowlistAdmin` is configured and the independent -`deployAllowlistPrecompileActivationHeight` is reached. The activation defaults to block zero. +when a non-zero `deployAllowlistAdmin` is configured and `deployAllowlistActivationHeight` is +reached. The activation defaults to block zero. -Before dynamic activation, the existing static policy applies. At activation, the genesis list is -the baseline. An unset enabled flag means enabled, avoiding a bootstrap transaction. A stored -disabled flag makes top-level deployment fail open while leaving the precompile callable. Re-enabling -clears that flag and restores the preserved policy. +Before activation, existing pre-activation behavior applies. At activation, the genesis list is the +baseline. An unset enabled flag means enabled, avoiding a bootstrap transaction. A stored disabled +flag makes top-level deployment fail open while leaving the precompile callable. Re-enabling clears +that flag and restores the preserved policy. Membership uses domain-separated hashed storage keys and tri-state address entries: unset falls back to the genesis baseline, allowed adds a non-baseline member, and denied removes a baseline member. @@ -79,8 +79,9 @@ custom RPC is added. ### Backwards Compatibility Chains without a non-zero admin use the exact legacy static behavior. Existing chains enabling the -feature must coordinate a future activation height after all validating nodes upgrade. When the -baseline is non-empty, dynamic activation must not precede static-list activation. +feature can do so only while their configured `deployAllowlistActivationHeight` is still in the +future. A chain whose static activation already passed needs a separate coordinated consensus +upgrade mechanism; v1 intentionally has no second activation field. ### Positive diff --git a/docs/guide/permissioned-evm.md b/docs/guide/permissioned-evm.md index 26c6307..43b8f9a 100644 --- a/docs/guide/permissioned-evm.md +++ b/docs/guide/permissioned-evm.md @@ -47,16 +47,14 @@ Set `deployAllowlistAdmin` to enable the state-backed precompile at "deployAllowlist": [ "0xInitialDeployerAddress" ], - "deployAllowlistActivationHeight": 0, - "deployAllowlistAdmin": "0xAdminProxyOrGovernanceAddress", - "deployAllowlistPrecompileActivationHeight": 20000000 + "deployAllowlistActivationHeight": 20000000, + "deployAllowlistAdmin": "0xAdminProxyOrGovernanceAddress" } ``` -`deployAllowlistPrecompileActivationHeight` defaults to `0` when a non-zero admin is set. Before -that block, static behavior is unchanged. At and after that block, the genesis list is the dynamic -baseline and state-backed enforcement is enabled by default. If the baseline is non-empty, dynamic -activation must be at or after `deployAllowlistActivationHeight`. +`deployAllowlistActivationHeight` defaults to `0` when omitted. Before that block, deployment +behavior is unchanged. At and after that block, the genesis list is the dynamic baseline and +state-backed enforcement is enabled by default. A configured empty baseline is intentionally different from legacy mode: it denies every top-level deployment while enforcement is enabled. Setting the admin to zero is the same as omitting it and @@ -104,8 +102,10 @@ emits no native events in v1. transaction rollback and chain-reorganization semantics. - For production, point the fixed admin at an `AdminProxy`, multisig, or governance contract. Admin rotation should happen behind that contract; changing the chainspec admin requires a hard fork. -- Existing networks must use a coordinated future `deployAllowlistPrecompileActivationHeight` and - upgrade all validating nodes before it. +- Existing networks can opt in only if their configured `deployAllowlistActivationHeight` is still + in the future. Networks whose static activation has already passed need a separate coordinated + consensus upgrade mechanism; this version intentionally does not expose a second activation + height. References: diff --git a/etc/ev-reth-genesis.json b/etc/ev-reth-genesis.json index c172034..7dcb0a5 100644 --- a/etc/ev-reth-genesis.json +++ b/etc/ev-reth-genesis.json @@ -32,8 +32,7 @@ "0x000000000000000000000000000000000000Ad00" ], "deployAllowlistActivationHeight": 0, - "deployAllowlistAdmin": "0x000000000000000000000000000000000000Ad00", - "deployAllowlistPrecompileActivationHeight": 0 + "deployAllowlistAdmin": "0x000000000000000000000000000000000000Ad00" } }, "difficulty": "0x1", From 489db830eecb7d131365138d3208cc7952ac1485 Mon Sep 17 00:00:00 2001 From: tac0turtle Date: Tue, 25 Aug 2026 17:02:57 +0200 Subject: [PATCH 3/5] refactor: simplify deploy permission admin naming --- crates/ev-revm/src/deploy.rs | 14 +++++++------- crates/ev-revm/src/factory.rs | 4 ++-- crates/node/src/config.rs | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/crates/ev-revm/src/deploy.rs b/crates/ev-revm/src/deploy.rs index d32716a..5693298 100644 --- a/crates/ev-revm/src/deploy.rs +++ b/crates/ev-revm/src/deploy.rs @@ -8,7 +8,7 @@ use std::sync::Arc; pub struct DeployAllowlistSettings { allowlist: Arc<[Address]>, activation_height: u64, - dynamic_admin: Option
, + admin: Option
, } impl DeployAllowlistSettings { @@ -21,14 +21,14 @@ impl DeployAllowlistSettings { Self { allowlist: Arc::from(allowlist), activation_height, - dynamic_admin: None, + admin: None, } } /// Creates state-backed deployment settings with a fixed admin. pub fn new_dynamic(allowlist: Vec
, activation_height: u64, admin: Address) -> Self { let mut settings = Self::new(allowlist, activation_height); - settings.dynamic_admin = Some(admin); + settings.admin = Some(admin); settings } @@ -60,14 +60,14 @@ impl DeployAllowlistSettings { self.allowlist.binary_search(&caller).is_ok() } - /// Returns the configured dynamic-permissions admin, if dynamic mode is enabled. - pub const fn dynamic_admin(&self) -> Option
{ - self.dynamic_admin + /// Returns the configured permissions admin, if state-backed mode is enabled. + pub const fn admin(&self) -> Option
{ + self.admin } /// Returns whether this chain uses state-backed deployment permissions. pub const fn is_dynamic(&self) -> bool { - self.dynamic_admin.is_some() + self.admin.is_some() } /// Returns whether dynamic deployment permissions are active in this block. diff --git a/crates/ev-revm/src/factory.rs b/crates/ev-revm/src/factory.rs index 21f602f..27b4206 100644 --- a/crates/ev-revm/src/factory.rs +++ b/crates/ev-revm/src/factory.rs @@ -235,7 +235,7 @@ impl EvEvmFactory { let Some(settings) = self.deploy_allowlist.as_ref() else { return; }; - let Some(admin) = settings.dynamic_admin() else { + let Some(admin) = settings.admin() else { return; }; if block_number < U256::from(settings.activation_height()) { @@ -439,7 +439,7 @@ impl EvTxEvmFactory { let Some(settings) = self.deploy_allowlist.as_ref() else { return; }; - let Some(admin) = settings.dynamic_admin() else { + let Some(admin) = settings.admin() else { return; }; if block_number < U256::from(settings.activation_height()) { diff --git a/crates/node/src/config.rs b/crates/node/src/config.rs index 314f228..0f06f3b 100644 --- a/crates/node/src/config.rs +++ b/crates/node/src/config.rs @@ -193,7 +193,7 @@ impl EvolvePayloadBuilderConfig { .unwrap_or(DEFAULT_CONTRACT_SIZE_LIMIT) } - /// Returns the genesis baseline, activation height, and optional dynamic admin. + /// Returns the genesis baseline, activation height, and optional admin. pub fn deploy_allowlist_settings(&self) -> Option { if self.deploy_allowlist.is_empty() && self.deploy_allowlist_admin.is_none() { None @@ -576,7 +576,7 @@ mod tests { } #[test] - fn test_zero_dynamic_admin_preserves_legacy_mode() { + fn test_zero_admin_preserves_legacy_mode() { let allowed = address!("00000000000000000000000000000000000000aa"); let extras = json!({ "deployAllowlist": [allowed], From e4f77d13a26e83c27c7683f57ac22fb432082b80 Mon Sep 17 00:00:00 2001 From: tac0turtle Date: Tue, 25 Aug 2026 22:09:35 +0200 Subject: [PATCH 4/5] fix: deny empty dynamic deploy baselines Empty allowlists in static mode still disable gating. Dynamic mode now denies when the genesis baseline is empty, and admin-only chainspecs default activation height to 0. Pass the shared baseline Arc into the precompile instead of cloning a Vec. --- .../ev-precompiles/src/deploy_permissions.rs | 14 ++-- crates/ev-revm/src/deploy.rs | 67 ++++++++++++++----- crates/ev-revm/src/factory.rs | 4 +- crates/node/src/config.rs | 6 +- 4 files changed, 67 insertions(+), 24 deletions(-) diff --git a/crates/ev-precompiles/src/deploy_permissions.rs b/crates/ev-precompiles/src/deploy_permissions.rs index 0b6e9d1..95ab585 100644 --- a/crates/ev-precompiles/src/deploy_permissions.rs +++ b/crates/ev-precompiles/src/deploy_permissions.rs @@ -113,12 +113,18 @@ impl DeployPermissionsPrecompile { } /// Creates a precompile using the fixed admin and genesis baseline. - pub fn new(admin: Address, mut baseline: Vec
) -> Self { - baseline.sort_unstable(); - baseline.dedup(); + /// Sorted unique inputs are stored without copying. + pub fn new(admin: Address, baseline: impl Into>) -> Self { + let baseline = baseline.into(); + if baseline.windows(2).all(|pair| pair[0] < pair[1]) { + return Self { admin, baseline }; + } + let mut owned = Vec::from(baseline.as_ref()); + owned.sort_unstable(); + owned.dedup(); Self { admin, - baseline: Arc::from(baseline), + baseline: Arc::from(owned), } } diff --git a/crates/ev-revm/src/deploy.rs b/crates/ev-revm/src/deploy.rs index 5693298..f3e8878 100644 --- a/crates/ev-revm/src/deploy.rs +++ b/crates/ev-revm/src/deploy.rs @@ -12,24 +12,30 @@ pub struct DeployAllowlistSettings { } impl DeployAllowlistSettings { - /// Creates a new deploy allowlist configuration. - /// An empty allowlist disables gating and allows all callers. - pub fn new(allowlist: Vec
, activation_height: u64) -> Self { + fn from_parts(allowlist: Vec
, activation_height: u64, admin: Option
) -> Self { let mut allowlist = allowlist; allowlist.sort_unstable(); allowlist.dedup(); Self { allowlist: Arc::from(allowlist), activation_height, - admin: None, + admin, } } + /// Creates a static deploy allowlist configuration. + /// An empty allowlist disables gating and allows all callers. + /// + /// For state-backed mode use [`Self::new_dynamic`], where an empty baseline + /// denies all callers until the admin adds members. + pub fn new(allowlist: Vec
, activation_height: u64) -> Self { + Self::from_parts(allowlist, activation_height, None) + } + /// Creates state-backed deployment settings with a fixed admin. + /// An empty baseline denies all callers; it does not disable gating. pub fn new_dynamic(allowlist: Vec
, activation_height: u64, admin: Address) -> Self { - let mut settings = Self::new(allowlist, activation_height); - settings.admin = Some(admin); - settings + Self::from_parts(allowlist, activation_height, Some(admin)) } /// Returns the activation height for deploy allowlist enforcement. @@ -37,9 +43,9 @@ impl DeployAllowlistSettings { self.activation_height } - /// Returns the allowlisted caller addresses. - pub fn allowlist(&self) -> &[Address] { - &self.allowlist + /// Returns the genesis baseline as a shared slice. + pub fn allowlist(&self) -> Arc<[Address]> { + Arc::clone(&self.allowlist) } /// Returns true if the allowlist is active at the given block number. @@ -47,12 +53,16 @@ impl DeployAllowlistSettings { block_number >= self.activation_height } - /// Returns true if the caller is in the allowlist. + /// Returns true if the caller is allowed by the configured genesis policy. + /// + /// Static mode (`admin` is `None`): an empty allowlist disables gating. + /// Dynamic mode (`admin` is `Some`): empty baseline denies, same as + /// [`Self::is_baseline_member`]. pub fn is_allowed(&self, caller: Address) -> bool { - if self.allowlist.is_empty() { + if !self.is_dynamic() && self.allowlist.is_empty() { return true; } - self.allowlist.binary_search(&caller).is_ok() + self.is_baseline_member(caller) } /// Returns whether the caller belongs to the genesis baseline. @@ -85,9 +95,11 @@ pub enum DeployCheckError { // Intentionally no envelope discriminator here to keep dependencies light. -/// Enforces the deploy allowlist policy. +/// Enforces the static deploy allowlist policy. /// /// If `is_top_level_create` is false or settings are None or not active yet, this is a no-op. +/// Dynamic settings are evaluated against the genesis baseline only (empty = deny); +/// runtime overrides must be applied by the execution handler. /// Otherwise returns `NotAllowed` if `caller` is not in the allowlist. pub fn check_deploy_allowed( settings: Option<&DeployAllowlistSettings>, @@ -104,7 +116,12 @@ pub fn check_deploy_allowed( if !settings.is_active(block_number) { return Ok(()); } - if settings.is_allowed(caller) { + let allowed = if settings.is_dynamic() { + settings.is_baseline_member(caller) + } else { + settings.is_allowed(caller) + }; + if allowed { Ok(()) } else { Err(DeployCheckError::NotAllowed) @@ -166,4 +183,24 @@ mod tests { let result = check_deploy_allowed(Some(&settings), caller, false, 0); assert!(result.is_ok()); } + + #[test] + fn dynamic_empty_baseline_denies_any_caller() { + let admin = address!("0x00000000000000000000000000000000000000aa"); + let caller = address!("0x00000000000000000000000000000000000000bb"); + let settings = DeployAllowlistSettings::new_dynamic(vec![], 0, admin); + assert!(!settings.is_allowed(caller)); + let result = check_deploy_allowed(Some(&settings), caller, true, 0); + assert_eq!(result, Err(DeployCheckError::NotAllowed)); + } + + #[test] + fn dynamic_baseline_member_passes_static_check() { + let admin = address!("0x00000000000000000000000000000000000000aa"); + let caller = address!("0x00000000000000000000000000000000000000bb"); + let settings = DeployAllowlistSettings::new_dynamic(vec![caller], 0, admin); + assert!(settings.is_allowed(caller)); + let result = check_deploy_allowed(Some(&settings), caller, true, 0); + assert!(result.is_ok()); + } } diff --git a/crates/ev-revm/src/factory.rs b/crates/ev-revm/src/factory.rs index 27b4206..6c6dce0 100644 --- a/crates/ev-revm/src/factory.rs +++ b/crates/ev-revm/src/factory.rs @@ -244,7 +244,7 @@ impl EvEvmFactory { let deploy_permissions = Arc::new(DeployPermissionsPrecompile::new( admin, - settings.allowlist().to_vec(), + settings.allowlist(), )); let id = DeployPermissionsPrecompile::id().clone(); precompiles.apply_precompile(&DEPLOY_PERMISSIONS_PRECOMPILE_ADDR, move |_| { @@ -448,7 +448,7 @@ impl EvTxEvmFactory { let deploy_permissions = Arc::new(DeployPermissionsPrecompile::new( admin, - settings.allowlist().to_vec(), + settings.allowlist(), )); let id = DeployPermissionsPrecompile::id().clone(); precompiles.apply_precompile(&DEPLOY_PERMISSIONS_PRECOMPILE_ADDR, move |_| { diff --git a/crates/node/src/config.rs b/crates/node/src/config.rs index 0f06f3b..6e4e3a7 100644 --- a/crates/node/src/config.rs +++ b/crates/node/src/config.rs @@ -160,8 +160,8 @@ impl EvolvePayloadBuilderConfig { 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() + if config.deploy_allowlist_activation_height.is_none() + && (config.deploy_allowlist_admin.is_some() || !config.deploy_allowlist.is_empty()) { config.deploy_allowlist_activation_height = Some(0); } @@ -563,7 +563,7 @@ mod tests { assert!(config.deploy_allowlist.is_empty()); assert_eq!(config.deploy_allowlist_admin, Some(admin)); - assert_eq!(config.deploy_allowlist_activation_height, None); + assert_eq!(config.deploy_allowlist_activation_height, Some(0)); assert_eq!( config.deploy_allowlist_settings(), Some(DeployAllowlistConfig { From a108f63bc6de41f0900b4566c09e6eb360b4c2af Mon Sep 17 00:00:00 2001 From: tac0turtle Date: Thu, 27 Aug 2026 10:31:34 +0200 Subject: [PATCH 5/5] test: cover legacy deploy permission compatibility --- crates/ev-revm/src/factory.rs | 35 +++++++++++++++++++++++++++++++++++ crates/node/src/config.rs | 1 + 2 files changed, 36 insertions(+) diff --git a/crates/ev-revm/src/factory.rs b/crates/ev-revm/src/factory.rs index 6c6dce0..d9d8832 100644 --- a/crates/ev-revm/src/factory.rs +++ b/crates/ev-revm/src/factory.rs @@ -1216,6 +1216,41 @@ mod tests { assert_eq!(disabled.present_value, U256::from(1)); } + #[test] + fn legacy_static_settings_never_install_deploy_permissions_precompile() { + let allowed = address!("0x00000000000000000000000000000000000000aa"); + let denied = address!("0x00000000000000000000000000000000000000bb"); + let settings = DeployAllowlistSettings::new(vec![allowed], 0); + let factory = EvEvmFactory::new( + alloy_evm::eth::EthEvmFactory::default(), + None, + None, + None, + Some(settings), + None, + ); + let mut evm = factory.create_evm( + permission_test_state(&[allowed, denied]), + permission_test_env(1), + ); + let disable = IDeployPermissions::setEnabledCall { enabled: false }.abi_encode(); + + let result = evm + .transact_raw(permission_call(allowed, 0, disable)) + .expect("F102 call executes as an ordinary account call in legacy mode"); + assert!(result + .state + .get(&DEPLOY_PERMISSIONS_PRECOMPILE_ADDR) + .and_then(|account| account.storage.get(&disabled_slot())) + .is_none()); + assert!(evm.transact_raw(deploy_tx(denied, 0)).is_err()); + assert!(evm + .transact_raw(deploy_tx(allowed, 0)) + .expect("legacy allowlisted deployment executes") + .result + .is_success()); + } + #[test] fn permission_reads_do_not_change_deployment_gas_accounting() { let admin = address!("0x00000000000000000000000000000000000000aa"); diff --git a/crates/node/src/config.rs b/crates/node/src/config.rs index 6e4e3a7..a194b6d 100644 --- a/crates/node/src/config.rs +++ b/crates/node/src/config.rs @@ -455,6 +455,7 @@ mod tests { assert_eq!(config.base_fee_redirect_activation_height, None); assert_eq!(config.mint_precompile_activation_height, None); assert_eq!(config.proposer_control_activation_height, None); + assert!(config.deploy_allowlist_settings().is_none()); } #[test]