diff --git a/Cargo.lock b/Cargo.lock index 5e48a45b..a39a7dc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3012,6 +3012,10 @@ dependencies = [ "eyre", "futures", "hex", + "jsonrpsee", + "jsonrpsee-core", + "jsonrpsee-proc-macros", + "jsonrpsee-types", "reth-basic-payload-builder", "reth-chainspec", "reth-cli", diff --git a/bin/ev-dev/src/main.rs b/bin/ev-dev/src/main.rs index 7ed4e653..f2ddc67f 100644 --- a/bin/ev-dev/src/main.rs +++ b/bin/ev-dev/src/main.rs @@ -15,7 +15,10 @@ use reth_ethereum_cli::Cli; use std::io::Write; use tracing::info; -use ev_node::{EvolveArgs, EvolveChainSpecParser, EvolveNode}; +use ev_node::{ + EvolveArgs, EvolveChainSpecParser, EvolveNode, EvolvePayloadBuilderConfig, + EvolveProposerApiImpl, EvolveProposerApiServer, +}; #[global_allocator] static ALLOC: reth_cli_util::allocator::Allocator = reth_cli_util::allocator::new_allocator(); @@ -209,7 +212,16 @@ fn main() { let evolve_cfg = EvolveConfig::default(); let evolve_txpool = EvolveTxpoolApiImpl::new(ctx.pool().clone(), evolve_cfg.max_txpool_bytes); + let proposer_cfg = + EvolvePayloadBuilderConfig::from_chain_spec(ctx.config().chain.as_ref())?; + let initial_next_proposer = proposer_cfg + .proposer_control_precompile_settings() + .map(|(_, _, initial_next_proposer)| initial_next_proposer) + .unwrap_or_default(); + let proposer_api = + EvolveProposerApiImpl::new(ctx.provider().clone(), initial_next_proposer); ctx.modules.merge_configured(evolve_txpool.into_rpc())?; + ctx.modules.merge_configured(proposer_api.into_rpc())?; Ok(()) }) .launch_with_debug_capabilities() diff --git a/bin/ev-reth/src/main.rs b/bin/ev-reth/src/main.rs index 79c35162..b2357d02 100644 --- a/bin/ev-reth/src/main.rs +++ b/bin/ev-reth/src/main.rs @@ -16,7 +16,10 @@ use tracing::info; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer}; use url::Url; -use ev_node::{log_startup, EvolveArgs, EvolveChainSpecParser, EvolveNode}; +use ev_node::{ + log_startup, EvolveArgs, EvolveChainSpecParser, EvolveNode, EvolvePayloadBuilderConfig, + EvolveProposerApiImpl, EvolveProposerApiServer, +}; #[global_allocator] static ALLOC: reth_cli_util::allocator::Allocator = reth_cli_util::allocator::new_allocator(); @@ -105,9 +108,18 @@ fn main() { let evolve_cfg = EvolveConfig::default(); let evolve_txpool = EvolveTxpoolApiImpl::new(ctx.pool().clone(), evolve_cfg.max_txpool_bytes); + let proposer_cfg = + EvolvePayloadBuilderConfig::from_chain_spec(ctx.config().chain.as_ref())?; + let initial_next_proposer = proposer_cfg + .proposer_control_precompile_settings() + .map(|(_, _, initial_next_proposer)| initial_next_proposer) + .unwrap_or_default(); + let proposer_api = + EvolveProposerApiImpl::new(ctx.provider().clone(), initial_next_proposer); // Merge into all enabled transports (HTTP / WS) ctx.modules.merge_configured(evolve_txpool.into_rpc())?; + ctx.modules.merge_configured(proposer_api.into_rpc())?; Ok(()) }) .launch() diff --git a/crates/ev-precompiles/README.md b/crates/ev-precompiles/README.md index bf8dbb8d..7abefd8f 100644 --- a/crates/ev-precompiles/README.md +++ b/crates/ev-precompiles/README.md @@ -4,7 +4,7 @@ Custom EVM precompiles for Evolve, providing native token supply management func ## Overview -This crate implements custom precompiled contracts that extend the EVM with Evolve-specific functionality. Currently, it provides a mint/burn precompile that allows controlled manipulation of native token supply. +This crate implements custom precompiled contracts that extend the EVM with Evolve-specific functionality. It provides a mint/burn precompile for controlled native token supply management and a proposer-control precompile for execution-owned ev-node proposer rotation. ## Mint Precompile @@ -190,3 +190,63 @@ cast send --rpc-url $RPC_URL --private-key $OPERATOR_KEY \ 0x000000000000000000000000000000000000f100 \ "mint(address,uint256)" 0xRECIPIENT 1000000000000000000 ``` + +## Proposer Control Precompile + +The proposer control precompile stores the ev-node proposer that should sign the next block. It is +used by the ev-node EVM execution adapter to populate ADR-023's `NextProposerAddress` from execution +state. + +### Address + +``` +0x000000000000000000000000000000000000f101 +``` + +### Interface + +```solidity +interface IProposerControl { + function nextProposer() external view returns (address); + function setNextProposer(address proposer) external; + function admin() external view returns (address); +} +``` + +### Configuration + +```json +{ + "config": { + "evolve": { + "proposerControlAdmin": "0x1234567890123456789012345678901234567890", + "proposerControlActivationHeight": 0, + "initialNextProposer": "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" + } + } +} +``` + +For existing chains, set `proposerControlActivationHeight` to a future block and upgrade all nodes +before that height. `initialNextProposer` should be the currently active proposer so reads are stable +before the first rotation transaction. + +### Operations + +The configured admin rotates the next proposer with: + +```bash +cast send --rpc-url $RPC_URL --private-key $ADMIN_KEY \ + 0x000000000000000000000000000000000000f101 \ + "setNextProposer(address)" 0xNEXT_PROPOSER +``` + +The stored proposer can be read through either the precompile ABI or ev-reth's convenience RPC: + +```bash +cast call --rpc-url $RPC_URL \ + 0x000000000000000000000000000000000000f101 \ + "nextProposer()(address)" + +cast rpc --rpc-url $RPC_URL evolve_getNextProposer latest +``` diff --git a/crates/ev-precompiles/src/lib.rs b/crates/ev-precompiles/src/lib.rs index 7e5e8b22..1d71c3d5 100644 --- a/crates/ev-precompiles/src/lib.rs +++ b/crates/ev-precompiles/src/lib.rs @@ -1 +1,2 @@ pub mod mint; +pub mod proposer; diff --git a/crates/ev-precompiles/src/proposer.rs b/crates/ev-precompiles/src/proposer.rs new file mode 100644 index 00000000..3537d2e3 --- /dev/null +++ b/crates/ev-precompiles/src/proposer.rs @@ -0,0 +1,428 @@ +// Proposer control 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, Address, Bytes, U256}; +use revm::{ + bytecode::Bytecode, + precompile::{PrecompileHalt, PrecompileOutput}, +}; +use std::sync::OnceLock; + +sol! { + interface IProposerControl { + function nextProposer() external view returns (address); + function setNextProposer(address proposer) external; + function admin() external view returns (address); + } +} + +pub const PROPOSER_CONTROL_PRECOMPILE_ADDR: Address = + address!("0x000000000000000000000000000000000000F101"); + +const NEXT_PROPOSER_SLOT: U256 = U256::ZERO; + +/// A custom precompile that stores the next ev-node proposer in execution state. +#[derive(Clone, Debug, Default)] +pub struct ProposerControlPrecompile { + admin: Address, + initial_next_proposer: Address, +} + +#[derive(Debug)] +enum ProposerControlPrecompileError { + Fatal(PrecompileError), + Halt(PrecompileHalt), +} + +type ProposerControlPrecompileResult = Result; + +impl ProposerControlPrecompileError { + fn fatal(err: EvmInternalsError) -> Self { + Self::Fatal(PrecompileError::Fatal(err.to_string())) + } + + const fn halt_static(reason: &'static str) -> Self { + Self::Halt(PrecompileHalt::other_static(reason)) + } +} + +impl ProposerControlPrecompile { + /// Use a lazily-initialized static for the ID since `custom` is not const. + pub fn id() -> &'static PrecompileId { + static ID: OnceLock = OnceLock::new(); + ID.get_or_init(|| PrecompileId::custom("proposer_control")) + } + + fn bytecode() -> &'static Bytecode { + static BYTECODE: OnceLock = OnceLock::new(); + BYTECODE.get_or_init(|| Bytecode::new_raw(Bytes::from_static(&[0xFE]))) + } + + pub const fn new(admin: Address, initial_next_proposer: Address) -> Self { + Self { + admin, + initial_next_proposer, + } + } + + fn map_internals_error(err: EvmInternalsError) -> ProposerControlPrecompileError { + ProposerControlPrecompileError::fatal(err) + } + + fn ensure_account_created( + internals: &mut EvmInternals<'_>, + ) -> ProposerControlPrecompileResult<()> { + let account = internals + .load_account(PROPOSER_CONTROL_PRECOMPILE_ADDR) + .map_err(Self::map_internals_error)?; + + if account.is_loaded_as_not_existing() { + // Keep the account non-empty so storage written by the precompile is not pruned. + internals + .set_code(PROPOSER_CONTROL_PRECOMPILE_ADDR, Self::bytecode().clone()) + .map_err(Self::map_internals_error)?; + internals + .load_account_mut(PROPOSER_CONTROL_PRECOMPILE_ADDR) + .map_err(Self::map_internals_error)? + .set_nonce(1); + internals + .touch_account(PROPOSER_CONTROL_PRECOMPILE_ADDR) + .map_err(Self::map_internals_error)?; + } + + Ok(()) + } + + fn ensure_admin(&self, caller: Address) -> ProposerControlPrecompileResult<()> { + if caller == self.admin { + Ok(()) + } else { + Err(ProposerControlPrecompileError::halt_static( + "unauthorized caller", + )) + } + } + + fn next_proposer( + &self, + internals: &mut EvmInternals<'_>, + ) -> ProposerControlPrecompileResult
{ + let value = internals + .sload(PROPOSER_CONTROL_PRECOMPILE_ADDR, NEXT_PROPOSER_SLOT) + .map_err(Self::map_internals_error)?; + let raw_value = *value; + if raw_value.is_zero() { + return Ok(self.initial_next_proposer); + } + Ok(Address::from_word(raw_value.into())) + } + + fn set_next_proposer( + internals: &mut EvmInternals<'_>, + proposer: Address, + ) -> ProposerControlPrecompileResult<()> { + if proposer.is_zero() { + return Err(ProposerControlPrecompileError::halt_static( + "proposer cannot be zero", + )); + } + + Self::ensure_account_created(internals)?; + let value = U256::from_be_bytes(proposer.into_word().into()); + internals + .sstore(PROPOSER_CONTROL_PRECOMPILE_ADDR, NEXT_PROPOSER_SLOT, value) + .map_err(Self::map_internals_error)?; + internals + .touch_account(PROPOSER_CONTROL_PRECOMPILE_ADDR) + .map_err(Self::map_internals_error)?; + Ok(()) + } +} + +impl Precompile for ProposerControlPrecompile { + 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 IProposerControl::IProposerControlCalls::abi_decode(input.data) { + Ok(v) => v, + Err(e) => { + return Ok(PrecompileOutput::halt( + PrecompileHalt::other(e.to_string()), + reservoir, + )) + } + }; + let internals = input.internals_mut(); + + let result = (|| -> ProposerControlPrecompileResult { + match decoded { + IProposerControl::IProposerControlCalls::nextProposer(_) => { + let proposer = self.next_proposer(internals)?; + Ok(proposer.abi_encode().into()) + } + IProposerControl::IProposerControlCalls::setNextProposer(call) => { + if is_static { + return Err(ProposerControlPrecompileError::halt_static( + "state change during static call", + )); + } + self.ensure_admin(caller)?; + Self::set_next_proposer(internals, call.proposer)?; + Ok(Bytes::new()) + } + IProposerControl::IProposerControlCalls::admin(_) => { + Ok(self.admin.abi_encode().into()) + } + } + })(); + + match result { + Ok(bytes) => Ok(PrecompileOutput::new(0, bytes, reservoir)), + Err(ProposerControlPrecompileError::Halt(reason)) => { + Ok(PrecompileOutput::halt(reason, reservoir)) + } + Err(ProposerControlPrecompileError::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, + }, + 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); + let block_env = BlockEnv::default(); + let cfg_env = CfgEnv::default(); + let tx_env = TxEnv::default(); + (journal, block_env, cfg_env, tx_env) + } + + fn run_call<'a>( + journal: &'a mut TestJournal, + block_env: &'a BlockEnv, + cfg_env: &'a CfgEnv, + tx_env: &'a TxEnv, + precompile: &ProposerControlPrecompile, + caller: Address, + data: &'a [u8], + is_static: bool, + ) -> PrecompileResult { + let input = PrecompileInput { + data, + gas: GAS_LIMIT, + reservoir: 0, + caller, + value: U256::ZERO, + target_address: PROPOSER_CONTROL_PRECOMPILE_ADDR, + is_static, + bytecode_address: PROPOSER_CONTROL_PRECOMPILE_ADDR, + internals: EvmInternals::new(journal, block_env, cfg_env, tx_env), + }; + + precompile.call(input) + } + + fn output_bytes(result: PrecompileResult) -> Bytes { + match result { + Ok(output) if !output.is_halt() => output.bytes.clone(), + Ok(output) => panic!("expected successful output, got halt {output:?}"), + Err(err) => panic!("expected successful output, got fatal error {err:?}"), + } + } + + fn assert_halt_message(result: PrecompileResult, expected: &str) { + match result { + Ok(output) => { + assert!(output.is_halt(), "expected halt output, got {output:?}"); + match output.halt_reason() { + Some(PrecompileHalt::Other(msg)) => { + assert_eq!(msg.as_ref(), expected, "unexpected halt message") + } + other => panic!("expected custom halt reason, got {other:?}"), + } + } + Err(err) => panic!("expected halting precompile output, got fatal error {err:?}"), + } + } + + #[test] + fn returns_initial_next_proposer_when_storage_unset() { + let admin = address!("0x0000000000000000000000000000000000000aaa"); + let initial = address!("0x0000000000000000000000000000000000000bbb"); + let precompile = ProposerControlPrecompile::new(admin, initial); + let (mut journal, block_env, cfg_env, tx_env) = setup_context(); + + let data = IProposerControl::nextProposerCall {}.abi_encode(); + let bytes = output_bytes(run_call( + &mut journal, + &block_env, + &cfg_env, + &tx_env, + &precompile, + admin, + &data, + true, + )); + let decoded = Address::abi_decode(&bytes).expect("address output decodes"); + + assert_eq!(decoded, initial); + } + + #[test] + fn admin_can_set_next_proposer() { + let admin = address!("0x0000000000000000000000000000000000000aaa"); + let initial = address!("0x0000000000000000000000000000000000000bbb"); + let next = address!("0x0000000000000000000000000000000000000ccc"); + let precompile = ProposerControlPrecompile::new(admin, initial); + let (mut journal, block_env, cfg_env, tx_env) = setup_context(); + + let set_data = IProposerControl::setNextProposerCall { proposer: next }.abi_encode(); + let result = run_call( + &mut journal, + &block_env, + &cfg_env, + &tx_env, + &precompile, + admin, + &set_data, + false, + ); + output_bytes(result); + + let get_data = IProposerControl::nextProposerCall {}.abi_encode(); + let bytes = output_bytes(run_call( + &mut journal, + &block_env, + &cfg_env, + &tx_env, + &precompile, + admin, + &get_data, + true, + )); + let decoded = Address::abi_decode(&bytes).expect("address output decodes"); + + assert_eq!(decoded, next); + } + + #[test] + fn non_admin_cannot_set_next_proposer() { + let admin = address!("0x0000000000000000000000000000000000000aaa"); + let caller = address!("0x0000000000000000000000000000000000000bbb"); + let next = address!("0x0000000000000000000000000000000000000ccc"); + let precompile = ProposerControlPrecompile::new(admin, Address::ZERO); + let (mut journal, block_env, cfg_env, tx_env) = setup_context(); + + let data = IProposerControl::setNextProposerCall { proposer: next }.abi_encode(); + let result = run_call( + &mut journal, + &block_env, + &cfg_env, + &tx_env, + &precompile, + caller, + &data, + false, + ); + + assert_halt_message(result, "unauthorized caller"); + } + + #[test] + fn rejects_zero_next_proposer() { + let admin = address!("0x0000000000000000000000000000000000000aaa"); + let precompile = ProposerControlPrecompile::new(admin, Address::ZERO); + let (mut journal, block_env, cfg_env, tx_env) = setup_context(); + + let data = IProposerControl::setNextProposerCall { + proposer: Address::ZERO, + } + .abi_encode(); + let result = run_call( + &mut journal, + &block_env, + &cfg_env, + &tx_env, + &precompile, + admin, + &data, + false, + ); + + assert_halt_message(result, "proposer cannot be zero"); + } + + #[test] + fn rejects_state_change_in_static_call() { + let admin = address!("0x0000000000000000000000000000000000000aaa"); + let next = address!("0x0000000000000000000000000000000000000bbb"); + let precompile = ProposerControlPrecompile::new(admin, Address::ZERO); + let (mut journal, block_env, cfg_env, tx_env) = setup_context(); + + let data = IProposerControl::setNextProposerCall { proposer: next }.abi_encode(); + let result = run_call( + &mut journal, + &block_env, + &cfg_env, + &tx_env, + &precompile, + admin, + &data, + true, + ); + + assert_halt_message(result, "state change during static call"); + } + + #[test] + fn admin_getter_returns_configured_admin() { + let admin = address!("0x0000000000000000000000000000000000000aaa"); + let precompile = ProposerControlPrecompile::new(admin, Address::ZERO); + let (mut journal, block_env, cfg_env, tx_env) = setup_context(); + + let data = IProposerControl::adminCall {}.abi_encode(); + let bytes = output_bytes(run_call( + &mut journal, + &block_env, + &cfg_env, + &tx_env, + &precompile, + Address::ZERO, + &data, + true, + )); + let decoded = Address::abi_decode(&bytes).expect("address output decodes"); + + assert_eq!(decoded, admin); + } +} diff --git a/crates/ev-revm/src/factory.rs b/crates/ev-revm/src/factory.rs index 82a9f5df..927d1b88 100644 --- a/crates/ev-revm/src/factory.rs +++ b/crates/ev-revm/src/factory.rs @@ -9,7 +9,10 @@ use alloy_evm::{ Database, EvmEnv, EvmFactory, }; use alloy_primitives::{Address, U256}; -use ev_precompiles::mint::{MintPrecompile, MINT_PRECOMPILE_ADDR}; +use ev_precompiles::{ + mint::{MintPrecompile, MINT_PRECOMPILE_ADDR}, + proposer::{ProposerControlPrecompile, PROPOSER_CONTROL_PRECOMPILE_ADDR}, +}; use reth_evm_ethereum::EthEvmConfig; use reth_revm::{ inspector::NoOpInspector, @@ -78,6 +81,41 @@ impl MintPrecompileSettings { } } +/// Settings for enabling the proposer control precompile at a specific block height. +#[derive(Debug, Clone, Copy)] +pub struct ProposerControlPrecompileSettings { + admin: Address, + activation_height: u64, + initial_next_proposer: Address, +} + +impl ProposerControlPrecompileSettings { + /// Creates a new settings object. + pub const fn new( + admin: Address, + activation_height: u64, + initial_next_proposer: Address, + ) -> Self { + Self { + admin, + activation_height, + initial_next_proposer, + } + } + + const fn activation_height(&self) -> u64 { + self.activation_height + } + + const fn admin(&self) -> Address { + self.admin + } + + const fn initial_next_proposer(&self) -> Address { + self.initial_next_proposer + } +} + /// Settings for custom contract size limit with activation height. #[derive(Debug, Clone, Copy)] pub struct ContractSizeLimitSettings { @@ -109,6 +147,7 @@ pub struct EvEvmFactory { inner: F, redirect: Option, mint_precompile: Option, + proposer_control_precompile: Option, deploy_allowlist: Option, contract_size_limit: Option, } @@ -119,6 +158,7 @@ impl EvEvmFactory { inner: F, redirect: Option, mint_precompile: Option, + proposer_control_precompile: Option, deploy_allowlist: Option, contract_size_limit: Option, ) -> Self { @@ -126,6 +166,7 @@ impl EvEvmFactory { inner, redirect, mint_precompile, + proposer_control_precompile, deploy_allowlist, contract_size_limit, } @@ -161,6 +202,33 @@ impl EvEvmFactory { }); } + fn install_proposer_control_precompile( + &self, + precompiles: &mut PrecompilesMap, + block_number: U256, + ) { + let Some(settings) = self.proposer_control_precompile else { + return; + }; + if block_number < U256::from(settings.activation_height()) { + return; + } + + let proposer_control = Arc::new(ProposerControlPrecompile::new( + settings.admin(), + settings.initial_next_proposer(), + )); + let id = ProposerControlPrecompile::id().clone(); + + precompiles.apply_precompile(&PROPOSER_CONTROL_PRECOMPILE_ADDR, move |_| { + let proposer_control_for_call = Arc::clone(&proposer_control); + let id_for_call = id; + Some(DynPrecompile::new_stateful(id_for_call, move |input| { + proposer_control_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()) { @@ -204,6 +272,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); } evm } @@ -229,6 +298,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); } evm } @@ -239,6 +309,7 @@ impl EvmFactory for EvEvmFactory { pub struct EvTxEvmFactory { redirect: Option, mint_precompile: Option, + proposer_control_precompile: Option, deploy_allowlist: Option, contract_size_limit: Option, } @@ -262,12 +333,14 @@ impl EvTxEvmFactory { pub const fn new( redirect: Option, mint_precompile: Option, + proposer_control_precompile: Option, deploy_allowlist: Option, contract_size_limit: Option, ) -> Self { Self { redirect, mint_precompile, + proposer_control_precompile, deploy_allowlist, contract_size_limit, } @@ -303,6 +376,33 @@ impl EvTxEvmFactory { }); } + fn install_proposer_control_precompile( + &self, + precompiles: &mut PrecompilesMap, + block_number: U256, + ) { + let Some(settings) = self.proposer_control_precompile else { + return; + }; + if block_number < U256::from(settings.activation_height()) { + return; + } + + let proposer_control = Arc::new(ProposerControlPrecompile::new( + settings.admin(), + settings.initial_next_proposer(), + )); + let id = ProposerControlPrecompile::id().clone(); + + precompiles.apply_precompile(&PROPOSER_CONTROL_PRECOMPILE_ADDR, move |_| { + let proposer_control_for_call = Arc::clone(&proposer_control); + let id_for_call = id; + Some(DynPrecompile::new_stateful(id_for_call, move |input| { + proposer_control_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()) { @@ -377,6 +477,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); } evm } @@ -401,6 +502,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); } evm } @@ -411,6 +513,7 @@ pub fn with_ev_handler( config: EthEvmConfig, redirect: Option, mint_precompile: Option, + proposer_control_precompile: Option, deploy_allowlist: Option, contract_size_limit: Option, ) -> EthEvmConfig> { @@ -422,6 +525,7 @@ pub fn with_ev_handler( *executor_factory.evm_factory(), redirect, mint_precompile, + proposer_control_precompile, deploy_allowlist, contract_size_limit, ); @@ -444,6 +548,7 @@ 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 reth_revm::{ revm::{ bytecode::Bytecode as RevmBytecode, @@ -459,6 +564,10 @@ mod tests { contract MintAdminProxy { function mint(address to, uint256 amount); } + + interface IProposerControl { + function setNextProposer(address proposer) external; + } } const ADMIN_PROXY_RUNTIME: [u8; 42] = alloy_primitives::hex!( @@ -511,6 +620,7 @@ mod tests { None, None, None, + None, ) .create_evm(state, evm_env.clone()); @@ -606,6 +716,7 @@ mod tests { Some(MintPrecompileSettings::new(contract, 0)), None, None, + None, ) .create_evm(state, evm_env); @@ -648,6 +759,7 @@ mod tests { None, None, None, + None, ); let mut before_env: alloy_evm::EvmEnv = EvmEnv::default(); @@ -718,6 +830,7 @@ mod tests { Some(MintPrecompileSettings::new(contract, 3)), None, None, + None, ); let tx_env = || crate::factory::TxEnv { @@ -766,4 +879,98 @@ mod tests { .expect("mint precompile should mint after activation"); assert_eq!(mintee_account.info.balance, amount); } + + #[test] + fn proposer_control_precompile_respects_activation_height() { + let admin = address!("0x0000000000000000000000000000000000000aaa"); + let next = address!("0x0000000000000000000000000000000000000bbb"); + + let build_state = || { + let mut state = State::builder() + .with_database(CacheDB::::default()) + .with_bundle_update() + .build(); + + state.insert_account( + admin, + AccountInfo { + balance: U256::from(10_000_000_000u64), + nonce: 0, + code_hash: KECCAK_EMPTY, + code: None, + account_id: None, + }, + ); + + state + }; + + let factory = EvEvmFactory::new( + alloy_evm::eth::EthEvmFactory::default(), + None, + None, + Some(ProposerControlPrecompileSettings::new( + admin, + 3, + Address::ZERO, + )), + None, + None, + ); + + let tx_env = || crate::factory::TxEnv { + caller: admin, + kind: TxKind::Call(PROPOSER_CONTROL_PRECOMPILE_ADDR), + gas_limit: 500_000, + gas_price: 1, + value: U256::ZERO, + data: IProposerControl::setNextProposerCall { proposer: next } + .abi_encode() + .into(), + ..Default::default() + }; + + let mut before_env: alloy_evm::EvmEnv = EvmEnv::default(); + before_env.cfg_env.chain_id = 1; + before_env.cfg_env.spec = SpecId::CANCUN; + before_env.block_env.number = U256::from(2); + before_env.block_env.basefee = 1; + before_env.block_env.gas_limit = 30_000_000; + + let mut evm_before = factory.create_evm(build_state(), before_env); + let result_before = evm_before + .transact_raw(tx_env()) + .expect("pre-activation call executes"); + let state: EvmState = result_before.state; + if let Some(account) = state.get(&PROPOSER_CONTROL_PRECOMPILE_ADDR) { + assert!( + account.storage.get(&U256::ZERO).is_none(), + "precompile must not write storage before activation height" + ); + } + + let mut after_env: alloy_evm::EvmEnv = EvmEnv::default(); + after_env.cfg_env.chain_id = 1; + after_env.cfg_env.spec = SpecId::CANCUN; + after_env.block_env.number = U256::from(3); + after_env.block_env.basefee = 1; + after_env.block_env.gas_limit = 30_000_000; + + let mut evm_after = factory.create_evm(build_state(), after_env); + let result_after = evm_after + .transact_raw(tx_env()) + .expect("post-activation call executes"); + let state: EvmState = result_after.state; + let proposer_account = state + .get(&PROPOSER_CONTROL_PRECOMPILE_ADDR) + .expect("proposer control precompile account should be touched"); + let slot = proposer_account + .storage + .get(&U256::ZERO) + .expect("next proposer slot should be written"); + assert_eq!( + slot.present_value, + U256::from_be_bytes(next.into_word().into()) + ); + } } diff --git a/crates/ev-revm/src/lib.rs b/crates/ev-revm/src/lib.rs index f0b120d0..c72dc420 100644 --- a/crates/ev-revm/src/lib.rs +++ b/crates/ev-revm/src/lib.rs @@ -15,10 +15,11 @@ 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 evm::{DefaultEvEvm, EvEvm}; pub use factory::{ with_ev_handler, BaseFeeRedirectSettings, ContractSizeLimitSettings, EvEvmFactory, - EvTxEvmFactory, MintPrecompileSettings, + EvTxEvmFactory, MintPrecompileSettings, ProposerControlPrecompileSettings, }; pub use handler::EvHandler; pub use tx_env::EvTxEnv; diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index 147dc502..10726cd1 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -76,6 +76,10 @@ thiserror.workspace = true async-trait.workspace = true futures.workspace = true clap.workspace = true +jsonrpsee = { workspace = true, features = ["server", "macros"] } +jsonrpsee-core.workspace = true +jsonrpsee-proc-macros.workspace = true +jsonrpsee-types.workspace = true [dev-dependencies] # Test dependencies diff --git a/crates/node/src/config.rs b/crates/node/src/config.rs index 1de4a5fa..78b97d19 100644 --- a/crates/node/src/config.rs +++ b/crates/node/src/config.rs @@ -18,6 +18,12 @@ struct ChainspecEvolveConfig { pub mint_admin: Option
, #[serde(default, rename = "mintPrecompileActivationHeight")] pub mint_precompile_activation_height: Option, + #[serde(default, rename = "proposerControlAdmin")] + pub proposer_control_admin: Option
, + #[serde(default, rename = "proposerControlActivationHeight")] + pub proposer_control_activation_height: Option, + #[serde(default, rename = "initialNextProposer")] + pub initial_next_proposer: Option
, /// Maximum contract code size in bytes. Defaults to 24KB (EIP-170) if not specified. #[serde(default, rename = "contractSizeLimit")] pub contract_size_limit: Option, @@ -47,6 +53,15 @@ pub struct EvolvePayloadBuilderConfig { /// Optional activation height for mint precompile; defaults to 0 when admin set. #[serde(default)] pub mint_precompile_activation_height: Option, + /// Optional proposer control precompile admin address sourced from the chainspec. + #[serde(default)] + pub proposer_control_admin: Option
, + /// Optional activation height for proposer control precompile; defaults to 0 when admin set. + #[serde(default)] + pub proposer_control_activation_height: Option, + /// Optional initial next proposer returned until the precompile storage is updated. + #[serde(default)] + pub initial_next_proposer: Option
, /// Maximum contract code size in bytes. Defaults to 24KB (EIP-170). #[serde(default)] pub contract_size_limit: Option, @@ -69,6 +84,9 @@ impl EvolvePayloadBuilderConfig { mint_admin: None, base_fee_redirect_activation_height: None, mint_precompile_activation_height: None, + proposer_control_admin: None, + proposer_control_activation_height: None, + initial_next_proposer: None, contract_size_limit: None, contract_size_limit_activation_height: None, deploy_allowlist: Vec::new(), @@ -93,6 +111,21 @@ impl EvolvePayloadBuilderConfig { .mint_admin .and_then(|addr| if addr.is_zero() { None } else { Some(addr) }); config.mint_precompile_activation_height = extras.mint_precompile_activation_height; + config.proposer_control_admin = extras.proposer_control_admin.and_then(|addr| { + if addr.is_zero() { + None + } else { + Some(addr) + } + }); + config.proposer_control_activation_height = extras.proposer_control_activation_height; + config.initial_next_proposer = extras.initial_next_proposer.and_then(|addr| { + if addr.is_zero() { + None + } else { + Some(addr) + } + }); if config.base_fee_sink.is_some() && config.base_fee_redirect_activation_height.is_none() @@ -104,6 +137,12 @@ impl EvolvePayloadBuilderConfig { config.mint_precompile_activation_height = Some(0); } + if config.proposer_control_admin.is_some() + && config.proposer_control_activation_height.is_none() + { + config.proposer_control_activation_height = Some(0); + } + config.contract_size_limit = extras.contract_size_limit; config.contract_size_limit_activation_height = extras.contract_size_limit_activation_height; @@ -202,6 +241,15 @@ impl EvolvePayloadBuilderConfig { }) } + /// Returns the proposer control precompile admin, activation height, and initial proposer. + pub fn proposer_control_precompile_settings(&self) -> Option<(Address, u64, Address)> { + self.proposer_control_admin.map(|admin| { + let activation = self.proposer_control_activation_height.unwrap_or(0); + let initial_next_proposer = self.initial_next_proposer.unwrap_or(Address::ZERO); + (admin, activation, initial_next_proposer) + }) + } + /// Returns the sink if the redirect is active for the provided block number. pub fn base_fee_sink_for_block(&self, block_number: u64) -> Option
{ self.base_fee_redirect_settings() @@ -276,8 +324,10 @@ mod tests { assert_eq!(config.base_fee_sink, None); assert_eq!(config.mint_admin, Some(mint_admin)); + assert_eq!(config.proposer_control_admin, None); assert_eq!(config.base_fee_redirect_activation_height, None); assert_eq!(config.mint_precompile_activation_height, Some(0)); + assert_eq!(config.proposer_control_activation_height, None); } #[test] @@ -300,6 +350,63 @@ mod tests { assert_eq!(config.mint_precompile_activation_height, Some(64)); } + #[test] + fn test_proposer_control_defaults_activation_to_zero() { + let admin = address!("00000000000000000000000000000000000000cc"); + let initial_next_proposer = address!("00000000000000000000000000000000000000dd"); + let extras = json!({ + "proposerControlAdmin": admin, + "initialNextProposer": initial_next_proposer + }); + + let chainspec = create_test_chainspec_with_extras(Some(extras)); + let config = EvolvePayloadBuilderConfig::from_chain_spec(&chainspec).unwrap(); + + assert_eq!(config.proposer_control_admin, Some(admin)); + assert_eq!(config.proposer_control_activation_height, Some(0)); + assert_eq!(config.initial_next_proposer, Some(initial_next_proposer)); + assert_eq!( + config.proposer_control_precompile_settings(), + Some((admin, 0, initial_next_proposer)) + ); + } + + #[test] + fn test_proposer_control_activation_override_and_zero_initial_ignored() { + let admin = address!("00000000000000000000000000000000000000cc"); + let extras = json!({ + "proposerControlAdmin": admin, + "proposerControlActivationHeight": 128, + "initialNextProposer": "0x0000000000000000000000000000000000000000" + }); + + let chainspec = create_test_chainspec_with_extras(Some(extras)); + let config = EvolvePayloadBuilderConfig::from_chain_spec(&chainspec).unwrap(); + + assert_eq!(config.proposer_control_admin, Some(admin)); + assert_eq!(config.proposer_control_activation_height, Some(128)); + assert_eq!(config.initial_next_proposer, None); + assert_eq!( + config.proposer_control_precompile_settings(), + Some((admin, 128, Address::ZERO)) + ); + } + + #[test] + fn test_proposer_control_admin_zero_disables() { + let extras = json!({ + "proposerControlAdmin": "0x0000000000000000000000000000000000000000", + "initialNextProposer": "0x00000000000000000000000000000000000000dd" + }); + + let chainspec = create_test_chainspec_with_extras(Some(extras)); + let config = EvolvePayloadBuilderConfig::from_chain_spec(&chainspec).unwrap(); + + assert_eq!(config.proposer_control_admin, None); + assert_eq!(config.proposer_control_activation_height, None); + assert!(config.proposer_control_precompile_settings().is_none()); + } + #[test] fn test_mint_admin_zero_disables() { let extras = json!({ @@ -333,8 +440,10 @@ mod tests { assert_eq!(config.base_fee_sink, None); assert_eq!(config.mint_admin, None); + assert_eq!(config.proposer_control_admin, None); 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); } #[test] @@ -371,8 +480,10 @@ mod tests { let config = EvolvePayloadBuilderConfig::default(); assert_eq!(config.base_fee_sink, None); assert_eq!(config.mint_admin, None); + assert_eq!(config.proposer_control_admin, None); 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.is_empty()); assert_eq!(config.deploy_allowlist_activation_height, None); } @@ -383,8 +494,10 @@ mod tests { let config = EvolvePayloadBuilderConfig::new(); assert_eq!(config.base_fee_sink, None); assert_eq!(config.mint_admin, None); + assert_eq!(config.proposer_control_admin, None); 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_eq!(config.contract_size_limit, None); assert!(config.deploy_allowlist.is_empty()); assert_eq!(config.deploy_allowlist_activation_height, None); @@ -399,8 +512,10 @@ mod tests { let config_with_sink = EvolvePayloadBuilderConfig { base_fee_sink: Some(address!("0000000000000000000000000000000000000001")), mint_admin: Some(address!("00000000000000000000000000000000000000aa")), + proposer_control_admin: Some(address!("00000000000000000000000000000000000000bb")), base_fee_redirect_activation_height: Some(0), mint_precompile_activation_height: Some(0), + proposer_control_activation_height: Some(0), ..Default::default() }; assert!(config_with_sink.validate().is_ok()); diff --git a/crates/node/src/executor.rs b/crates/node/src/executor.rs index 70dbd189..4c948e43 100644 --- a/crates/node/src/executor.rs +++ b/crates/node/src/executor.rs @@ -7,7 +7,7 @@ use alloy_primitives::U256; use alloy_rpc_types_engine::ExecutionData; use ev_revm::{ BaseFeeRedirect, BaseFeeRedirectSettings, ContractSizeLimitSettings, DeployAllowlistSettings, - EvTxEvmFactory, MintPrecompileSettings, + EvTxEvmFactory, MintPrecompileSettings, ProposerControlPrecompileSettings, }; use reth_chainspec::{ChainSpec, EthChainSpec}; use reth_errors::RethError; @@ -422,6 +422,19 @@ where .mint_precompile_settings() .map(|(admin, activation)| MintPrecompileSettings::new(admin, activation)); + let proposer_control_precompile = evolve_config.proposer_control_precompile_settings().map( + |(admin, activation, initial_next_proposer)| { + info!( + target = "ev-reth::executor", + admin = ?admin, + activation_height = activation, + initial_next_proposer = ?initial_next_proposer, + "Proposer control precompile enabled" + ); + ProposerControlPrecompileSettings::new(admin, activation, initial_next_proposer) + }, + ); + let contract_size_limit = evolve_config .contract_size_limit_settings() @@ -451,6 +464,7 @@ where let factory = EvTxEvmFactory::new( redirect, mint_precompile, + proposer_control_precompile, deploy_allowlist, contract_size_limit, ); diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs index 543d5196..8e5fd423 100644 --- a/crates/node/src/lib.rs +++ b/crates/node/src/lib.rs @@ -27,6 +27,8 @@ pub mod node; pub mod payload_service; /// Payload types for `EvPrimitives`. pub mod payload_types; +/// RPC accessors for proposer-control state. +pub mod proposer_rpc; /// RPC wiring for EvTxEnvelope support. pub mod rpc; /// Drop guard for recording `duration_ms` on tracing spans. @@ -50,4 +52,5 @@ pub use executor::{build_evm_config, EvolveEvmConfig, EvolveExecutorBuilder}; pub use node::{log_startup, EvolveEngineTypes, EvolveNode, EvolveNodeAddOns}; pub use payload_service::{EvolveEnginePayloadBuilder, EvolvePayloadBuilderBuilder}; pub use payload_types::EvBuiltPayload; +pub use proposer_rpc::{EvolveProposerApiImpl, EvolveProposerApiServer}; pub use validator::{EvolveEngineValidator, EvolveEngineValidatorBuilder}; diff --git a/crates/node/src/payload_service.rs b/crates/node/src/payload_service.rs index e58122e4..e39ad69c 100644 --- a/crates/node/src/payload_service.rs +++ b/crates/node/src/payload_service.rs @@ -107,6 +107,16 @@ where config.mint_precompile_activation_height = self.config.mint_precompile_activation_height; } + if self.config.proposer_control_admin.is_some() { + config.proposer_control_admin = self.config.proposer_control_admin; + } + if self.config.proposer_control_activation_height.is_some() { + config.proposer_control_activation_height = + self.config.proposer_control_activation_height; + } + if self.config.initial_next_proposer.is_some() { + config.initial_next_proposer = self.config.initial_next_proposer; + } config.validate()?; diff --git a/crates/node/src/proposer_rpc.rs b/crates/node/src/proposer_rpc.rs new file mode 100644 index 00000000..20344f4b --- /dev/null +++ b/crates/node/src/proposer_rpc.rs @@ -0,0 +1,70 @@ +//! RPC accessors for Evolve proposer control state. + +use alloy_eips::BlockNumberOrTag; +use alloy_primitives::{Address, B256}; +use async_trait::async_trait; +use jsonrpsee_core::RpcResult; +use jsonrpsee_proc_macros::rpc; +use jsonrpsee_types::ErrorObjectOwned; +use reth_storage_api::StateProviderFactory; + +const NEXT_PROPOSER_SLOT: B256 = B256::ZERO; +const INTERNAL_ERROR: i32 = -32603; + +/// Evolve proposer-control RPC API. +#[rpc(server, namespace = "evolve")] +pub trait EvolveProposerApi { + /// Returns the next proposer stored by the proposer-control precompile. + #[method(name = "getNextProposer")] + async fn get_next_proposer(&self, block: Option) -> RpcResult
; +} + +/// Implementation of the Evolve proposer-control RPC API. +#[derive(Debug, Clone)] +pub struct EvolveProposerApiImpl { + provider: Provider, + initial_next_proposer: Address, +} + +impl EvolveProposerApiImpl { + /// Creates a new proposer-control API. + pub const fn new(provider: Provider, initial_next_proposer: Address) -> Self { + Self { + provider, + initial_next_proposer, + } + } + + fn rpc_error(message: impl Into) -> ErrorObjectOwned { + ErrorObjectOwned::owned(INTERNAL_ERROR, message.into(), None::<()>) + } +} + +#[async_trait] +impl EvolveProposerApiServer for EvolveProposerApiImpl +where + Provider: StateProviderFactory + Send + Sync + 'static, +{ + async fn get_next_proposer(&self, block: Option) -> RpcResult
{ + let block = block.unwrap_or(BlockNumberOrTag::Latest); + let state = self + .provider + .state_by_block_number_or_tag(block) + .map_err(|err| Self::rpc_error(format!("failed to load state for {block:?}: {err}")))?; + let value = state + .storage( + ev_revm::PROPOSER_CONTROL_PRECOMPILE_ADDR, + NEXT_PROPOSER_SLOT, + ) + .map_err(|err| { + Self::rpc_error(format!("failed to read proposer control storage: {err}")) + })? + .unwrap_or_default(); + + if value.is_zero() { + Ok(self.initial_next_proposer) + } else { + Ok(Address::from_word(value.into())) + } + } +} diff --git a/crates/tests/src/common.rs b/crates/tests/src/common.rs index 27e8874b..7a4c0451 100644 --- a/crates/tests/src/common.rs +++ b/crates/tests/src/common.rs @@ -163,6 +163,15 @@ impl EvolveTestFixture { let mint_precompile = config .mint_precompile_settings() .map(|(admin, activation)| MintPrecompileSettings::new(admin, activation)); + let proposer_control_precompile = config.proposer_control_precompile_settings().map( + |(admin, activation, initial_next_proposer)| { + ev_revm::ProposerControlPrecompileSettings::new( + admin, + activation, + initial_next_proposer, + ) + }, + ); let contract_size_limit = config .contract_size_limit_settings() .map(|(limit, activation)| ContractSizeLimitSettings::new(limit, activation)); @@ -172,6 +181,7 @@ impl EvolveTestFixture { let evm_factory = EvTxEvmFactory::new( base_fee_redirect, mint_precompile, + proposer_control_precompile, deploy_allowlist, contract_size_limit, ); diff --git a/docs/adr/ADR-0004-proposer-rotation-precompile.md b/docs/adr/ADR-0004-proposer-rotation-precompile.md new file mode 100644 index 00000000..252259b1 --- /dev/null +++ b/docs/adr/ADR-0004-proposer-rotation-precompile.md @@ -0,0 +1,304 @@ +# ADR 0004: Execution-Owned Proposer Rotation Precompile + +## Changelog + +* 2026-04-27: Initial draft + +## Status + +DRAFT - Not Implemented + +## Abstract + +ADR-023 in ev-node moves proposer selection from node-local configuration into the execution +environment. ev-reth therefore needs a deterministic execution-state source for the address that +should sign the next ev-node block. + +This ADR proposes a small ev-reth precompile that stores the next proposer address in EVM state and +allows a configured admin to update it through normal transactions. ev-node will query ev-reth after +executing each block and return the selected address through `ExecuteResult.NextProposerAddress`. +This keeps proposer rotation controlled by execution state while avoiding a header format change. + +## Context + +ev-node previously selected the block proposer from genesis or local node configuration. That makes +sequencer key rotation operationally brittle: every node must agree on the same key transition at the +same time, and rotation cannot naturally be governed by EVM state. + +ADR-023 changes this model. The execution layer becomes the authority for proposer rotation: + +- `ExecuteTxs` returns the state root after block execution. +- `ExecuteTxs` may also return `NextProposerAddress`, the address expected to sign the next block. +- An empty `NextProposerAddress` means the current proposer remains active. +- `GetExecutionInfo` may expose the current next proposer at startup so ev-node can seed state. + +For ev-reth, the proposer selector must be: + +- deterministic across all nodes; +- persisted in execution state; +- readable by the ev-node EVM execution adapter after block execution; +- updateable through standard EVM transactions; +- protected by strong access control; +- compatible with existing Engine API payload production. + +## Alternatives + +### 1. Solidity System Contract + +A system contract could be deployed at genesis or during an upgrade. It would store the proposer and +implement access control in Solidity. + +* **Pros:** Standard bytecode, ABI, events, and audit tooling. Easy for governance contracts and + multisigs to integrate with. +* **Cons:** ev-reth still needs a reliable node-side read path for ev-node. Chains must manage + genesis allocation or upgrade deployment. Access-control bugs become bytecode-level consensus + state, and changes require contract upgrade patterns. + +### 2. Node-Local Configuration + +Nodes could configure the active proposer or a proposer schedule locally. + +* **Pros:** Simple to implement in ev-node. +* **Cons:** Rejected by ADR-023. Node-local configuration is not deterministic execution state and + can split the network if operators disagree or rotate at different times. + +### 3. Engine API Payload Attribute + +ev-node could pass the next proposer through payload attributes and ev-reth could echo it back. + +* **Pros:** Minimal EVM changes. +* **Cons:** The proposer would be controlled by the block producer rather than by execution state. + A malicious proposer could rotate authority without a transaction authorized by governance. + +### 4. Header Commitment + +ev-node could add a next-proposer field to headers. + +* **Pros:** Header-only clients can observe proposer rotation directly. +* **Cons:** This changes signed header encoding and hash chains. ADR-023 explicitly avoids this for + the first version. + +## Decision + +We will implement an optional proposer rotation precompile in ev-reth. + +The precompile will be located at a reserved address: + +```text +0x000000000000000000000000000000000000f101 +``` + +It will expose this Solidity-compatible interface: + +```solidity +interface IProposerControl { + function nextProposer() external view returns (address); + function setNextProposer(address proposer) external; + function admin() external view returns (address); +} +``` + +The precompile stores the configured next proposer in its own account storage. The admin is configured +from the chainspec and may be an EOA, a genesis-deployed proxy, or a governance/multisig contract. + +The proposed chainspec fields are: + +```json +{ + "config": { + "evolve": { + "proposerControlAdmin": "0x1234567890123456789012345678901234567890", + "proposerControlActivationHeight": 0, + "initialNextProposer": "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" + } + } +} +``` + +Field semantics: + +- `proposerControlAdmin`: enables the precompile and authorizes proposer updates. The zero address + disables the precompile. +- `proposerControlActivationHeight`: block height where the precompile becomes callable. Defaults to + `0` when `proposerControlAdmin` is set. +- `initialNextProposer`: optional initial proposer value written or exposed from genesis. If omitted, + the precompile starts with no stored proposer and ev-node falls back to genesis proposer at startup. + +### Storage Model + +The precompile will make its own account non-empty, following the mint precompile pattern, so storage +is not pruned between blocks. + +Suggested storage slots: + +- `slot 0`: `nextProposer` as a 20-byte address encoded in a 32-byte word. + +The admin does not need dynamic storage if it is fixed from chainspec. Returning `admin()` can use the +configured value. + +### Write Semantics + +`setNextProposer(address proposer)`: + +- requires `msg.sender == proposerControlAdmin`; +- rejects the zero address; +- stores `proposer` in `slot 0`; +- emits no EVM log in the first version because revm precompile log emission needs explicit support + and is not necessary for consensus correctness. + +An admin contract can be used as `proposerControlAdmin` to provide multisig or governance-controlled +authorization. In that model, transactions are sent to the admin contract, and the admin contract calls +the precompile. + +### Read Semantics + +`nextProposer()` returns the stored proposer. If no proposer has been stored, it returns the zero +address. + +ev-node treats a zero or empty proposer as "unchanged" per ADR-023. For startup, if ev-reth reports +zero, ev-node falls back to `genesis.proposer_address`. + +### ev-node Bridge + +ev-reth should expose a small RPC method for the ev-node EVM execution adapter: + +```text +evolve_getNextProposer(blockTag) -> address +``` + +The adapter in `../ev-node/execution/evm` will: + +1. Query the current proposer before executing block `N`. +2. Execute block `N` through the existing Engine API flow. +3. Query the proposer at block `N`. +4. Return `ExecuteResult.NextProposerAddress` only when the post-execution proposer is non-zero and + differs from the pre-execution proposer. +5. Return an empty `NextProposerAddress` when unchanged, preserving ADR-023 compatibility. + +`GetExecutionInfo` should query `evolve_getNextProposer(latest)` and return it as +`ExecutionInfo.NextProposerAddress` when non-zero. + +The RPC method is a convenience and stability layer for ev-node. It avoids embedding Solidity ABI and +contract-call details in the Go adapter while still deriving the value from execution state. + +### Existing Chain Upgrade + +Existing chains should activate proposer control with a coordinated future-height upgrade: + +1. Choose a future `proposerControlActivationHeight` far enough ahead for all full nodes and the + current sequencer to upgrade ev-reth. +2. Add `proposerControlAdmin` to the chainspec `config.evolve` section. This should normally be a + genesis-deployed admin proxy, multisig, or security-council contract address. An EOA is acceptable + only for development or emergency procedures. +3. Set `initialNextProposer` to the currently expected ev-node proposer. This makes + `evolve_getNextProposer` return the current signer immediately after activation even before the + first rotation transaction. +4. Upgrade full nodes first, then the sequencer, before the activation height. +5. After activation, rotate by sending a normal transaction from the admin to + `0x000000000000000000000000000000000000f101` calling `setNextProposer(newProposer)`. +6. Restart or reconfigure the future sequencer so its ev-node signer key matches `newProposer` before + it is expected to produce block `N+1`. + +Example chainspec addition: + +```json +{ + "config": { + "evolve": { + "proposerControlAdmin": "0x1234567890123456789012345678901234567890", + "proposerControlActivationHeight": 20000000, + "initialNextProposer": "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" + } + } +} +``` + +For a new chain, `proposerControlActivationHeight` can be `0`. For an existing chain, it should be a +future block. Using a future activation height avoids historical state-transition divergence for +archival nodes replaying blocks before proposer control existed. + +### Implementation Details + +Implementation should follow the existing mint precompile shape: + +- add `crates/ev-precompiles/src/proposer.rs`; +- export the module from `crates/ev-precompiles/src/lib.rs`; +- add `ProposerControlPrecompileSettings` in `crates/ev-revm/src/factory.rs`; +- install the precompile in both EVM factory paths when active for the current block; +- parse chainspec fields in `crates/node/src/config.rs`; +- pass settings through `crates/node/src/executor.rs` and test helpers; +- expose the read RPC from `bin/ev-reth/src/main.rs` or the node RPC module wiring; +- update `../ev-node/execution/evm` to call the read RPC and populate ADR-023 fields. + +## Consequences + +### Backwards Compatibility + +The precompile is optional. Networks without `proposerControlAdmin` keep current behavior and ev-node +receives empty proposer updates. + +Existing chains can activate the precompile at a future block using +`proposerControlActivationHeight`. Nodes must upgrade before the activation height. Before activation, +calls to the address follow normal EVM behavior for an empty account. + +### Positive + +* **Execution-owned rotation:** The signer for the next ev-node block is derived from EVM state. +* **Small consensus surface:** The native logic is limited to one authorized storage write and one + storage read. +* **Operationally simple:** Key rotation is a normal transaction from the configured admin. +* **Governance compatible:** The admin can be a multisig or governance contract. +* **No header change:** The design aligns with ADR-023 and keeps header encoding unchanged. + +### Negative + +* **Non-standard precompile:** All compatible ev-reth nodes must implement the same native behavior. +* **Admin compromise risk:** The configured admin can rotate proposer authority to an attacker. +* **Limited event support:** A native precompile may not emit standard logs in the first version, so + monitoring should query state or transaction traces. +* **Cross-repo change:** Full support requires both ev-reth and the ev-node EVM adapter changes. + +### Neutral + +* A Solidity system contract remains a viable future replacement if standard events or upgradeable + in-contract policy become more important than native simplicity. +* The precompile does not decide whether a local node has the matching private key. ev-node still + checks its configured signer against the expected proposer. + +## Further Discussions + +Open questions before implementation: + +- Should `initialNextProposer` be required when the precompile is enabled, or should genesis fallback + remain sufficient? +- Should `setNextProposer` allow setting the current proposer again, or reject no-op updates? +- Should the precompile support a two-step rotation such as `proposeNextProposer` and + `acceptProposer`, or is multisig/admin policy enough? +- Should ev-reth expose `evolve_getNextProposer` only on authenticated RPC, or is public read access + acceptable because the value is public state? +- Should the first version add log emission support, or should monitoring rely on calls and state + reads? + +## Test Cases + +Required test coverage: + +- Unit tests for `nextProposer`, `admin`, and `setNextProposer` ABI decoding. +- Authorization tests for admin, non-admin EOA, and admin contract caller. +- Zero-address rejection. +- Storage persistence across blocks. +- Activation-height behavior before and after activation. +- Factory registration tests for both EVM factory paths. +- RPC tests for `evolve_getNextProposer`. +- ev-node EVM adapter tests showing unchanged proposer returns empty and changed proposer returns the + new address. +- End-to-end test where block `N` updates the proposer and ev-node expects the new signer for block + `N+1`. + +## References + +* `../ev-node/docs/adr/adr-023-execution-owned-proposer-rotation.md` +* `../ev-node/core/execution/execution.go` +* `crates/ev-precompiles/src/mint.rs` +* `crates/ev-revm/src/factory.rs` +* `crates/node/src/config.rs`