diff --git a/CLAUDE.md b/CLAUDE.md index 3b1ddc1..9f3a70c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **Check formatting**: `make fmt-check` - **Run linter**: `make lint` - **Run all checks**: `make check-all` + - After changes, run `make check-all`. ### Running the Node - **Run with defaults**: `make run` diff --git a/Cargo.lock b/Cargo.lock index 318aaf1..9f0c537 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2879,15 +2879,20 @@ name = "ev-node" version = "0.1.0" dependencies = [ "alloy-consensus", + "alloy-consensus-any", "alloy-eips", "alloy-evm", "alloy-genesis", + "alloy-network", "alloy-primitives", "alloy-rpc-types", "alloy-rpc-types-engine", + "alloy-rpc-types-eth", "async-trait", + "c-kzg", "clap", "ev-common", + "ev-primitives", "ev-revm", "evolve-ev-reth", "eyre", @@ -2896,6 +2901,7 @@ dependencies = [ "reth-basic-payload-builder", "reth-chainspec", "reth-cli", + "reth-codecs", "reth-consensus", "reth-db", "reth-engine-local", @@ -2919,9 +2925,14 @@ dependencies = [ "reth-primitives-traits", "reth-provider", "reth-revm", + "reth-rpc", "reth-rpc-api", "reth-rpc-builder", + "reth-rpc-convert", "reth-rpc-engine-api", + "reth-rpc-eth-api", + "reth-rpc-eth-types", + "reth-storage-api", "reth-tasks", "reth-testing-utils", "reth-tracing", @@ -2952,6 +2963,23 @@ dependencies = [ "tracing", ] +[[package]] +name = "ev-primitives" +version = "0.1.0" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "bytes", + "reth-codecs", + "reth-db-api", + "reth-ethereum-primitives", + "reth-primitives-traits", + "serde", +] + [[package]] name = "ev-reth" version = "0.1.0" @@ -3003,6 +3031,7 @@ dependencies = [ "alloy-primitives", "alloy-sol-types", "ev-precompiles", + "ev-primitives", "reth-evm", "reth-evm-ethereum", "reth-primitives", @@ -3031,6 +3060,7 @@ dependencies = [ "ev-common", "ev-node", "ev-precompiles", + "ev-primitives", "ev-revm", "evolve-ev-reth", "eyre", @@ -3081,6 +3111,7 @@ dependencies = [ "alloy-rpc-types-engine", "alloy-rpc-types-txpool", "async-trait", + "ev-primitives", "eyre", "jsonrpsee", "jsonrpsee-core", diff --git a/Cargo.toml b/Cargo.toml index d3babc7..bc19abe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "bin/ev-reth", "crates/common", + "crates/ev-primitives", "crates/evolve", "crates/node", "crates/tests", @@ -47,13 +48,14 @@ reth-transaction-pool = { git = "https://github.com/paradigmxyz/reth.git", tag = reth-network = { git = "https://github.com/paradigmxyz/reth.git", tag = "v1.8.4" } reth-network-types = { git = "https://github.com/paradigmxyz/reth.git", tag = "v1.8.4" } reth-chain-state = { git = "https://github.com/paradigmxyz/reth.git", tag = "v1.8.4" } +reth-db-api = { git = "https://github.com/paradigmxyz/reth.git", tag = "v1.8.4" } reth-ethereum = { git = "https://github.com/paradigmxyz/reth.git", tag = "v1.8.4" } reth-ethereum-cli = { git = "https://github.com/paradigmxyz/reth.git", tag = "v1.8.4" } reth-basic-payload-builder = { git = "https://github.com/paradigmxyz/reth.git", tag = "v1.8.4" } reth-engine-local = { git = "https://github.com/paradigmxyz/reth.git", tag = "v1.8.4" } reth-engine-primitives = { git = "https://github.com/paradigmxyz/reth.git", tag = "v1.8.4" } reth-ethereum-payload-builder = { git = "https://github.com/paradigmxyz/reth.git", tag = "v1.8.4" } -reth-ethereum-primitives = { git = "https://github.com/paradigmxyz/reth.git", tag = "v1.8.4" } +reth-ethereum-primitives = { git = "https://github.com/paradigmxyz/reth.git", tag = "v1.8.4", features = ["serde", "serde-bincode-compat", "reth-codec"] } reth-e2e-test-utils = { git = "https://github.com/paradigmxyz/reth.git", tag = "v1.8.4" } reth-evm = { git = "https://github.com/paradigmxyz/reth.git", tag = "v1.8.4" } reth-evm-ethereum = { git = "https://github.com/paradigmxyz/reth.git", tag = "v1.8.4" } @@ -69,8 +71,12 @@ reth-revm = { git = "https://github.com/paradigmxyz/reth.git", tag = "v1.8.4" } reth-rpc-api = { git = "https://github.com/paradigmxyz/reth.git", tag = "v1.8.4" } reth-rpc-builder = { git = "https://github.com/paradigmxyz/reth.git", tag = "v1.8.4" } reth-rpc-engine-api = { git = "https://github.com/paradigmxyz/reth.git", tag = "v1.8.4" } +reth-rpc = { git = "https://github.com/paradigmxyz/reth.git", tag = "v1.8.4" } +reth-rpc-convert = { git = "https://github.com/paradigmxyz/reth.git", tag = "v1.8.4" } +reth-codecs = { git = "https://github.com/paradigmxyz/reth.git", tag = "v1.8.4" } ev-revm = { path = "crates/ev-revm" } +ev-primitives = { path = "crates/ev-primitives" } # Consensus dependencies @@ -106,12 +112,18 @@ alloy-rpc-types-eth = { version = "1.0.37", default-features = false } alloy-rpc-types-engine = { version = "1.0.37", default-features = false } alloy-signer = { version = "1.0.37", default-features = false } alloy-signer-local = { version = "1.0.37", features = ["mnemonic"] } +alloy-serde = { version = "1.0.37", default-features = false } alloy-primitives = { version = "1.3.1", default-features = false } alloy-consensus = { version = "1.0.37", default-features = false } +alloy-consensus-any = { version = "1.0.37", default-features = false } +alloy-rlp = { version = "0.3.12", default-features = false } alloy-genesis = { version = "1.0.37", default-features = false } alloy-rpc-types-txpool = { version = "1.0.37", default-features = false } alloy-sol-types = { version = "1.3.1", default-features = false } +# Utility dependencies +bytes = "1.10.1" + revm-inspector = { version = "10.0.1" } # Core dependencies eyre = "0.6" diff --git a/crates/ev-primitives/Cargo.toml b/crates/ev-primitives/Cargo.toml new file mode 100644 index 0000000..b3bac41 --- /dev/null +++ b/crates/ev-primitives/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "ev-primitives" +version = "0.1.0" +edition = "2021" +rust-version = "1.82" +license = "MIT OR Apache-2.0" + +[dependencies] +alloy-consensus = { workspace = true } +alloy-eips = { workspace = true, features = ["serde"] } +alloy-primitives = { workspace = true, features = ["k256", "rlp", "serde"] } +alloy-serde = { workspace = true } +alloy-rlp = { workspace = true, features = ["derive"] } +bytes = { workspace = true } +reth-codecs = { workspace = true } +reth-db-api = { workspace = true } +reth-ethereum-primitives = { workspace = true } +reth-primitives-traits = { workspace = true, features = ["serde-bincode-compat"] } +serde = { workspace = true, features = ["derive"] } + +[features] +serde-bincode-compat = ["reth-primitives-traits/serde-bincode-compat"] diff --git a/crates/ev-primitives/src/lib.rs b/crates/ev-primitives/src/lib.rs new file mode 100644 index 0000000..3ee5d26 --- /dev/null +++ b/crates/ev-primitives/src/lib.rs @@ -0,0 +1,33 @@ +//! EV-specific primitive types, including the EvNode 0x76 transaction. + +mod pool; +mod tx; + +pub use pool::{EvPooledTxEnvelope, EvPooledTxType}; +pub use tx::{ + Call, EvNodeSignedTx, EvNodeTransaction, EvTxEnvelope, EvTxType, TransactionSigned, + EVNODE_SPONSOR_DOMAIN, EVNODE_TX_TYPE_ID, +}; + +use reth_primitives_traits::NodePrimitives; + +/// Block type alias for ev-reth. +pub type Block = alloy_consensus::Block; + +/// Block body type alias for ev-reth. +pub type BlockBody = alloy_consensus::BlockBody; + +/// Receipt type alias for ev-reth. +pub type Receipt = reth_ethereum_primitives::Receipt; + +/// Helper struct that specifies the ev-reth NodePrimitives types. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct EvPrimitives; + +impl NodePrimitives for EvPrimitives { + type Block = Block; + type BlockHeader = alloy_consensus::Header; + type BlockBody = BlockBody; + type SignedTx = TransactionSigned; + type Receipt = Receipt; +} diff --git a/crates/ev-primitives/src/pool.rs b/crates/ev-primitives/src/pool.rs new file mode 100644 index 0000000..b612136 --- /dev/null +++ b/crates/ev-primitives/src/pool.rs @@ -0,0 +1,79 @@ +//! Pooled transaction envelope for ev-reth. + +use alloy_consensus::{ + error::ValueError, + transaction::{SignerRecoverable, TxHashRef}, + TransactionEnvelope, +}; +use alloy_primitives::{Address, B256}; +use reth_primitives_traits::{InMemorySize, SignedTransaction}; + +use crate::tx::{EvNodeSignedTx, EvTxEnvelope}; + +/// Pooled transaction envelope with optional blob sidecar support. +#[derive(Clone, Debug, TransactionEnvelope)] +#[envelope(tx_type_name = EvPooledTxType)] +pub enum EvPooledTxEnvelope { + /// Standard Ethereum pooled transaction envelope (may include blob sidecar). + #[envelope(flatten)] + Ethereum(reth_ethereum_primitives::PooledTransactionVariant), + /// EvNode typed transaction (no sidecar). + #[envelope(ty = 0x76)] + EvNode(EvNodeSignedTx), +} + +impl InMemorySize for EvPooledTxEnvelope { + fn size(&self) -> usize { + match self { + EvPooledTxEnvelope::Ethereum(tx) => tx.size(), + EvPooledTxEnvelope::EvNode(tx) => tx.size(), + } + } +} + +impl SignerRecoverable for EvPooledTxEnvelope { + fn recover_signer(&self) -> Result { + match self { + EvPooledTxEnvelope::Ethereum(tx) => tx.recover_signer(), + EvPooledTxEnvelope::EvNode(tx) => tx + .signature() + .recover_address_from_prehash(&tx.tx().executor_signing_hash()) + .map_err(|_| alloy_consensus::crypto::RecoveryError::new()), + } + } + + fn recover_signer_unchecked(&self) -> Result { + self.recover_signer() + } +} + +impl TxHashRef for EvPooledTxEnvelope { + fn tx_hash(&self) -> &B256 { + match self { + EvPooledTxEnvelope::Ethereum(tx) => tx.tx_hash(), + EvPooledTxEnvelope::EvNode(tx) => tx.hash(), + } + } +} + +impl TryFrom for EvPooledTxEnvelope { + type Error = ValueError; + + fn try_from(value: EvTxEnvelope) -> Result { + match value { + EvTxEnvelope::Ethereum(tx) => Ok(Self::Ethereum(tx.try_into()?)), + EvTxEnvelope::EvNode(tx) => Ok(Self::EvNode(tx)), + } + } +} + +impl From for EvTxEnvelope { + fn from(value: EvPooledTxEnvelope) -> Self { + match value { + EvPooledTxEnvelope::Ethereum(tx) => EvTxEnvelope::Ethereum(tx.into()), + EvPooledTxEnvelope::EvNode(tx) => EvTxEnvelope::EvNode(tx), + } + } +} + +impl SignedTransaction for EvPooledTxEnvelope {} diff --git a/crates/ev-primitives/src/tx.rs b/crates/ev-primitives/src/tx.rs new file mode 100644 index 0000000..9100752 --- /dev/null +++ b/crates/ev-primitives/src/tx.rs @@ -0,0 +1,626 @@ +//! Transaction types for ev-reth. + +use alloy_consensus::{ + transaction::{RlpEcdsaDecodableTx, RlpEcdsaEncodableTx, SignerRecoverable, TxHashRef}, + SignableTransaction, Transaction, TransactionEnvelope, +}; +use alloy_eips::eip2930::AccessList; +use alloy_primitives::{keccak256, Address, Bytes, Signature, TxKind, B256, U256}; +use alloy_rlp::{bytes::Buf, BufMut, Decodable, Encodable, Header, RlpDecodable, RlpEncodable}; +use reth_codecs::{ + alloy::transaction::{CompactEnvelope, Envelope, FromTxCompact, ToTxCompact}, + txtype::COMPACT_EXTENDED_IDENTIFIER_FLAG, + Compact, +}; +use reth_db_api::{ + table::{Compress, Decompress}, + DatabaseError, +}; +use reth_primitives_traits::{InMemorySize, SignedTransaction}; +use std::vec::Vec; + +/// EIP-2718 transaction type for EvNode batch + sponsorship. +pub const EVNODE_TX_TYPE_ID: u8 = 0x76; +/// Signature domain for sponsor authorization. +pub const EVNODE_SPONSOR_DOMAIN: u8 = 0x78; + +/// Single call entry in an EvNode transaction. +#[derive( + Clone, + Debug, + PartialEq, + Eq, + Hash, + RlpEncodable, + RlpDecodable, + serde::Serialize, + serde::Deserialize, +)] +pub struct Call { + /// Destination (CALL or CREATE). + pub to: TxKind, + /// ETH value. + pub value: U256, + /// Calldata. + pub input: Bytes, +} + +/// EvNode batch + sponsorship transaction payload. +#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EvNodeTransaction { + #[serde(with = "alloy_serde::quantity")] + pub chain_id: u64, + #[serde(with = "alloy_serde::quantity")] + pub nonce: u64, + #[serde(with = "alloy_serde::quantity")] + pub max_priority_fee_per_gas: u128, + #[serde(with = "alloy_serde::quantity")] + pub max_fee_per_gas: u128, + #[serde(with = "alloy_serde::quantity")] + pub gas_limit: u64, + pub calls: Vec, + pub access_list: AccessList, + pub fee_payer_signature: Option, +} + +/// Signed EvNode transaction (executor signature). +pub type EvNodeSignedTx = alloy_consensus::Signed; + +/// Envelope type that includes standard Ethereum transactions and EvNode transactions. +#[derive(Clone, Debug, TransactionEnvelope)] +#[envelope(tx_type_name = EvTxType)] +pub enum EvTxEnvelope { + /// Standard Ethereum typed transaction envelope. + #[envelope(flatten)] + Ethereum(reth_ethereum_primitives::TransactionSigned), + /// EvNode typed transaction. + #[envelope(ty = 0x76)] + EvNode(EvNodeSignedTx), +} + +/// Signed transaction type alias for ev-reth. +pub type TransactionSigned = EvTxEnvelope; + +impl EvNodeTransaction { + /// Returns the executor signing hash (domain 0x76, empty sponsor fields). + pub fn executor_signing_hash(&self) -> B256 { + let payload = self.encoded_payload(None); + let mut preimage = Vec::with_capacity(1 + payload.len()); + preimage.push(EVNODE_TX_TYPE_ID); + preimage.extend_from_slice(&payload); + keccak256(preimage) + } + + /// Returns the sponsor signing hash (domain 0x78, executor address bound). + pub fn sponsor_signing_hash(&self, executor: Address) -> B256 { + let payload = self.encoded_payload_with_executor(executor); + let mut preimage = Vec::with_capacity(1 + payload.len()); + preimage.push(EVNODE_SPONSOR_DOMAIN); + preimage.extend_from_slice(&payload); + keccak256(preimage) + } + + /// Recovers the executor address from the provided signature. + pub fn recover_executor( + &self, + signature: &Signature, + ) -> Result { + signature.recover_address_from_prehash(&self.executor_signing_hash()) + } + + /// Recovers the sponsor address from the provided signature and executor address. + pub fn recover_sponsor( + &self, + executor: Address, + signature: &Signature, + ) -> Result { + signature.recover_address_from_prehash(&self.sponsor_signing_hash(executor)) + } + + fn first_call(&self) -> Option<&Call> { + self.calls.first() + } + + fn encoded_payload(&self, fee_payer_signature: Option<&Signature>) -> Vec { + let payload_len = self.payload_fields_length(fee_payer_signature); + let mut out = Vec::with_capacity( + Header { + list: true, + payload_length: payload_len, + } + .length_with_payload(), + ); + Header { + list: true, + payload_length: payload_len, + } + .encode(&mut out); + self.encode_payload_fields(&mut out, fee_payer_signature); + out + } + + fn encoded_payload_with_executor(&self, executor: Address) -> Vec { + // Sponsor signatures must be computed over the unsigned sponsor field to avoid + // self-referential hashing. + let mut out = Vec::with_capacity(self.payload_fields_length(None) + 32); + out.extend_from_slice(executor.as_slice()); + self.encode_payload_fields(&mut out, None); + out + } + + fn payload_fields_length(&self, fee_payer_signature: Option<&Signature>) -> usize { + self.chain_id.length() + + self.nonce.length() + + self.max_priority_fee_per_gas.length() + + self.max_fee_per_gas.length() + + self.gas_limit.length() + + self.calls.length() + + self.access_list.length() + + optional_signature_length(fee_payer_signature) + } + + fn encode_payload_fields(&self, out: &mut dyn BufMut, fee_payer_signature: Option<&Signature>) { + self.chain_id.encode(out); + self.nonce.encode(out); + self.max_priority_fee_per_gas.encode(out); + self.max_fee_per_gas.encode(out); + self.gas_limit.encode(out); + self.calls.encode(out); + self.access_list.encode(out); + encode_optional_signature(out, fee_payer_signature); + } +} + +impl Transaction for EvNodeTransaction { + fn chain_id(&self) -> Option { + Some(self.chain_id) + } + + fn nonce(&self) -> u64 { + self.nonce + } + + fn gas_limit(&self) -> u64 { + self.gas_limit + } + + fn gas_price(&self) -> Option { + None + } + + fn max_fee_per_gas(&self) -> u128 { + self.max_fee_per_gas + } + + fn max_priority_fee_per_gas(&self) -> Option { + Some(self.max_priority_fee_per_gas) + } + + fn max_fee_per_blob_gas(&self) -> Option { + None + } + + fn priority_fee_or_price(&self) -> u128 { + self.max_priority_fee_per_gas + } + + fn effective_gas_price(&self, base_fee: Option) -> u128 { + let max_fee = self.max_fee_per_gas; + let Some(base_fee) = base_fee else { + return max_fee; + }; + let base_fee = base_fee as u128; + if max_fee < base_fee { + return max_fee; + } + let priority_fee = self.max_priority_fee_per_gas; + let max_priority_fee = max_fee.saturating_sub(base_fee); + base_fee.saturating_add(priority_fee.min(max_priority_fee)) + } + + fn is_dynamic_fee(&self) -> bool { + true + } + + fn kind(&self) -> TxKind { + self.first_call() + .map(|call| call.to) + .unwrap_or(TxKind::Create) + } + + fn is_create(&self) -> bool { + matches!(self.first_call().map(|call| call.to), Some(TxKind::Create)) + } + + fn value(&self) -> U256 { + self.calls + .iter() + .fold(U256::ZERO, |acc, call| acc.saturating_add(call.value)) + } + + fn input(&self) -> &Bytes { + static EMPTY: Bytes = Bytes::new(); + self.first_call().map(|call| &call.input).unwrap_or(&EMPTY) + } + + fn access_list(&self) -> Option<&AccessList> { + Some(&self.access_list) + } + + fn blob_versioned_hashes(&self) -> Option<&[B256]> { + None + } + + fn authorization_list(&self) -> Option<&[alloy_eips::eip7702::SignedAuthorization]> { + None + } +} + +impl alloy_eips::Typed2718 for EvNodeTransaction { + fn ty(&self) -> u8 { + EVNODE_TX_TYPE_ID + } +} + +impl SignableTransaction for EvNodeTransaction { + fn set_chain_id(&mut self, chain_id: alloy_primitives::ChainId) { + self.chain_id = chain_id; + } + + fn encode_for_signing(&self, out: &mut dyn BufMut) { + out.put_u8(EVNODE_TX_TYPE_ID); + let payload_len = self.payload_fields_length(None); + Header { + list: true, + payload_length: payload_len, + } + .encode(out); + self.encode_payload_fields(out, None); + } + + fn payload_len_for_signature(&self) -> usize { + 1 + Header { + list: true, + payload_length: self.payload_fields_length(None), + } + .length_with_payload() + } +} + +impl RlpEcdsaEncodableTx for EvNodeTransaction { + fn rlp_encoded_fields_length(&self) -> usize { + self.payload_fields_length(self.fee_payer_signature.as_ref()) + } + + fn rlp_encode_fields(&self, out: &mut dyn BufMut) { + self.encode_payload_fields(out, self.fee_payer_signature.as_ref()); + } +} + +impl RlpEcdsaDecodableTx for EvNodeTransaction { + const DEFAULT_TX_TYPE: u8 = EVNODE_TX_TYPE_ID; + + fn rlp_decode_fields(buf: &mut &[u8]) -> alloy_rlp::Result { + Ok(Self { + chain_id: Decodable::decode(buf)?, + nonce: Decodable::decode(buf)?, + max_priority_fee_per_gas: Decodable::decode(buf)?, + max_fee_per_gas: Decodable::decode(buf)?, + gas_limit: Decodable::decode(buf)?, + calls: Decodable::decode(buf)?, + access_list: Decodable::decode(buf)?, + fee_payer_signature: decode_optional_signature(buf)?, + }) + } +} + +impl Encodable for EvNodeTransaction { + fn length(&self) -> usize { + Header { + list: true, + payload_length: self.rlp_encoded_fields_length(), + } + .length_with_payload() + } + + fn encode(&self, out: &mut dyn BufMut) { + self.rlp_encode(out); + } +} + +impl Decodable for EvNodeTransaction { + fn decode(buf: &mut &[u8]) -> alloy_rlp::Result { + Self::rlp_decode(buf) + } +} + +impl Compact for EvNodeTransaction { + fn to_compact(&self, buf: &mut B) -> usize + where + B: alloy_rlp::bytes::BufMut + AsMut<[u8]>, + { + let mut out = Vec::new(); + self.encode(&mut out); + out.to_compact(buf) + } + + fn from_compact(buf: &[u8], len: usize) -> (Self, &[u8]) { + let (bytes, buf) = Vec::::from_compact(buf, len); + let mut slice = bytes.as_slice(); + let decoded = Self::decode(&mut slice).expect("valid evnode tx rlp"); + (decoded, buf) + } +} + +impl InMemorySize for Call { + fn size(&self) -> usize { + core::mem::size_of::() + self.input.len() + } +} + +impl InMemorySize for EvNodeTransaction { + fn size(&self) -> usize { + let calls_size = self.calls.iter().map(InMemorySize::size).sum::(); + let access_list_size = self.access_list.size(); + let sponsor_sig_size = self + .fee_payer_signature + .map(|_| core::mem::size_of::()) + .unwrap_or(0); + core::mem::size_of::() + calls_size + access_list_size + sponsor_sig_size + } +} + +impl InMemorySize for EvTxType { + fn size(&self) -> usize { + core::mem::size_of::() + } +} + +impl InMemorySize for EvTxEnvelope { + fn size(&self) -> usize { + match self { + EvTxEnvelope::Ethereum(tx) => tx.size(), + EvTxEnvelope::EvNode(tx) => tx.size(), + } + } +} + +impl SignerRecoverable for EvTxEnvelope { + fn recover_signer(&self) -> Result { + match self { + EvTxEnvelope::Ethereum(tx) => tx.recover_signer(), + EvTxEnvelope::EvNode(tx) => tx + .signature() + .recover_address_from_prehash(&tx.tx().executor_signing_hash()) + .map_err(|_| alloy_consensus::crypto::RecoveryError::new()), + } + } + + fn recover_signer_unchecked(&self) -> Result { + self.recover_signer() + } +} + +impl TxHashRef for EvTxEnvelope { + fn tx_hash(&self) -> &B256 { + match self { + EvTxEnvelope::Ethereum(tx) => tx.tx_hash(), + EvTxEnvelope::EvNode(tx) => tx.hash(), + } + } +} + +impl Compact for EvTxType { + fn to_compact(&self, buf: &mut B) -> usize + where + B: alloy_rlp::bytes::BufMut + AsMut<[u8]>, + { + match self { + EvTxType::Ethereum(inner) => inner.to_compact(buf), + EvTxType::EvNode => { + buf.put_u8(EVNODE_TX_TYPE_ID); + COMPACT_EXTENDED_IDENTIFIER_FLAG + } + } + } + + fn from_compact(mut buf: &[u8], identifier: usize) -> (Self, &[u8]) { + match identifier { + COMPACT_EXTENDED_IDENTIFIER_FLAG => { + let extended_identifier = buf.get_u8(); + match extended_identifier { + EVNODE_TX_TYPE_ID => (Self::EvNode, buf), + _ => panic!("Unsupported EvTxType identifier: {extended_identifier}"), + } + } + v => { + let (inner, buf) = alloy_consensus::TxType::from_compact(buf, v); + (Self::Ethereum(inner), buf) + } + } + } +} + +impl Envelope for EvTxEnvelope { + fn signature(&self) -> &Signature { + match self { + EvTxEnvelope::Ethereum(tx) => tx.signature(), + EvTxEnvelope::EvNode(tx) => tx.signature(), + } + } + + fn tx_type(&self) -> Self::TxType { + match self { + EvTxEnvelope::Ethereum(tx) => EvTxType::Ethereum(tx.tx_type()), + EvTxEnvelope::EvNode(_) => EvTxType::EvNode, + } + } +} + +impl FromTxCompact for EvTxEnvelope { + type TxType = EvTxType; + + fn from_tx_compact(buf: &[u8], tx_type: Self::TxType, signature: Signature) -> (Self, &[u8]) + where + Self: Sized, + { + match tx_type { + EvTxType::Ethereum(inner) => { + let (tx, buf) = reth_ethereum_primitives::TransactionSigned::from_tx_compact( + buf, inner, signature, + ); + (Self::Ethereum(tx), buf) + } + EvTxType::EvNode => { + let (tx, buf) = EvNodeTransaction::from_compact(buf, buf.len()); + let tx = alloy_consensus::Signed::new_unhashed(tx, signature); + (Self::EvNode(tx), buf) + } + } + } +} + +impl ToTxCompact for EvTxEnvelope { + fn to_tx_compact(&self, buf: &mut (impl alloy_rlp::bytes::BufMut + AsMut<[u8]>)) { + match self { + EvTxEnvelope::Ethereum(tx) => tx.to_tx_compact(buf), + EvTxEnvelope::EvNode(tx) => { + tx.tx().to_compact(buf); + } + } + } +} + +impl Compact for EvTxEnvelope { + fn to_compact(&self, buf: &mut B) -> usize + where + B: alloy_rlp::bytes::BufMut + AsMut<[u8]>, + { + ::to_compact(self, buf) + } + + fn from_compact(buf: &[u8], len: usize) -> (Self, &[u8]) { + ::from_compact(buf, len) + } +} + +impl SignedTransaction for EvTxEnvelope {} + +impl reth_primitives_traits::serde_bincode_compat::RlpBincode for EvTxEnvelope {} + +impl Compress for EvTxEnvelope { + type Compressed = Vec; + + fn compress_to_buf>(&self, buf: &mut B) { + let _ = Compact::to_compact(self, buf); + } +} + +impl Decompress for EvTxEnvelope { + fn decompress(value: &[u8]) -> Result { + let (obj, _) = Compact::from_compact(value, value.len()); + Ok(obj) + } +} + +fn optional_signature_length(value: Option<&Signature>) -> usize { + match value { + Some(sig) => sig.as_bytes().as_slice().length(), + None => 1, + } +} + +fn encode_optional_signature(out: &mut dyn BufMut, value: Option<&Signature>) { + match value { + Some(sig) => sig.as_bytes().as_slice().encode(out), + None => out.put_u8(alloy_rlp::EMPTY_STRING_CODE), + } +} + +fn decode_optional_signature(buf: &mut &[u8]) -> alloy_rlp::Result> { + let bytes = Header::decode_bytes(buf, false)?; + if bytes.is_empty() { + return Ok(None); + } + let raw: [u8; 65] = bytes + .try_into() + .map_err(|_| alloy_rlp::Error::UnexpectedLength)?; + Signature::from_raw_array(&raw) + .map(Some) + .map_err(|_| alloy_rlp::Error::Custom("invalid signature bytes")) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_eips::eip2930::AccessList; + + fn sample_signature() -> Signature { + let mut bytes = [0u8; 65]; + bytes[64] = 27; + Signature::from_raw_array(&bytes).expect("valid test signature") + } + + fn sample_tx() -> EvNodeTransaction { + EvNodeTransaction { + chain_id: 1, + nonce: 1, + max_priority_fee_per_gas: 1, + max_fee_per_gas: 2, + gas_limit: 30_000, + calls: vec![Call { + to: TxKind::Create, + value: U256::from(1), + input: Bytes::new(), + }], + access_list: AccessList::default(), + fee_payer_signature: None, + } + } + + #[test] + fn executor_signing_hash_ignores_sponsor_fields() { + let mut tx = sample_tx(); + let base_hash = tx.executor_signing_hash(); + + tx.fee_payer_signature = Some(sample_signature()); + + assert_eq!(base_hash, tx.executor_signing_hash()); + } + + #[test] + fn sponsor_signing_hash_binds_executor() { + let tx = sample_tx(); + let a = Address::from_slice(&[1u8; 20]); + let b = Address::from_slice(&[2u8; 20]); + assert_ne!(tx.sponsor_signing_hash(a), tx.sponsor_signing_hash(b)); + } + + #[test] + fn rlp_roundtrip_with_optional_signature() { + let mut tx = sample_tx(); + tx.fee_payer_signature = Some(sample_signature()); + + let mut out = Vec::new(); + tx.encode(&mut out); + let mut slice = out.as_slice(); + let decoded = EvNodeTransaction::decode(&mut slice).expect("decode tx"); + assert_eq!(decoded.fee_payer_signature, tx.fee_payer_signature); + } + + #[test] + fn decode_optional_signature_none() { + let mut buf: &[u8] = &[alloy_rlp::EMPTY_STRING_CODE]; + let decoded = decode_optional_signature(&mut buf).expect("decode none signature"); + assert_eq!(decoded, None); + assert!(buf.is_empty()); + } + + #[test] + fn decode_optional_signature_rejects_invalid_length() { + let mut buf: &[u8] = &[0x82, 0x01, 0x02]; + let err = decode_optional_signature(&mut buf).expect_err("invalid length"); + assert_eq!(err, alloy_rlp::Error::UnexpectedLength); + } +} diff --git a/crates/ev-revm/Cargo.toml b/crates/ev-revm/Cargo.toml index 9ee6715..fe01658 100644 --- a/crates/ev-revm/Cargo.toml +++ b/crates/ev-revm/Cargo.toml @@ -19,6 +19,7 @@ revm-inspector.workspace = true revm-context-interface.workspace = true thiserror.workspace = true ev-precompiles = { path = "../ev-precompiles" } +ev-primitives = { path = "../ev-primitives" } [dev-dependencies] alloy-sol-types.workspace = true diff --git a/crates/ev-revm/src/api/exec.rs b/crates/ev-revm/src/api/exec.rs index c26c43e..46e45f6 100644 --- a/crates/ev-revm/src/api/exec.rs +++ b/crates/ev-revm/src/api/exec.rs @@ -1,7 +1,11 @@ //! Execution traits for [`EvEvm`], mirroring the Reth mainnet implementations //! while inserting the EV-specific handler that redirects the base fee. -use crate::{evm::EvEvm, handler::EvHandler}; +use crate::{ + evm::EvEvm, + handler::EvHandler, + tx_env::{BatchCallsTx, SponsorPayerTx}, +}; use alloy_primitives::{Address, Bytes}; use reth_revm::revm::{ context::{result::ExecResultAndState, ContextSetters}, @@ -26,7 +30,9 @@ pub type EvExecutionResult = ExecutionResult; impl ExecuteEvm for EvEvm where - CTX: ContextTr> + ContextSetters, + CTX: ContextTr, Tx: SponsorPayerTx + BatchCallsTx> + + ContextSetters, + ::Tx: Clone, ::Db: Database, ::Journal: JournalTr + JournalTr::Db>, @@ -73,7 +79,12 @@ where impl ExecuteCommitEvm for EvEvm where - CTX: ContextTr> + ContextSetters, + CTX: ContextTr< + Db: DatabaseCommit, + Journal: JournalTr, + Tx: SponsorPayerTx + BatchCallsTx, + > + ContextSetters, + ::Tx: Clone, PRECOMP: PrecompileProvider, { fn commit(&mut self, state: Self::State) { @@ -83,7 +94,11 @@ where impl InspectEvm for EvEvm where - CTX: ContextTr + JournalExt> + ContextSetters, + CTX: ContextTr< + Journal: JournalTr + JournalExt, + Tx: SponsorPayerTx + BatchCallsTx, + > + ContextSetters, + ::Tx: Clone, INSP: Inspector, PRECOMP: PrecompileProvider, { @@ -106,8 +121,12 @@ where impl InspectCommitEvm for EvEvm where - CTX: ContextTr + JournalExt, Db: DatabaseCommit> - + ContextSetters, + CTX: ContextTr< + Journal: JournalTr + JournalExt, + Db: DatabaseCommit, + Tx: SponsorPayerTx + BatchCallsTx, + > + ContextSetters, + ::Tx: Clone, INSP: Inspector, PRECOMP: PrecompileProvider, { @@ -115,7 +134,11 @@ where impl SystemCallEvm for EvEvm where - CTX: ContextTr, Tx: SystemCallTx> + ContextSetters, + CTX: ContextTr< + Journal: JournalTr, + Tx: SystemCallTx + SponsorPayerTx + BatchCallsTx, + > + ContextSetters, + ::Tx: Clone, PRECOMP: PrecompileProvider, { fn system_call_one_with_caller( @@ -142,8 +165,11 @@ where impl InspectSystemCallEvm for EvEvm where - CTX: ContextTr + JournalExt, Tx: SystemCallTx> - + ContextSetters, + CTX: ContextTr< + Journal: JournalTr + JournalExt, + Tx: SystemCallTx + SponsorPayerTx + BatchCallsTx, + > + ContextSetters, + ::Tx: Clone, INSP: Inspector, PRECOMP: PrecompileProvider, { diff --git a/crates/ev-revm/src/evm.rs b/crates/ev-revm/src/evm.rs index c9161c8..a6e3432 100644 --- a/crates/ev-revm/src/evm.rs +++ b/crates/ev-revm/src/evm.rs @@ -1,6 +1,6 @@ //! EV-specific EVM wrapper that installs the base-fee redirect handler. -use crate::{base_fee::BaseFeeRedirect, deploy::DeployAllowlistSettings}; +use crate::{base_fee::BaseFeeRedirect, deploy::DeployAllowlistSettings, tx_env::EvTxEnv}; use alloy_evm::{Evm as AlloyEvm, EvmEnv}; use alloy_primitives::{Address, Bytes}; use reth_revm::{ @@ -376,3 +376,93 @@ where ) } } + +impl AlloyEvm + for EvEvm, DB>, INSP, PRECOMP> +where + DB: alloy_evm::Database, + INSP: Inspector, DB>, EthInterpreter>, + PRECOMP: PrecompileProvider< + Context, DB>, + Output = InterpreterResult, + >, +{ + type DB = DB; + type Tx = EvTxEnv; + type Error = EVMError; + type HaltReason = HaltReason; + type Spec = SpecId; + type Precompiles = PRECOMP; + type Inspector = INSP; + + fn block(&self) -> &BlockEnv { + &self.inner.ctx.block + } + + fn chain_id(&self) -> u64 { + self.inner.ctx.cfg.chain_id + } + + fn transact_raw( + &mut self, + tx: Self::Tx, + ) -> Result, Self::Error> { + if self.inspect { + InspectEvm::inspect_tx(self, tx) + } else { + ExecuteEvm::transact(self, tx) + } + .map(|res| ResultAndState::new(res.result, res.state)) + } + + fn transact_system_call( + &mut self, + caller: Address, + contract: Address, + data: Bytes, + ) -> Result, Self::Error> { + if self.inspect { + InspectSystemCallEvm::inspect_system_call_with_caller(self, caller, contract, data) + } else { + SystemCallEvm::system_call_with_caller(self, caller, contract, data) + } + .map(|res| ResultAndState::new(res.result, res.state)) + } + + fn finish(self) -> (Self::DB, EvmEnv) { + let Self { inner, .. } = self; + let Context { + block, + cfg, + journaled_state, + .. + } = inner.ctx; + ( + journaled_state.database, + EvmEnv { + block_env: block, + cfg_env: cfg, + }, + ) + } + + fn set_inspector_enabled(&mut self, enabled: bool) { + self.inspect = enabled; + } + + fn components(&self) -> (&Self::DB, &Self::Inspector, &Self::Precompiles) { + ( + &self.inner.ctx.journaled_state.database, + &self.inner.inspector, + &self.inner.precompiles, + ) + } + + fn components_mut(&mut self) -> (&mut Self::DB, &mut Self::Inspector, &mut Self::Precompiles) { + ( + &mut self.inner.ctx.journaled_state.database, + &mut self.inner.inspector, + &mut self.inner.precompiles, + ) + } +} diff --git a/crates/ev-revm/src/factory.rs b/crates/ev-revm/src/factory.rs index d4d65e1..5e3e828 100644 --- a/crates/ev-revm/src/factory.rs +++ b/crates/ev-revm/src/factory.rs @@ -1,6 +1,8 @@ //! Helpers for wrapping Reth EVM factories with the EV handler. -use crate::{base_fee::BaseFeeRedirect, deploy::DeployAllowlistSettings, evm::EvEvm}; +use crate::{ + base_fee::BaseFeeRedirect, deploy::DeployAllowlistSettings, evm::EvEvm, tx_env::EvTxEnv, +}; use alloy_evm::{ eth::{EthBlockExecutorFactory, EthEvmContext, EthEvmFactory}, precompiles::{DynPrecompile, Precompile, PrecompilesMap}, @@ -14,11 +16,14 @@ use reth_revm::{ revm::{ context::{ result::{EVMError, HaltReason}, - TxEnv, + Evm as RevmEvm, FrameStack, TxEnv, }, - context_interface::result::InvalidTransaction, + context_interface::{journaled_state::JournalTr, result::InvalidTransaction}, + handler::instructions::EthInstructions, + interpreter::interpreter::EthInterpreter, + precompile::{PrecompileSpecId, Precompiles}, primitives::hardfork::SpecId, - Inspector, + Context, Inspector, }, }; use std::sync::Arc; @@ -228,6 +233,177 @@ impl EvmFactory for EvEvmFactory { } } +/// EV EVM factory that builds a mainnet EVM with `EvTxEnv` and EV hooks. +#[derive(Debug, Default, Clone)] +pub struct EvTxEvmFactory { + redirect: Option, + mint_precompile: Option, + deploy_allowlist: Option, + contract_size_limit: Option, +} + +type EvEvmContext = Context< + reth_revm::revm::context::BlockEnv, + EvTxEnv, + reth_revm::revm::context::CfgEnv, + DB, +>; +type EvRevmEvm = RevmEvm< + EvEvmContext, + I, + EthInstructions>, + PrecompilesMap, + reth_revm::revm::handler::EthFrame, +>; + +impl EvTxEvmFactory { + /// Creates a new EV EVM factory with optional redirect, mint, allowlist, and size settings. + pub const fn new( + redirect: Option, + mint_precompile: Option, + deploy_allowlist: Option, + contract_size_limit: Option, + ) -> Self { + Self { + redirect, + mint_precompile, + deploy_allowlist, + contract_size_limit, + } + } + + fn contract_size_limit_for_block(&self, block_number: U256) -> Option { + self.contract_size_limit.and_then(|settings| { + if block_number >= U256::from(settings.activation_height()) { + Some(settings.limit()) + } else { + None + } + }) + } + + fn install_mint_precompile(&self, precompiles: &mut PrecompilesMap, block_number: U256) { + let Some(settings) = self.mint_precompile else { + return; + }; + if block_number < U256::from(settings.activation_height()) { + return; + } + + let mint = Arc::new(MintPrecompile::new(settings.admin())); + let id = MintPrecompile::id().clone(); + + precompiles.apply_precompile(&MINT_PRECOMPILE_ADDR, move |_| { + let mint_for_call = Arc::clone(&mint); + let id_for_call = id; + Some(DynPrecompile::new_stateful(id_for_call, move |input| { + mint_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()) { + Some(settings.redirect()) + } else { + None + } + }) + } + + fn build_evm>>( + &self, + db: DB, + env: EvmEnv, + inspector: I, + ) -> EvRevmEvm { + let precompiles = PrecompilesMap::from_static(Precompiles::new( + PrecompileSpecId::from_spec_id(env.cfg_env.spec), + )); + + let mut journaled_state = reth_revm::revm::Journal::new(db); + journaled_state.set_spec_id(env.cfg_env.spec); + + let ctx = Context { + block: env.block_env, + tx: EvTxEnv::default(), + cfg: env.cfg_env, + journaled_state, + chain: (), + local: Default::default(), + error: Ok(()), + }; + + RevmEvm { + ctx, + inspector, + instruction: EthInstructions::new_mainnet(), + precompiles, + frame_stack: FrameStack::new(), + } + } +} + +impl EvmFactory for EvTxEvmFactory { + type Evm>> = + EvEvm, I, PrecompilesMap>; + type Context = EvEvmContext; + type Tx = EvTxEnv; + type Error = + EVMError; + type HaltReason = HaltReason; + type Spec = SpecId; + type Precompiles = PrecompilesMap; + + fn create_evm( + &self, + db: DB, + mut env: EvmEnv, + ) -> Self::Evm { + let block_number = env.block_env.number; + if let Some(limit) = self.contract_size_limit_for_block(block_number) { + env.cfg_env.limit_contract_code_size = Some(limit); + } + let inner = self.build_evm(db, env, NoOpInspector {}); + let mut evm = EvEvm::from_inner( + inner, + self.redirect_for_block(block_number), + self.deploy_allowlist.clone(), + false, + ); + { + let inner = evm.inner_mut(); + self.install_mint_precompile(&mut inner.precompiles, block_number); + } + evm + } + + fn create_evm_with_inspector>>( + &self, + db: DB, + mut env: EvmEnv, + inspector: I, + ) -> Self::Evm { + let block_number = env.block_env.number; + if let Some(limit) = self.contract_size_limit_for_block(block_number) { + env.cfg_env.limit_contract_code_size = Some(limit); + } + let inner = self.build_evm(db, env, inspector); + let mut evm = EvEvm::from_inner( + inner, + self.redirect_for_block(block_number), + self.deploy_allowlist.clone(), + true, + ); + { + let inner = evm.inner_mut(); + self.install_mint_precompile(&mut inner.precompiles, block_number); + } + evm + } +} + /// Wraps an [`EthEvmConfig`] so that it produces [`EvEvm`] instances. pub fn with_ev_handler( config: EthEvmConfig, diff --git a/crates/ev-revm/src/handler.rs b/crates/ev-revm/src/handler.rs index d569869..59b3816 100644 --- a/crates/ev-revm/src/handler.rs +++ b/crates/ev-revm/src/handler.rs @@ -3,23 +3,33 @@ use crate::{ base_fee::{BaseFeeRedirect, BaseFeeRedirectError}, deploy::DeployAllowlistSettings, + tx_env::{BatchCallsTx, SponsorPayerTx}, }; -use alloy_primitives::TxKind; +use alloy_primitives::{TxKind, U256}; use reth_revm::{ inspector::{Inspector, InspectorEvmTr, InspectorHandler}, revm::{ - context::result::ExecutionResult, - context_interface::{result::HaltReason, Block, ContextTr, JournalTr, Transaction}, + context::{result::ExecutionResult, ContextSetters}, + context_interface::{ + result::HaltReason, + transaction::{AccessListItemTr, TransactionType}, + Block, Cfg, ContextTr, JournalTr, Transaction, + }, handler::{ post_execution, EthFrame, EvmTr, EvmTrError, FrameResult, FrameTr, Handler, MainnetHandler, }, interpreter::{ - interpreter::EthInterpreter, interpreter_action::FrameInit, InitialAndFloorGas, + gas::{calculate_initial_tx_gas, ACCESS_LIST_ADDRESS, ACCESS_LIST_STORAGE_KEY}, + interpreter::EthInterpreter, + interpreter_action::FrameInit, + Gas, InitialAndFloorGas, }, - state::EvmState, + primitives::{eip7702, hardfork::SpecId}, + state::{AccountInfo, Bytecode, EvmState}, }, }; +use std::cmp::Ordering; /// Handler wrapper that mirrors the mainnet handler but applies optional EV-specific policies. #[derive(Debug, Clone)] @@ -85,7 +95,14 @@ impl EvHandler { impl Handler for EvHandler where - EVM: EvmTr>, Frame = FRAME>, + EVM: EvmTr< + Context: ContextTr< + Journal: JournalTr, + Tx: SponsorPayerTx + BatchCallsTx, + > + ContextSetters, + Frame = FRAME, + >, + <::Context as ContextTr>::Tx: Clone, ERROR: EvmTrError, FRAME: FrameTr, { @@ -98,6 +115,25 @@ where } fn validate_initial_tx_gas(&self, evm: &Self::Evm) -> Result { + let ctx = evm.ctx_ref(); + let tx = ctx.tx(); + if let Some(calls) = tx.batch_calls() { + if calls.is_empty() { + return Err(Self::Error::from_string( + "evnode transaction must include at least one call".into(), + )); + } + if calls.iter().skip(1).any(|call| call.to.is_create()) { + return Err(Self::Error::from_string( + "only the first call may be CREATE".into(), + )); + } + if calls.len() > 1 { + return validate_batch_initial_tx_gas(tx, calls, ctx.cfg().spec().into(), false) + .map_err(From::from); + } + } + self.inner.validate_initial_tx_gas(evm) } @@ -114,7 +150,94 @@ where evm: &mut Self::Evm, ) -> Result<(), Self::Error> { self.ensure_deploy_allowed(evm)?; - self.inner.validate_against_state_and_deduct_caller(evm) + + let ctx = evm.ctx_mut(); + let tx = ctx.tx(); + let sponsor_invalid = tx.sponsor_signature_invalid(); + let sponsor = tx.sponsor(); + let caller_address = tx.caller(); + let total_value = tx.batch_total_value(); + let is_call = tx.kind().is_call(); + let basefee = ctx.block().basefee() as u128; + let blob_price = ctx.block().blob_gasprice().unwrap_or_default(); + let is_balance_check_disabled = ctx.cfg().is_balance_check_disabled(); + let is_eip3607_disabled = ctx.cfg().is_eip3607_disabled(); + let is_nonce_check_disabled = ctx.cfg().is_nonce_check_disabled(); + + if sponsor_invalid { + return Err(Self::Error::from_string("invalid sponsor signature".into())); + } + + let (tx, journal) = ctx.tx_journal_mut(); + if let Some(sponsor) = sponsor { + { + let caller = journal.load_account_code(caller_address)?.data; + validate_account_nonce_and_code( + &caller.info, + tx, + is_eip3607_disabled, + is_nonce_check_disabled, + )?; + + // Only validate that caller has enough balance for the value transfer. + // Do NOT pre-deduct the value - it will be transferred during execution. + // This matches the mainnet behavior where only gas is pre-deducted. + if !is_balance_check_disabled && caller.info.balance < total_value { + return Err(reth_revm::revm::context_interface::result::InvalidTransaction::LackOfFundForMaxFee { + fee: Box::new(total_value), + balance: Box::new(caller.info.balance), + } + .into()); + } + + if is_call { + caller.info.set_nonce(caller.info.nonce.saturating_add(1)); + } + } + + let effective_gas_price = tx.effective_gas_price(basefee); + let gas_cost = + U256::from(tx.gas_limit()).saturating_mul(U256::from(effective_gas_price)); + + let sponsor_account = journal.load_account_code(sponsor)?.data; + let sponsor_balance = sponsor_account.info.balance; + if !is_balance_check_disabled && sponsor_balance < gas_cost { + return Err(reth_revm::revm::context_interface::result::InvalidTransaction::LackOfFundForMaxFee { + fee: Box::new(gas_cost), + balance: Box::new(sponsor_balance), + } + .into()); + } + + let mut new_sponsor_balance = sponsor_balance.saturating_sub(gas_cost); + if is_balance_check_disabled { + new_sponsor_balance = new_sponsor_balance.max(gas_cost); + } + sponsor_account.info.set_balance(new_sponsor_balance); + // Mark the sponsor account as touched so state changes are included in the final state + journal.touch_account(sponsor); + } else { + let caller = journal.load_account_code(caller_address)?.data; + validate_account_nonce_and_code( + &caller.info, + tx, + is_eip3607_disabled, + is_nonce_check_disabled, + )?; + let new_caller_balance = calculate_caller_fee( + caller.info.balance, + tx, + basefee, + blob_price, + is_balance_check_disabled, + )?; + caller.info.set_balance(new_caller_balance); + if is_call { + caller.info.set_nonce(caller.info.nonce.saturating_add(1)); + } + } + + Ok(()) } fn first_frame_input( @@ -125,6 +248,55 @@ where self.inner.first_frame_input(evm, gas_limit) } + fn execution( + &mut self, + evm: &mut Self::Evm, + init_and_floor_gas: &InitialAndFloorGas, + ) -> Result { + let calls = match evm.ctx().tx().batch_calls() { + Some([]) => { + return Err(Self::Error::from_string( + "evnode transaction must include at least one call".into(), + )); + } + Some(calls) if calls.len() > 1 => calls.to_vec(), + _ => return self.inner.execution(evm, init_and_floor_gas), + }; + + let base_tx = evm.ctx().tx().clone(); + let gas_limit = base_tx.gas_limit(); + let checkpoint = evm.ctx_mut().journal_mut().checkpoint(); + let mut remaining_gas = gas_limit.saturating_sub(init_and_floor_gas.initial_gas); + let mut total_refunded: i64 = 0; + let mut last_result: Option = None; + + for call in &calls { + let mut call_tx = base_tx.clone(); + call_tx.set_batch_call(call); + evm.ctx_mut().set_tx(call_tx); + let first_frame_input = self.inner.first_frame_input(evm, remaining_gas)?; + let mut frame_result = self.inner.run_exec_loop(evm, first_frame_input)?; + let instruction_result = frame_result.interpreter_result().result; + total_refunded = total_refunded.saturating_add(frame_result.gas().refunded()); + remaining_gas = frame_result.gas().remaining(); + + if !instruction_result.is_ok() { + evm.ctx_mut().journal_mut().checkpoint_revert(checkpoint); + finalize_batch_gas(&mut frame_result, gas_limit, remaining_gas, 0); + return Ok(frame_result); + } + + last_result = Some(frame_result); + } + + evm.ctx_mut().journal_mut().checkpoint_commit(); + + let mut frame_result = last_result.expect("batch execution requires at least one call"); + finalize_batch_gas(&mut frame_result, gas_limit, remaining_gas, total_refunded); + + Ok(frame_result) + } + fn last_frame_result( &mut self, evm: &mut Self::Evm, @@ -165,7 +337,26 @@ where evm: &mut Self::Evm, exec_result: &mut ::FrameResult, ) -> Result<(), Self::Error> { - self.inner.reimburse_caller(evm, exec_result) + // For sponsored transactions, reimburse the sponsor instead of the caller + let sponsor = evm.ctx().tx().sponsor(); + if let Some(sponsor) = sponsor { + let gas = exec_result.gas(); + let basefee = evm.ctx().block().basefee() as u128; + let effective_gas_price = evm.ctx().tx().effective_gas_price(basefee); + let reimbursement = U256::from( + effective_gas_price + .saturating_mul((gas.remaining() + gas.refunded() as u64) as u128), + ); + let journal = evm.ctx_mut().journal_mut(); + let sponsor_account = journal.load_account(sponsor)?.data; + let new_balance = sponsor_account.info.balance.saturating_add(reimbursement); + sponsor_account.info.set_balance(new_balance); + // Mark the sponsor account as touched so state changes are included in the final state + journal.touch_account(sponsor); + Ok(()) + } else { + self.inner.reimburse_caller(evm, exec_result) + } } fn reward_beneficiary( @@ -197,30 +388,204 @@ where impl InspectorHandler for EvHandler> where EVM: InspectorEvmTr< - Context: ContextTr>, + Context: ContextTr, Tx: SponsorPayerTx + BatchCallsTx>, Frame = EthFrame, Inspector: Inspector<::Context, EthInterpreter>, >, + ::Context: ContextSetters, + <::Context as ContextTr>::Tx: Clone, ERROR: EvmTrError, { type IT = EthInterpreter; } +fn validate_account_nonce_and_code( + caller_info: &AccountInfo, + tx: &Tx, + is_eip3607_disabled: bool, + is_nonce_check_disabled: bool, +) -> Result<(), reth_revm::revm::context_interface::result::InvalidTransaction> +where + Tx: Transaction, +{ + if !is_eip3607_disabled { + let bytecode = match caller_info.code.as_ref() { + Some(code) => code, + None => &Bytecode::default(), + }; + if !bytecode.is_empty() && !bytecode.is_eip7702() { + return Err( + reth_revm::revm::context_interface::result::InvalidTransaction::RejectCallerWithCode, + ); + } + } + + if !is_nonce_check_disabled { + let tx_nonce = tx.nonce(); + let state_nonce = caller_info.nonce; + match tx_nonce.cmp(&state_nonce) { + Ordering::Greater => { + return Err( + reth_revm::revm::context_interface::result::InvalidTransaction::NonceTooHigh { + tx: tx_nonce, + state: state_nonce, + }, + ); + } + Ordering::Less => { + return Err( + reth_revm::revm::context_interface::result::InvalidTransaction::NonceTooLow { + tx: tx_nonce, + state: state_nonce, + }, + ); + } + Ordering::Equal => {} + } + } + + Ok(()) +} + +fn calculate_caller_fee( + balance: reth_revm::revm::primitives::U256, + tx: &Tx, + basefee: u128, + blob_price: u128, + is_balance_check_disabled: bool, +) -> Result< + reth_revm::revm::primitives::U256, + reth_revm::revm::context_interface::result::InvalidTransaction, +> +where + Tx: Transaction, +{ + let effective_balance_spending = tx + .effective_balance_spending(basefee, blob_price) + .expect("effective balance is always smaller than max balance so it can't overflow"); + if !is_balance_check_disabled && balance < effective_balance_spending { + return Err( + reth_revm::revm::context_interface::result::InvalidTransaction::LackOfFundForMaxFee { + fee: Box::new(effective_balance_spending), + balance: Box::new(balance), + }, + ); + } + + let gas_balance_spending = effective_balance_spending - tx.value(); + + let mut new_balance = balance.saturating_sub(gas_balance_spending); + + if is_balance_check_disabled { + new_balance = new_balance.max(tx.value()); + } + + Ok(new_balance) +} + +fn validate_batch_initial_tx_gas( + tx: &Tx, + calls: &[ev_primitives::Call], + spec: SpecId, + is_eip7623_disabled: bool, +) -> Result { + let mut initial_gas = 0u64; + let mut floor_gas = 0u64; + + for call in calls { + let call_gas = + calculate_initial_tx_gas(spec, call.input.as_ref(), call.to.is_create(), 0, 0, 0); + initial_gas = initial_gas.saturating_add(call_gas.initial_gas); + floor_gas = floor_gas.saturating_add(call_gas.floor_gas); + } + + let mut accounts = 0u64; + let mut storages = 0u64; + if tx.tx_type() != TransactionType::Legacy { + if let Some(access_list) = tx.access_list() { + (accounts, storages) = access_list.fold((0u64, 0u64), |(mut acc, mut stor), item| { + acc = acc.saturating_add(1); + stor = stor.saturating_add(item.storage_slots().count() as u64); + (acc, stor) + }); + } + } + + initial_gas = initial_gas + .saturating_add(accounts.saturating_mul(ACCESS_LIST_ADDRESS)) + .saturating_add(storages.saturating_mul(ACCESS_LIST_STORAGE_KEY)); + + if spec.is_enabled_in(SpecId::PRAGUE) { + initial_gas = initial_gas.saturating_add( + (tx.authorization_list_len() as u64).saturating_mul(eip7702::PER_EMPTY_ACCOUNT_COST), + ); + } else { + floor_gas = 0; + } + + if is_eip7623_disabled { + floor_gas = 0; + } + + if initial_gas > tx.gas_limit() { + return Err( + reth_revm::revm::context_interface::result::InvalidTransaction::CallGasCostMoreThanGasLimit { + gas_limit: tx.gas_limit(), + initial_gas, + }, + ); + } + + if spec.is_enabled_in(SpecId::PRAGUE) && floor_gas > tx.gas_limit() { + return Err( + reth_revm::revm::context_interface::result::InvalidTransaction::GasFloorMoreThanGasLimit { + gas_floor: floor_gas, + gas_limit: tx.gas_limit(), + }, + ); + } + + Ok(InitialAndFloorGas::new(initial_gas, floor_gas)) +} + +fn finalize_batch_gas( + frame_result: &mut FrameResult, + tx_gas_limit: u64, + remaining_gas: u64, + refund: i64, +) { + let instruction_result = frame_result.interpreter_result().result; + let mut gas = Gas::new_spent(tx_gas_limit); + if instruction_result.is_ok_or_revert() { + gas.erase_cost(remaining_gas); + } + if instruction_result.is_ok() { + gas.record_refund(refund); + } + *frame_result.gas_mut() = gas; +} + #[cfg(test)] mod tests { use super::*; - use crate::EvEvm; - use alloy_primitives::{address, Address, Bytes, TxKind, U256}; + use crate::{DeployAllowlistSettings, EvEvm, EvTxEnv, EvTxEvmFactory}; + use alloy_primitives::{address, Address, Bytes, TxKind, B256, U256}; + use ev_primitives::Call; use reth_revm::{ inspector::NoOpInspector, revm::{ context::Context, - database::EmptyDB, + context_interface::{ + result::ExecutionResult, + transaction::{AccessList, AccessListItem, TransactionType}, + }, + database::{CacheDB, EmptyDB}, handler::{EthFrame, FrameResult}, interpreter::{CallOutcome, Gas, InstructionResult, InterpreterResult}, - primitives::hardfork::SpecId, + primitives::{hardfork::SpecId, KECCAK_EMPTY}, + state::{AccountInfo, EvmState}, }, - MainContext, + MainContext, State, }; use std::convert::Infallible; @@ -231,10 +596,16 @@ mod tests { type TestError = EVMError; type TestHandler = EvHandler>; - use reth_revm::revm::context::{BlockEnv, CfgEnv, TxEnv}; + use alloy_evm::{Evm, EvmEnv, EvmFactory}; + use reth_revm::revm::{ + bytecode::Bytecode as RevmBytecode, + context::{BlockEnv, CfgEnv, TxEnv}, + }; const BASE_FEE: u64 = 100; const GAS_PRICE: u128 = 200; + const STORAGE_RUNTIME: [u8; 6] = [0x60, 0x01, 0x60, 0x00, 0x55, 0x00]; + const REVERT_RUNTIME: [u8; 5] = [0x60, 0x00, 0x60, 0x00, 0xfd]; #[test] fn reward_beneficiary_redirects_base_fee_sink() { @@ -284,6 +655,308 @@ mod tests { assert!(beneficiary_balance.is_zero()); } + #[test] + fn batch_initial_gas_sums_calls_and_access_list() { + let tx_env = TxEnv { + gas_limit: 1_000_000, + tx_type: TransactionType::Eip1559.into(), + access_list: AccessList(vec![AccessListItem { + address: address!("0x00000000000000000000000000000000000000aa"), + storage_keys: vec![B256::ZERO, B256::from([0x11; 32])], + }]), + ..Default::default() + }; + + let calls = vec![ + Call { + to: TxKind::Call(address!("0x00000000000000000000000000000000000000bb")), + value: U256::ZERO, + input: Bytes::new(), + }, + Call { + to: TxKind::Call(address!("0x00000000000000000000000000000000000000cc")), + value: U256::ZERO, + input: Bytes::from(vec![0x01, 0x00, 0x02]), + }, + ]; + + let gas_call_1 = + calculate_initial_tx_gas(SpecId::PRAGUE, calls[0].input.as_ref(), false, 0, 0, 0); + let gas_call_2 = + calculate_initial_tx_gas(SpecId::PRAGUE, calls[1].input.as_ref(), false, 0, 0, 0); + let access_list_cost = ACCESS_LIST_ADDRESS + 2 * ACCESS_LIST_STORAGE_KEY; + + let result = validate_batch_initial_tx_gas(&tx_env, &calls, SpecId::PRAGUE, false) + .expect("batch gas should validate"); + + let expected_initial = gas_call_1 + .initial_gas + .saturating_add(gas_call_2.initial_gas) + .saturating_add(access_list_cost); + let expected_floor = gas_call_1.floor_gas.saturating_add(gas_call_2.floor_gas); + + assert_eq!(result.initial_gas, expected_initial); + assert_eq!(result.floor_gas, expected_floor); + } + + #[test] + fn batch_initial_gas_rejects_when_gas_limit_too_low() { + let tx_env = TxEnv { + gas_limit: 10_000, + ..Default::default() + }; + + let calls = vec![Call { + to: TxKind::Call(address!("0x00000000000000000000000000000000000000dd")), + value: U256::ZERO, + input: Bytes::from(vec![0x11; 64]), + }]; + + let err = validate_batch_initial_tx_gas(&tx_env, &calls, SpecId::CANCUN, false) + .expect_err("should reject when gas limit is too low"); + + assert!(matches!( + err, + InvalidTransaction::CallGasCostMoreThanGasLimit { .. } + )); + } + + #[test] + fn batch_execution_reverts_state_on_failure() { + let caller = address!("0x0000000000000000000000000000000000000aaa"); + let storage_contract = address!("0x0000000000000000000000000000000000000bbb"); + let revert_contract = address!("0x0000000000000000000000000000000000000ccc"); + + let mut state = State::builder() + .with_database(CacheDB::::default()) + .with_bundle_update() + .build(); + + state.insert_account( + caller, + AccountInfo { + balance: U256::from(10_000_000_000u64), + nonce: 0, + code_hash: KECCAK_EMPTY, + code: None, + }, + ); + + state.insert_account( + storage_contract, + AccountInfo { + balance: U256::ZERO, + nonce: 1, + code_hash: alloy_primitives::keccak256(STORAGE_RUNTIME.as_slice()), + code: Some(RevmBytecode::new_raw(Bytes::copy_from_slice( + STORAGE_RUNTIME.as_slice(), + ))), + }, + ); + + state.insert_account( + revert_contract, + AccountInfo { + balance: U256::ZERO, + nonce: 1, + code_hash: alloy_primitives::keccak256(REVERT_RUNTIME.as_slice()), + code: Some(RevmBytecode::new_raw(Bytes::copy_from_slice( + REVERT_RUNTIME.as_slice(), + ))), + }, + ); + + let mut evm_env: EvmEnv = EvmEnv::default(); + evm_env.cfg_env.chain_id = 1; + evm_env.cfg_env.spec = SpecId::CANCUN; + evm_env.block_env.basefee = 1; + evm_env.block_env.gas_limit = 30_000_000; + evm_env.block_env.number = U256::from(1); + + let mut evm = EvTxEvmFactory::default().create_evm(state, evm_env); + + let calls = vec![ + Call { + to: TxKind::Call(storage_contract), + value: U256::ZERO, + input: Bytes::new(), + }, + Call { + to: TxKind::Call(revert_contract), + value: U256::ZERO, + input: Bytes::new(), + }, + ]; + + let tx_env = TxEnv { + caller, + gas_limit: 200_000, + gas_price: 1, + gas_priority_fee: Some(1), + chain_id: Some(1), + tx_type: TransactionType::Eip1559.into(), + ..Default::default() + }; + + let tx = EvTxEnv::with_calls(tx_env, calls); + + let result_and_state = evm + .transact_raw(tx) + .expect("batch execution should complete"); + + assert!(matches!( + result_and_state.result, + ExecutionResult::Revert { .. } + )); + + let state: EvmState = result_and_state.state; + let storage_account = state + .get(&storage_contract) + .expect("storage contract should be loaded"); + if let Some(slot) = storage_account.storage.get(&U256::ZERO) { + assert!(slot.original_value.is_zero()); + assert!(slot.present_value.is_zero()); + assert!(!slot.is_changed()); + } + } + + #[test] + fn batch_execution_commits_state_on_success() { + let caller = address!("0x0000000000000000000000000000000000000aaa"); + let storage_contract = address!("0x0000000000000000000000000000000000000bbb"); + + let mut state = State::builder() + .with_database(CacheDB::::default()) + .with_bundle_update() + .build(); + + state.insert_account( + caller, + AccountInfo { + balance: U256::from(10_000_000_000u64), + nonce: 0, + code_hash: KECCAK_EMPTY, + code: None, + }, + ); + + state.insert_account( + storage_contract, + AccountInfo { + balance: U256::ZERO, + nonce: 1, + code_hash: alloy_primitives::keccak256(STORAGE_RUNTIME.as_slice()), + code: Some(RevmBytecode::new_raw(Bytes::copy_from_slice( + STORAGE_RUNTIME.as_slice(), + ))), + }, + ); + + let mut evm_env: EvmEnv = EvmEnv::default(); + evm_env.cfg_env.chain_id = 1; + evm_env.cfg_env.spec = SpecId::CANCUN; + evm_env.block_env.basefee = 1; + evm_env.block_env.gas_limit = 30_000_000; + evm_env.block_env.number = U256::from(1); + + let mut evm = EvTxEvmFactory::default().create_evm(state, evm_env); + + let calls = vec![ + Call { + to: TxKind::Call(storage_contract), + value: U256::ZERO, + input: Bytes::new(), + }, + Call { + to: TxKind::Call(storage_contract), + value: U256::ZERO, + input: Bytes::new(), + }, + ]; + + let tx_env = TxEnv { + caller, + gas_limit: 200_000, + gas_price: 1, + gas_priority_fee: Some(1), + chain_id: Some(1), + tx_type: TransactionType::Eip1559.into(), + ..Default::default() + }; + + let tx = EvTxEnv::with_calls(tx_env, calls); + + let result_and_state = evm + .transact_raw(tx) + .expect("batch execution should complete"); + + assert!(matches!( + result_and_state.result, + ExecutionResult::Success { .. } + )); + + let state: EvmState = result_and_state.state; + let storage_account = state + .get(&storage_contract) + .expect("storage contract should be loaded"); + let slot = storage_account + .storage + .get(&U256::ZERO) + .expect("slot 0 should be written"); + assert_eq!(slot.present_value, U256::from(1)); + assert!(slot.is_changed()); + } + + #[test] + fn batch_execution_rejects_empty_calls() { + let caller = address!("0x0000000000000000000000000000000000000aaa"); + + let mut state = State::builder() + .with_database(CacheDB::::default()) + .with_bundle_update() + .build(); + + state.insert_account( + caller, + AccountInfo { + balance: U256::from(10_000_000_000u64), + nonce: 0, + code_hash: KECCAK_EMPTY, + code: None, + }, + ); + + let mut evm_env: EvmEnv = EvmEnv::default(); + evm_env.cfg_env.chain_id = 1; + evm_env.cfg_env.spec = SpecId::CANCUN; + evm_env.block_env.basefee = 1; + evm_env.block_env.gas_limit = 30_000_000; + evm_env.block_env.number = U256::from(1); + + let mut evm = EvTxEvmFactory::default().create_evm(state, evm_env); + + let tx_env = TxEnv { + caller, + gas_limit: 200_000, + gas_price: 1, + gas_priority_fee: Some(1), + chain_id: Some(1), + tx_type: TransactionType::Eip1559.into(), + ..Default::default() + }; + + let tx = EvTxEnv::with_calls(tx_env, Vec::new()); + + let err = evm + .transact_raw(tx) + .expect_err("empty call batch should reject"); + assert!( + err.to_string() + .contains("evnode transaction must include at least one call"), + "unexpected error: {err:?}" + ); + } + #[test] fn reject_deploy_for_non_allowlisted_caller() { let allowlisted = address!("0x00000000000000000000000000000000000000aa"); diff --git a/crates/ev-revm/src/lib.rs b/crates/ev-revm/src/lib.rs index 11fe505..f0b120d 100644 --- a/crates/ev-revm/src/lib.rs +++ b/crates/ev-revm/src/lib.rs @@ -8,6 +8,8 @@ pub mod deploy; pub mod evm; pub mod factory; pub mod handler; +/// EV-specific transaction environment extensions. +pub mod tx_env; pub use api::EvBuilder; pub use base_fee::{BaseFeeRedirect, BaseFeeRedirectError}; @@ -16,6 +18,7 @@ pub use deploy::DeployAllowlistSettings; pub use evm::{DefaultEvEvm, EvEvm}; pub use factory::{ with_ev_handler, BaseFeeRedirectSettings, ContractSizeLimitSettings, EvEvmFactory, - MintPrecompileSettings, + EvTxEvmFactory, MintPrecompileSettings, }; pub use handler::EvHandler; +pub use tx_env::EvTxEnv; diff --git a/crates/ev-revm/src/tx_env.rs b/crates/ev-revm/src/tx_env.rs new file mode 100644 index 0000000..b5d820a --- /dev/null +++ b/crates/ev-revm/src/tx_env.rs @@ -0,0 +1,446 @@ +use alloy_evm::{FromRecoveredTx, FromTxWithEncoded}; +use alloy_primitives::{Address, Bytes, U256}; +use ev_primitives::{Call, EvTxEnvelope}; +use reth_evm::TransactionEnv; +use reth_revm::revm::{ + context::TxEnv, + context_interface::{ + either::Either, + transaction::{ + AccessList, AccessListItem, RecoveredAuthorization, SignedAuthorization, + Transaction as RevmTransaction, TransactionType, + }, + }, + handler::SystemCallTx, + primitives::{Address as RevmAddress, Bytes as RevmBytes, TxKind, B256}, +}; + +/// Transaction environment wrapper that supports `EvTxEnvelope` conversions. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct EvTxEnv { + inner: TxEnv, + sponsor: Option
, + sponsor_signature_invalid: bool, + calls: Vec, + batch_value: U256, + is_evnode: bool, +} + +impl EvTxEnv { + /// Wrap a `TxEnv` with EV-specific metadata. + pub const fn new(inner: TxEnv) -> Self { + Self { + batch_value: inner.value, + inner, + sponsor: None, + sponsor_signature_invalid: false, + calls: Vec::new(), + is_evnode: false, + } + } + + /// Returns the underlying `TxEnv`. + pub const fn inner(&self) -> &TxEnv { + &self.inner + } + + /// Returns the underlying `TxEnv` mutably. + pub fn inner_mut(&mut self) -> &mut TxEnv { + &mut self.inner + } + + /// Returns the recovered sponsor address, if any. + pub const fn sponsor(&self) -> Option
{ + self.sponsor + } + + /// Returns whether the sponsor signature was invalid. + pub const fn sponsor_signature_invalid(&self) -> bool { + self.sponsor_signature_invalid + } + + /// Returns the batch calls for this transaction. + pub fn calls(&self) -> &[Call] { + &self.calls + } + + /// Returns the total value across all calls. + pub const fn batch_value(&self) -> U256 { + self.batch_value + } + + /// Updates the inner `TxEnv` to represent a single call from the batch. + pub fn set_call(&mut self, call: &Call) { + self.inner.kind = call.to; + self.inner.value = call.value; + self.inner.data = call.input.clone(); + } +} + +#[cfg(test)] +impl EvTxEnv { + /// Test helper to build an `EvTxEnv` with batch calls pre-populated. + pub fn with_calls(mut inner: TxEnv, calls: Vec) -> Self { + let batch_value = calls + .iter() + .fold(U256::ZERO, |acc, call| acc.saturating_add(call.value)); + if let Some(first) = calls.first() { + inner.kind = first.to; + inner.data = first.input.clone(); + } + inner.value = batch_value; + let mut env = Self::new(inner); + env.calls = calls; + env.batch_value = batch_value; + env.is_evnode = true; + env + } +} + +impl From for EvTxEnv { + fn from(inner: TxEnv) -> Self { + Self { + batch_value: inner.value, + inner, + sponsor: None, + sponsor_signature_invalid: false, + calls: Vec::new(), + is_evnode: false, + } + } +} + +impl From for TxEnv { + fn from(env: EvTxEnv) -> Self { + env.inner + } +} + +impl RevmTransaction for EvTxEnv { + type AccessListItem<'a> + = &'a AccessListItem + where + Self: 'a; + type Authorization<'a> + = &'a Either + where + Self: 'a; + + fn tx_type(&self) -> u8 { + self.inner.tx_type + } + + fn caller(&self) -> RevmAddress { + self.inner.caller + } + + fn gas_limit(&self) -> u64 { + self.inner.gas_limit + } + + fn value(&self) -> U256 { + self.inner.value + } + + fn input(&self) -> &RevmBytes { + &self.inner.data + } + + fn nonce(&self) -> u64 { + self.inner.nonce + } + + fn kind(&self) -> TxKind { + self.inner.kind + } + + fn chain_id(&self) -> Option { + self.inner.chain_id + } + + fn gas_price(&self) -> u128 { + self.inner.gas_price + } + + fn access_list(&self) -> Option>> { + Some(self.inner.access_list.0.iter()) + } + + fn blob_versioned_hashes(&self) -> &[B256] { + &self.inner.blob_hashes + } + + fn max_fee_per_blob_gas(&self) -> u128 { + self.inner.max_fee_per_blob_gas + } + + fn authorization_list_len(&self) -> usize { + self.inner.authorization_list.len() + } + + fn authorization_list(&self) -> impl Iterator> { + self.inner.authorization_list.iter() + } + + fn max_priority_fee_per_gas(&self) -> Option { + self.inner.gas_priority_fee + } +} + +impl TransactionEnv for EvTxEnv { + fn set_gas_limit(&mut self, gas_limit: u64) { + self.inner.gas_limit = gas_limit; + } + + fn nonce(&self) -> u64 { + self.inner.nonce + } + + fn set_nonce(&mut self, nonce: u64) { + self.inner.nonce = nonce; + } + + fn set_access_list(&mut self, access_list: AccessList) { + self.inner.access_list = access_list; + } +} + +impl alloy_evm::ToTxEnv for EvTxEnv { + fn to_tx_env(&self) -> Self { + self.clone() + } +} + +impl FromRecoveredTx for EvTxEnv { + fn from_recovered_tx(tx: &EvTxEnvelope, sender: Address) -> Self { + match tx { + EvTxEnvelope::Ethereum(inner) => Self::new(TxEnv::from_recovered_tx(inner, sender)), + EvTxEnvelope::EvNode(ev) => { + let (sponsor, sponsor_signature_invalid) = + if let Some(signature) = ev.tx().fee_payer_signature.as_ref() { + match ev.tx().recover_sponsor(sender, signature) { + Ok(sponsor) => (Some(sponsor), false), + Err(_) => (None, true), + } + } else { + (None, false) + }; + let calls = ev.tx().calls.clone(); + let batch_value = calls + .iter() + .fold(U256::ZERO, |acc, call| acc.saturating_add(call.value)); + let env = TxEnv { + caller: sender, + gas_limit: ev.tx().gas_limit, + gas_price: ev.tx().max_fee_per_gas, + gas_priority_fee: Some(ev.tx().max_priority_fee_per_gas), + kind: ev + .tx() + .calls + .first() + .map(|call| call.to) + .unwrap_or(TxKind::Create), + value: batch_value, + data: ev + .tx() + .calls + .first() + .map(|call| call.input.clone()) + .unwrap_or_default(), + nonce: ev.tx().nonce, + chain_id: Some(ev.tx().chain_id), + access_list: ev.tx().access_list.clone(), + tx_type: TransactionType::Eip1559.into(), + ..Default::default() + }; + Self { + inner: env, + sponsor, + sponsor_signature_invalid, + calls, + batch_value, + is_evnode: true, + } + } + } + } +} + +impl FromTxWithEncoded for EvTxEnv { + fn from_encoded_tx(tx: &EvTxEnvelope, caller: Address, _encoded: Bytes) -> Self { + Self::from_recovered_tx(tx, caller) + } +} + +impl SystemCallTx for EvTxEnv { + fn new_system_tx_with_caller( + caller: Address, + system_contract_address: Address, + data: Bytes, + ) -> Self { + Self::new( + TxEnv::builder() + .caller(caller) + .data(data) + .kind(TxKind::Call(system_contract_address)) + .gas_limit(30_000_000) + .build() + .unwrap(), + ) + } +} + +/// Exposes the optional sponsor payer for EV-specific transactions. +pub trait SponsorPayerTx { + /// Returns the sponsor address, if any. + fn sponsor(&self) -> Option
; + /// Returns whether the sponsor signature was invalid. + fn sponsor_signature_invalid(&self) -> bool; +} + +/// Batch-call helpers for EV transactions. +pub trait BatchCallsTx { + /// Returns the batch calls, if present. + fn batch_calls(&self) -> Option<&[Call]>; + /// Returns the total value across all calls. + fn batch_total_value(&self) -> U256; + /// Sets the inner `TxEnv` to the given call. + fn set_batch_call(&mut self, call: &Call); +} + +impl SponsorPayerTx for EvTxEnv { + fn sponsor(&self) -> Option
{ + self.sponsor + } + + fn sponsor_signature_invalid(&self) -> bool { + self.sponsor_signature_invalid + } +} + +impl BatchCallsTx for EvTxEnv { + fn batch_calls(&self) -> Option<&[Call]> { + if self.is_evnode || !self.calls.is_empty() { + Some(&self.calls) + } else { + None + } + } + + fn batch_total_value(&self) -> U256 { + self.batch_value + } + + fn set_batch_call(&mut self, call: &Call) { + self.set_call(call); + } +} + +impl SponsorPayerTx for TxEnv { + fn sponsor(&self) -> Option
{ + None + } + + fn sponsor_signature_invalid(&self) -> bool { + false + } +} + +impl BatchCallsTx for TxEnv { + fn batch_calls(&self) -> Option<&[Call]> { + None + } + + fn batch_total_value(&self) -> U256 { + self.value + } + + fn set_batch_call(&mut self, call: &Call) { + self.kind = call.to; + self.value = call.value; + self.data = call.input.clone(); + } +} + +#[cfg(test)] +mod tests { + use super::{BatchCallsTx, EvTxEnv}; + use alloy_evm::FromRecoveredTx; + use alloy_primitives::{Address, Bytes, Signature, TxKind, U256}; + use ev_primitives::{Call, EvNodeSignedTx, EvNodeTransaction, EvTxEnvelope}; + + fn sample_evnode_tx() -> EvNodeTransaction { + EvNodeTransaction { + chain_id: 1, + nonce: 1, + max_priority_fee_per_gas: 1, + max_fee_per_gas: 2, + gas_limit: 21_000, + calls: vec![Call { + to: TxKind::Call(Address::ZERO), + value: U256::ZERO, + input: Bytes::default(), + }], + access_list: Default::default(), + fee_payer_signature: None, + } + } + + fn signature_with_parity(v: u8, r: u8, s: u8) -> Signature { + let mut bytes = [0u8; 65]; + bytes[0] = r; + bytes[32] = s; + bytes[64] = v; + Signature::from_raw_array(&bytes).expect("valid parity") + } + + #[test] + fn from_recovered_tx_marks_invalid_sponsor_signature() { + let executor = Address::from([0x11; 20]); + let mut tx = sample_evnode_tx(); + tx.fee_payer_signature = Some(signature_with_parity(27, 0, 0)); + + let signed = EvNodeSignedTx::new_unhashed(tx, signature_with_parity(27, 1, 1)); + let env = EvTxEnv::from_recovered_tx(&EvTxEnvelope::EvNode(signed), executor); + + assert!( + env.sponsor().is_none(), + "invalid signature should not recover sponsor" + ); + assert!( + env.sponsor_signature_invalid(), + "invalid signature should be flagged" + ); + } + + #[test] + fn from_recovered_tx_allows_missing_sponsor_signature() { + let executor = Address::from([0x22; 20]); + let tx = sample_evnode_tx(); + + let signed = EvNodeSignedTx::new_unhashed(tx, signature_with_parity(27, 1, 1)); + let env = EvTxEnv::from_recovered_tx(&EvTxEnvelope::EvNode(signed), executor); + + assert!(env.sponsor().is_none()); + assert!(!env.sponsor_signature_invalid()); + } + + #[test] + fn batch_calls_exposes_empty_evnode_calls() { + let executor = Address::from([0x33; 20]); + let mut tx = sample_evnode_tx(); + tx.calls = Vec::new(); + + let signed = EvNodeSignedTx::new_unhashed(tx, signature_with_parity(27, 1, 1)); + let env = EvTxEnv::from_recovered_tx(&EvTxEnvelope::EvNode(signed), executor); + + let calls = env.batch_calls().expect("evnode should expose batch calls"); + assert!(calls.is_empty()); + } + + #[test] + fn batch_calls_omits_non_evnode_calls() { + let env = EvTxEnv::from(reth_revm::revm::context::TxEnv::default()); + assert!(env.batch_calls().is_none()); + } +} diff --git a/crates/evolve/Cargo.toml b/crates/evolve/Cargo.toml index b9d2dcb..98871ac 100644 --- a/crates/evolve/Cargo.toml +++ b/crates/evolve/Cargo.toml @@ -23,6 +23,7 @@ reth-node-api.workspace = true reth-ethereum = { workspace = true, features = ["node-api", "node"] } reth-ethereum-primitives.workspace = true reth-execution-types.workspace = true +ev-primitives = { path = "../ev-primitives" } # Alloy dependencies alloy-rpc-types-engine.workspace = true diff --git a/crates/evolve/src/consensus.rs b/crates/evolve/src/consensus.rs index e74ed4e..8c107bd 100644 --- a/crates/evolve/src/consensus.rs +++ b/crates/evolve/src/consensus.rs @@ -1,5 +1,6 @@ //! Evolve custom consensus implementation that allows same timestamps across blocks. +use ev_primitives::{Block, BlockBody, EvPrimitives, Receipt}; use reth_chainspec::ChainSpec; use reth_consensus::{Consensus, ConsensusError, FullConsensus, HeaderValidator}; use reth_consensus_common::validation::{ @@ -8,10 +9,9 @@ use reth_consensus_common::validation::{ }; use reth_ethereum::node::builder::{components::ConsensusBuilder, BuilderContext}; use reth_ethereum_consensus::EthBeaconConsensus; -use reth_ethereum_primitives::{Block, BlockBody, EthPrimitives, Receipt}; use reth_execution_types::BlockExecutionResult; use reth_node_api::{FullNodeTypes, NodeTypes}; -use reth_primitives::{RecoveredBlock, SealedBlock, SealedHeader}; +use reth_primitives_traits::{RecoveredBlock, SealedBlock, SealedHeader}; use std::sync::Arc; /// Builder for `EvolveConsensus` @@ -34,9 +34,9 @@ impl EvolveConsensusBuilder { impl ConsensusBuilder for EvolveConsensusBuilder where Node: FullNodeTypes, - Node::Types: NodeTypes, + Node::Types: NodeTypes, { - type Consensus = Arc>; + type Consensus = Arc>; async fn build_consensus(self, ctx: &BuilderContext) -> eyre::Result { Ok(Arc::new(EvolveConsensus::new(ctx.chain_spec())) as Self::Consensus) @@ -107,18 +107,18 @@ impl Consensus for EvolveConsensus { validate_body_against_header(body, header.header()) } - fn validate_block_pre_execution(&self, block: &SealedBlock) -> Result<(), Self::Error> { + fn validate_block_pre_execution(&self, block: &SealedBlock) -> Result<(), Self::Error> { // Use inner consensus for pre-execution validation self.inner.validate_block_pre_execution(block) } } -impl FullConsensus for EvolveConsensus { +impl FullConsensus for EvolveConsensus { fn validate_block_post_execution( &self, block: &RecoveredBlock, result: &BlockExecutionResult, ) -> Result<(), ConsensusError> { - as FullConsensus>::validate_block_post_execution(&self.inner, block, result) + as FullConsensus>::validate_block_post_execution(&self.inner, block, result) } } diff --git a/crates/evolve/src/types.rs b/crates/evolve/src/types.rs index b8f23a2..9084360 100644 --- a/crates/evolve/src/types.rs +++ b/crates/evolve/src/types.rs @@ -1,5 +1,5 @@ use alloy_primitives::{Address, B256}; -use reth_primitives::TransactionSigned; +use ev_primitives::TransactionSigned; use serde::{Deserialize, Serialize}; /// Payload attributes for the Evolve Reth node diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index 635ec38..03d8bcf 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -13,6 +13,7 @@ description = "Evolve node implementation" ev-common = { path = "../common" } evolve-ev-reth = { path = "../evolve" } ev-revm = { path = "../ev-revm" } +ev-primitives = { path = "../ev-primitives" } # Reth dependencies reth-node-builder.workspace = true @@ -31,6 +32,9 @@ reth-basic-payload-builder.workspace = true reth-engine-local.workspace = true reth-revm.workspace = true reth-trie-db.workspace = true +reth-storage-api.workspace = true +reth-transaction-pool.workspace = true +reth-codecs.workspace = true # Additional reth dependencies for payload builder reth-node-types.workspace = true @@ -43,17 +47,25 @@ reth-node-core.workspace = true reth-rpc-builder.workspace = true reth-rpc-api.workspace = true reth-rpc-engine-api.workspace = true +reth-rpc-convert.workspace = true +reth-rpc.workspace = true +reth-rpc-eth-api.workspace = true +reth-rpc-eth-types.workspace = true reth-engine-primitives.workspace = true reth-ethereum-primitives.workspace = true # Alloy dependencies alloy-rpc-types.workspace = true alloy-rpc-types-engine.workspace = true +alloy-rpc-types-eth.workspace = true alloy-primitives.workspace = true alloy-eips.workspace = true alloy-consensus.workspace = true +alloy-consensus-any.workspace = true alloy-evm.workspace = true alloy-genesis.workspace = true +alloy-network.workspace = true +c-kzg = "2.1.5" # Core dependencies eyre.workspace = true diff --git a/crates/node/src/attributes.rs b/crates/node/src/attributes.rs index ed7b3e6..06efcde 100644 --- a/crates/node/src/attributes.rs +++ b/crates/node/src/attributes.rs @@ -6,15 +6,13 @@ use alloy_rpc_types::{ }; use reth_chainspec::EthereumHardforks; use reth_engine_local::payload::LocalPayloadAttributesBuilder; -use reth_ethereum::{ - node::api::payload::{PayloadAttributes, PayloadBuilderAttributes}, - TransactionSigned, -}; +use reth_ethereum::node::api::payload::{PayloadAttributes, PayloadBuilderAttributes}; use reth_payload_builder::EthPayloadBuilderAttributes; use reth_payload_primitives::PayloadAttributesBuilder; use serde::{Deserialize, Serialize}; use crate::error::EvolveEngineError; +use ev_primitives::TransactionSigned; /// Evolve payload attributes that support passing transactions via Engine API. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/node/src/builder.rs b/crates/node/src/builder.rs index c29c3d6..64586ac 100644 --- a/crates/node/src/builder.rs +++ b/crates/node/src/builder.rs @@ -1,8 +1,7 @@ -use crate::config::EvolvePayloadBuilderConfig; -use alloy_consensus::transaction::Transaction; -use alloy_evm::eth::EthEvmFactory; +use crate::{config::EvolvePayloadBuilderConfig, executor::EvEvmConfig}; +use alloy_consensus::transaction::{Transaction, TxHashRef}; use alloy_primitives::Address; -use ev_revm::EvEvmFactory; +use ev_revm::EvTxEvmFactory; use evolve_ev_reth::EvolvePayloadAttributes; use reth_chainspec::{ChainSpec, ChainSpecProvider}; use reth_errors::RethError; @@ -10,15 +9,15 @@ use reth_evm::{ execute::{BlockBuilder, BlockBuilderOutcome}, ConfigureEvm, NextBlockEnvAttributes, }; -use reth_evm_ethereum::EthEvmConfig; use reth_payload_builder_primitives::PayloadBuilderError; -use reth_primitives::{transaction::SignedTransaction, Header, SealedBlock, SealedHeader}; +use reth_primitives::{transaction::SignedTransaction, Header, SealedHeader}; +use reth_primitives_traits::SealedBlock; use reth_provider::{HeaderProvider, StateProviderFactory}; use reth_revm::{database::StateProviderDatabase, State}; use std::sync::Arc; use tracing::{debug, info}; -type EvolveEthEvmConfig = EthEvmConfig>; +type EvolveEthEvmConfig = EvEvmConfig; /// Payload builder for Evolve Reth node #[derive(Debug)] @@ -66,7 +65,7 @@ where pub async fn build_payload( &self, attributes: EvolvePayloadAttributes, - ) -> Result { + ) -> Result, PayloadBuilderError> { // Validate attributes attributes .validate() @@ -142,12 +141,12 @@ where ); for (i, tx) in attributes.transactions.iter().enumerate() { tracing::debug!( - index = i, - hash = ?tx.hash(), - nonce = tx.nonce(), - gas_price = ?tx.gas_price(), - gas_limit = tx.gas_limit(), - "Processing transaction" + index = i, + hash = ?tx.tx_hash(), + nonce = tx.nonce(), + gas_price = ?tx.gas_price(), + gas_limit = tx.gas_limit(), + "Processing transaction" ); // Convert to recovered transaction for execution diff --git a/crates/node/src/evm_executor.rs b/crates/node/src/evm_executor.rs new file mode 100644 index 0000000..008e44c --- /dev/null +++ b/crates/node/src/evm_executor.rs @@ -0,0 +1,315 @@ +use std::{borrow::Cow, boxed::Box, vec::Vec}; + +use alloy_consensus::{Transaction, TxReceipt}; +use alloy_eips::{eip7685::Requests, Encodable2718}; +use alloy_evm::{ + block::{ + state_changes::{balance_increment_state, post_block_balance_increments}, + BlockExecutionError, BlockExecutionResult, BlockExecutor, BlockExecutorFactory, + BlockExecutorFor, BlockValidationError, ExecutableTx, OnStateHook, + StateChangePostBlockSource, StateChangeSource, SystemCaller, + }, + eth::{ + dao_fork, eip6110, + receipt_builder::{ReceiptBuilder, ReceiptBuilderCtx}, + spec::{EthExecutorSpec, EthSpec}, + EthBlockExecutionCtx, + }, + Database, EthEvmFactory, Evm, EvmFactory, FromRecoveredTx, FromTxWithEncoded, +}; +use alloy_primitives::Log; +use ev_primitives::{Receipt, TransactionSigned}; +use reth_codecs::alloy::transaction::Envelope; +use reth_ethereum_forks::EthereumHardfork; +use reth_revm::revm::{ + context_interface::result::ResultAndState, database::State, DatabaseCommit, Inspector, +}; + +/// Receipt builder that works with Ev transaction envelopes. +#[derive(Debug, Clone, Copy, Default)] +#[non_exhaustive] +pub struct EvReceiptBuilder; + +impl ReceiptBuilder for EvReceiptBuilder { + type Transaction = TransactionSigned; + type Receipt = Receipt; + + fn build_receipt( + &self, + ctx: ReceiptBuilderCtx<'_, Self::Transaction, E>, + ) -> Self::Receipt { + let ReceiptBuilderCtx { + tx, + result, + cumulative_gas_used, + .. + } = ctx; + Receipt { + tx_type: tx.tx_type(), + success: result.is_success(), + cumulative_gas_used, + logs: result.into_logs(), + } + } +} + +/// Block executor for EV transactions. +#[derive(Debug)] +pub struct EvBlockExecutor<'a, Evm, Spec, R: ReceiptBuilder> { + spec: Spec, + /// Block execution context (parent hash, withdrawals, ommers, etc.). + pub ctx: EthBlockExecutionCtx<'a>, + evm: Evm, + system_caller: SystemCaller, + receipt_builder: R, + receipts: Vec, + gas_used: u64, +} + +impl<'a, Evm, Spec, R> EvBlockExecutor<'a, Evm, Spec, R> +where + Spec: Clone, + R: ReceiptBuilder, +{ + /// Creates a new block executor with the provided EVM, context, spec, and receipt builder. + pub fn new(evm: Evm, ctx: EthBlockExecutionCtx<'a>, spec: Spec, receipt_builder: R) -> Self { + Self { + evm, + ctx, + receipts: Vec::new(), + gas_used: 0, + system_caller: SystemCaller::new(spec.clone()), + spec, + receipt_builder, + } + } +} + +impl<'db, DB, E, Spec, R> BlockExecutor for EvBlockExecutor<'_, E, Spec, R> +where + DB: Database + 'db, + E: Evm< + DB = &'db mut State, + Tx: FromRecoveredTx + FromTxWithEncoded, + >, + Spec: EthExecutorSpec, + R: ReceiptBuilder>, +{ + type Transaction = R::Transaction; + type Receipt = R::Receipt; + type Evm = E; + + fn apply_pre_execution_changes(&mut self) -> Result<(), BlockExecutionError> { + let state_clear_flag = self + .spec + .is_spurious_dragon_active_at_block(self.evm.block().number.saturating_to()); + self.evm.db_mut().set_state_clear_flag(state_clear_flag); + + self.system_caller + .apply_blockhashes_contract_call(self.ctx.parent_hash, &mut self.evm)?; + self.system_caller + .apply_beacon_root_contract_call(self.ctx.parent_beacon_block_root, &mut self.evm)?; + + Ok(()) + } + + fn execute_transaction_without_commit( + &mut self, + tx: impl ExecutableTx, + ) -> Result::HaltReason>, BlockExecutionError> { + let block_available_gas = self.evm.block().gas_limit - self.gas_used; + + if tx.tx().gas_limit() > block_available_gas { + return Err( + BlockValidationError::TransactionGasLimitMoreThanAvailableBlockGas { + transaction_gas_limit: tx.tx().gas_limit(), + block_available_gas, + } + .into(), + ); + } + + self.evm.transact(&tx).map_err(|err| { + let hash = tx.tx().trie_hash(); + BlockExecutionError::evm(err, hash) + }) + } + + fn commit_transaction( + &mut self, + output: ResultAndState<::HaltReason>, + tx: impl ExecutableTx, + ) -> Result { + let ResultAndState { result, state } = output; + + self.system_caller + .on_state(StateChangeSource::Transaction(self.receipts.len()), &state); + + let gas_used = result.gas_used(); + self.gas_used += gas_used; + + self.receipts + .push(self.receipt_builder.build_receipt(ReceiptBuilderCtx { + tx: tx.tx(), + evm: &self.evm, + result, + state: &state, + cumulative_gas_used: self.gas_used, + })); + + self.evm.db_mut().commit(state); + + Ok(gas_used) + } + + fn finish( + mut self, + ) -> Result<(Self::Evm, BlockExecutionResult), BlockExecutionError> { + let requests = if self + .spec + .is_prague_active_at_timestamp(self.evm.block().timestamp.saturating_to()) + { + let deposit_requests = + eip6110::parse_deposits_from_receipts(&self.spec, &self.receipts)?; + + let mut requests = Requests::default(); + + if !deposit_requests.is_empty() { + requests.push_request_with_type(eip6110::DEPOSIT_REQUEST_TYPE, deposit_requests); + } + + requests.extend( + self.system_caller + .apply_post_execution_changes(&mut self.evm)?, + ); + requests + } else { + Requests::default() + }; + + let mut balance_increments = post_block_balance_increments( + &self.spec, + self.evm.block(), + self.ctx.ommers, + self.ctx.withdrawals.as_deref(), + ); + + if self + .spec + .ethereum_fork_activation(EthereumHardfork::Dao) + .transitions_at_block(self.evm.block().number.saturating_to()) + { + let drained_balance: u128 = self + .evm + .db_mut() + .drain_balances(dao_fork::DAO_HARDFORK_ACCOUNTS) + .map_err(|_| BlockValidationError::IncrementBalanceFailed)? + .into_iter() + .sum(); + + *balance_increments + .entry(dao_fork::DAO_HARDFORK_BENEFICIARY) + .or_default() += drained_balance; + } + + self.evm + .db_mut() + .increment_balances(balance_increments.clone()) + .map_err(|_| BlockValidationError::IncrementBalanceFailed)?; + + self.system_caller.try_on_state_with(|| { + balance_increment_state(&balance_increments, self.evm.db_mut()).map(|state| { + ( + StateChangeSource::PostBlock(StateChangePostBlockSource::BalanceIncrements), + Cow::Owned(state), + ) + }) + })?; + + Ok(( + self.evm, + BlockExecutionResult { + receipts: self.receipts, + requests, + gas_used: self.gas_used, + }, + )) + } + + fn set_state_hook(&mut self, hook: Option>) { + self.system_caller.with_state_hook(hook); + } + + fn evm_mut(&mut self) -> &mut Self::Evm { + &mut self.evm + } + + fn evm(&self) -> &Self::Evm { + &self.evm + } +} + +/// Block executor factory for EV transactions. +#[derive(Debug, Clone, Default, Copy)] +pub struct EvBlockExecutorFactory +{ + receipt_builder: R, + spec: Spec, + evm_factory: EvmFactory, +} + +impl EvBlockExecutorFactory { + /// Creates a new EV block executor factory. + pub const fn new(receipt_builder: R, spec: Spec, evm_factory: EvmFactory) -> Self { + Self { + receipt_builder, + spec, + evm_factory, + } + } + + /// Returns the receipt builder used by the factory. + pub const fn receipt_builder(&self) -> &R { + &self.receipt_builder + } + + /// Returns the spec configuration used by the factory. + pub const fn spec(&self) -> &Spec { + &self.spec + } + + /// Returns the underlying EVM factory. + pub const fn evm_factory(&self) -> &EvmFactory { + &self.evm_factory + } +} + +impl BlockExecutorFactory for EvBlockExecutorFactory +where + R: ReceiptBuilder> + + Clone, + Spec: EthExecutorSpec + Clone, + EvmF: EvmFactory + FromTxWithEncoded>, + Self: 'static, +{ + type EvmFactory = EvmF; + type ExecutionCtx<'a> = EthBlockExecutionCtx<'a>; + type Transaction = R::Transaction; + type Receipt = R::Receipt; + + fn evm_factory(&self) -> &Self::EvmFactory { + &self.evm_factory + } + + fn create_executor<'a, DB, I>( + &'a self, + evm: EvmF::Evm<&'a mut State, I>, + ctx: Self::ExecutionCtx<'a>, + ) -> impl BlockExecutorFor<'a, Self, DB, I> + where + DB: Database + 'a, + I: Inspector>> + 'a, + { + EvBlockExecutor::new(evm, ctx, self.spec.clone(), self.receipt_builder.clone()) + } +} diff --git a/crates/node/src/executor.rs b/crates/node/src/executor.rs index 5c136b9..07970d6 100644 --- a/crates/node/src/executor.rs +++ b/crates/node/src/executor.rs @@ -1,27 +1,377 @@ //! Helpers to build the ev-reth executor with EV-specific hooks applied. -use alloy_evm::eth::{spec::EthExecutorSpec, EthEvmFactory}; +use alloy_consensus::{BlockHeader, Header}; +use alloy_eips::{eip1559::INITIAL_BASE_FEE, Decodable2718}; +use alloy_evm::{eth::spec::EthExecutorSpec, FromRecoveredTx, FromTxWithEncoded}; +use alloy_primitives::U256; +use alloy_rpc_types_engine::ExecutionData; use ev_revm::{ - with_ev_handler, BaseFeeRedirect, BaseFeeRedirectSettings, ContractSizeLimitSettings, - DeployAllowlistSettings, EvEvmFactory, MintPrecompileSettings, + BaseFeeRedirect, BaseFeeRedirectSettings, ContractSizeLimitSettings, DeployAllowlistSettings, + EvTxEvmFactory, MintPrecompileSettings, }; -use reth_chainspec::ChainSpec; +use reth_chainspec::{ChainSpec, EthChainSpec}; +use reth_errors::RethError; use reth_ethereum::{ chainspec::EthereumHardforks, - evm::EthEvmConfig, node::{ api::FullNodeTypes, builder::{components::ExecutorBuilder as RethExecutorBuilder, BuilderContext}, }, }; use reth_ethereum_forks::Hardforks; +use reth_evm::{ + ConfigureEngineEvm, ConfigureEvm, EvmEnv, EvmEnvFor, ExecutableTxIterator, ExecutionCtxFor, + NextBlockEnvAttributes, TransactionEnv, +}; use reth_node_builder::PayloadBuilderConfig; +use reth_primitives_traits::{ + constants::MAX_TX_GAS_LIMIT_OSAKA, SealedBlock, SealedHeader, SignedTransaction, TxTy, +}; +use reth_revm::revm::{ + context::{BlockEnv, CfgEnv}, + context_interface::block::BlobExcessGasAndPrice, + primitives::hardfork::SpecId, +}; use tracing::info; -use crate::{config::EvolvePayloadBuilderConfig, EvolveNode}; +use crate::{ + config::EvolvePayloadBuilderConfig, + evm_executor::{EvBlockExecutorFactory, EvReceiptBuilder}, + EvolveNode, +}; +use ev_primitives::{EvPrimitives, EvTxEnvelope}; +use reth_evm_ethereum::{revm_spec, revm_spec_by_timestamp_and_block_number, EthBlockAssembler}; /// Type alias for the EV-aware EVM config we install into the node. -pub type EvolveEvmConfig = EthEvmConfig>; +pub type EvolveEvmConfig = EvEvmConfig; + +/// EVM config wired for `EvPrimitives`. +#[derive(Debug, Clone)] +pub struct EvEvmConfig { + /// Factory used to create block executors. + pub executor_factory: EvBlockExecutorFactory, EvmFactory>, + /// Block assembler used for building block bodies and headers. + pub block_assembler: EthBlockAssembler, +} + +impl EvEvmConfig { + /// Creates a new EV EVM config with the default EVM factory. + pub fn new(chain_spec: std::sync::Arc) -> Self { + Self::new_with_evm_factory(chain_spec, EvTxEvmFactory::default()) + } +} + +impl EvEvmConfig { + /// Creates a new EV EVM config using the provided EVM factory. + pub fn new_with_evm_factory( + chain_spec: std::sync::Arc, + evm_factory: EvmFactory, + ) -> Self { + Self { + block_assembler: EthBlockAssembler::new(chain_spec.clone()), + executor_factory: EvBlockExecutorFactory::new( + EvReceiptBuilder, + chain_spec, + evm_factory, + ), + } + } + + /// Returns the chain spec used by this config. + pub const fn chain_spec(&self) -> &std::sync::Arc { + self.executor_factory.spec() + } + + /// Sets the extra data to be included in built blocks. + pub fn with_extra_data(mut self, extra_data: alloy_primitives::Bytes) -> Self { + self.block_assembler.extra_data = extra_data; + self + } +} + +impl ConfigureEvm for EvEvmConfig +where + ChainSpec: EthExecutorSpec + EthChainSpec
+ Hardforks + 'static, + EvmF: reth_evm::EvmFactory< + Tx: TransactionEnv, + Spec = SpecId, + Precompiles = reth_evm::precompiles::PrecompilesMap, + > + Clone + + std::fmt::Debug + + Send + + Sync + + Unpin + + 'static, + EvmF::Tx: FromRecoveredTx + FromTxWithEncoded + Clone, +{ + type Primitives = EvPrimitives; + type Error = std::convert::Infallible; + type NextBlockEnvCtx = NextBlockEnvAttributes; + type BlockExecutorFactory = + EvBlockExecutorFactory, EvmF>; + type BlockAssembler = EthBlockAssembler; + + fn block_executor_factory(&self) -> &Self::BlockExecutorFactory { + &self.executor_factory + } + + fn block_assembler(&self) -> &Self::BlockAssembler { + &self.block_assembler + } + + fn evm_env(&self, header: &Header) -> Result { + let blob_params = self.chain_spec().blob_params_at_timestamp(header.timestamp); + let spec = revm_spec(self.chain_spec(), header); + + let mut cfg_env = CfgEnv::new() + .with_chain_id(self.chain_spec().chain().id()) + .with_spec(spec); + + if let Some(blob_params) = &blob_params { + cfg_env.set_max_blobs_per_tx(blob_params.max_blobs_per_tx); + } + + if self + .chain_spec() + .is_osaka_active_at_timestamp(header.timestamp) + { + cfg_env.tx_gas_limit_cap = Some(MAX_TX_GAS_LIMIT_OSAKA); + } + + let blob_excess_gas_and_price = + header + .excess_blob_gas + .zip(blob_params) + .map(|(excess_blob_gas, params)| { + let blob_gasprice = params.calc_blob_fee(excess_blob_gas); + BlobExcessGasAndPrice { + excess_blob_gas, + blob_gasprice, + } + }); + + let block_env = BlockEnv { + number: U256::from(header.number), + beneficiary: header.beneficiary, + timestamp: U256::from(header.timestamp), + difficulty: if spec >= SpecId::MERGE { + U256::ZERO + } else { + header.difficulty + }, + prevrandao: if spec >= SpecId::MERGE { + Some(header.mix_hash) + } else { + None + }, + gas_limit: header.gas_limit, + basefee: header.base_fee_per_gas.unwrap_or_default(), + blob_excess_gas_and_price, + }; + + Ok(EvmEnv { cfg_env, block_env }) + } + + fn next_evm_env( + &self, + parent: &Header, + attributes: &NextBlockEnvAttributes, + ) -> Result { + let chain_spec = self.chain_spec(); + let blob_params = chain_spec.blob_params_at_timestamp(attributes.timestamp); + let spec_id = revm_spec_by_timestamp_and_block_number( + chain_spec, + attributes.timestamp, + parent.number() + 1, + ); + + let mut cfg = CfgEnv::new() + .with_chain_id(self.chain_spec().chain().id()) + .with_spec(spec_id); + + if let Some(blob_params) = &blob_params { + cfg.set_max_blobs_per_tx(blob_params.max_blobs_per_tx); + } + + if self + .chain_spec() + .is_osaka_active_at_timestamp(attributes.timestamp) + { + cfg.tx_gas_limit_cap = Some(MAX_TX_GAS_LIMIT_OSAKA); + } + + let blob_excess_gas_and_price = + parent + .excess_blob_gas + .zip(blob_params) + .map(|(excess_blob_gas, params)| { + let blob_gasprice = params.calc_blob_fee(excess_blob_gas); + BlobExcessGasAndPrice { + excess_blob_gas, + blob_gasprice, + } + }); + + // Calculate base fee for the next block + let mut basefee = chain_spec.next_block_base_fee(parent, attributes.timestamp); + + let mut gas_limit = attributes.gas_limit; + + // If we are on the London fork boundary, we need to multiply the parent's gas limit by the + // elasticity multiplier to get the new gas limit. + if chain_spec + .fork(reth_ethereum_forks::EthereumHardfork::London) + .transitions_at_block(parent.number + 1) + { + let elasticity_multiplier = chain_spec + .base_fee_params_at_timestamp(attributes.timestamp) + .elasticity_multiplier; + + // multiply the gas limit by the elasticity multiplier + gas_limit *= elasticity_multiplier as u64; + + // set the base fee to the initial base fee from the EIP-1559 spec + basefee = Some(INITIAL_BASE_FEE); + } + + let block_env = BlockEnv { + number: U256::from(parent.number + 1), + beneficiary: attributes.suggested_fee_recipient, + timestamp: U256::from(attributes.timestamp), + difficulty: U256::ZERO, + prevrandao: Some(attributes.prev_randao), + gas_limit, + basefee: basefee.unwrap_or_default(), + blob_excess_gas_and_price, + }; + + Ok(EvmEnv { + cfg_env: cfg, + block_env, + }) + } + + fn context_for_block<'a>( + &self, + block: &'a SealedBlock, + ) -> Result, Self::Error> { + Ok(alloy_evm::eth::EthBlockExecutionCtx { + parent_hash: block.header().parent_hash, + parent_beacon_block_root: block.header().parent_beacon_block_root, + ommers: &block.body().ommers, + withdrawals: block + .body() + .withdrawals + .as_ref() + .map(std::borrow::Cow::Borrowed), + }) + } + + fn context_for_next_block( + &self, + parent: &SealedHeader
, + attributes: Self::NextBlockEnvCtx, + ) -> Result, Self::Error> { + Ok(alloy_evm::eth::EthBlockExecutionCtx { + parent_hash: parent.hash(), + parent_beacon_block_root: attributes.parent_beacon_block_root, + ommers: &[], + withdrawals: attributes.withdrawals.map(std::borrow::Cow::Owned), + }) + } +} + +impl ConfigureEngineEvm for EvEvmConfig +where + ChainSpec: EthExecutorSpec + EthChainSpec
+ Hardforks + 'static, + EvmF: reth_evm::EvmFactory< + Tx: TransactionEnv + FromRecoveredTx + FromTxWithEncoded, + Spec = SpecId, + Precompiles = reth_evm::precompiles::PrecompilesMap, + > + Clone + + std::fmt::Debug + + Send + + Sync + + Unpin + + 'static, +{ + fn evm_env_for_payload(&self, payload: &ExecutionData) -> EvmEnvFor { + let timestamp = payload.payload.timestamp(); + let block_number = payload.payload.block_number(); + + let blob_params = self.chain_spec().blob_params_at_timestamp(timestamp); + let spec = + revm_spec_by_timestamp_and_block_number(self.chain_spec(), timestamp, block_number); + + let mut cfg_env = CfgEnv::new() + .with_chain_id(self.chain_spec().chain().id()) + .with_spec(spec); + + if let Some(blob_params) = &blob_params { + cfg_env.set_max_blobs_per_tx(blob_params.max_blobs_per_tx); + } + + if self.chain_spec().is_osaka_active_at_timestamp(timestamp) { + cfg_env.tx_gas_limit_cap = Some(MAX_TX_GAS_LIMIT_OSAKA); + } + + let blob_excess_gas_and_price = + payload + .payload + .excess_blob_gas() + .zip(blob_params) + .map(|(excess_blob_gas, params)| { + let blob_gasprice = params.calc_blob_fee(excess_blob_gas); + BlobExcessGasAndPrice { + excess_blob_gas, + blob_gasprice, + } + }); + + let block_env = BlockEnv { + number: U256::from(block_number), + beneficiary: payload.payload.fee_recipient(), + timestamp: U256::from(timestamp), + difficulty: if spec >= SpecId::MERGE { + U256::ZERO + } else { + payload.payload.as_v1().prev_randao.into() + }, + prevrandao: (spec >= SpecId::MERGE).then(|| payload.payload.as_v1().prev_randao), + gas_limit: payload.payload.gas_limit(), + basefee: payload.payload.saturated_base_fee_per_gas(), + blob_excess_gas_and_price, + }; + + EvmEnv { cfg_env, block_env } + } + + fn context_for_payload<'a>(&self, payload: &'a ExecutionData) -> ExecutionCtxFor<'a, Self> { + alloy_evm::eth::EthBlockExecutionCtx { + parent_hash: payload.parent_hash(), + parent_beacon_block_root: payload.sidecar.parent_beacon_block_root(), + ommers: &[], + withdrawals: payload + .payload + .withdrawals() + .map(|w| std::borrow::Cow::Owned(w.clone().into())), + } + } + + fn tx_iterator_for_payload(&self, payload: &ExecutionData) -> impl ExecutableTxIterator { + payload + .payload + .transactions() + .clone() + .into_iter() + .map(|tx| { + let tx = TxTy::::decode_2718_exact(tx.as_ref()) + .map_err(RethError::other)?; + let signer = tx.try_recover().map_err(RethError::other)?; + Ok::<_, RethError>(tx.with_signer(signer)) + }) + } +} /// Builds the EV-aware EVM configuration by wrapping the default config with the EV handler. pub fn build_evm_config(ctx: &BuilderContext) -> eyre::Result @@ -30,8 +380,6 @@ where ChainSpec: Hardforks + EthExecutorSpec + EthereumHardforks, { let chain_spec = ctx.chain_spec(); - let base_config = EthEvmConfig::new(chain_spec.clone()) - .with_extra_data(ctx.payload_builder_config().extra_data_bytes()); let evolve_config = EvolvePayloadBuilderConfig::from_chain_spec(chain_spec.as_ref())?; evolve_config.validate()?; @@ -78,13 +426,15 @@ where DeployAllowlistSettings::new(allowlist, activation) }); - Ok(with_ev_handler( - base_config, + let factory = EvTxEvmFactory::new( redirect, mint_precompile, deploy_allowlist, contract_size_limit, - )) + ); + + Ok(EvEvmConfig::new_with_evm_factory(chain_spec, factory) + .with_extra_data(ctx.payload_builder_config().extra_data_bytes())) } /// Thin wrapper so we can plug the EV executor into the node components builder. diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs index b25d5d0..7a9d525 100644 --- a/crates/node/src/lib.rs +++ b/crates/node/src/lib.rs @@ -17,12 +17,20 @@ pub mod chainspec; pub mod config; /// Shared error types for evolve node wiring. pub mod error; +/// EV-specific EVM executor building blocks. +pub mod evm_executor; /// Executor wiring for EV aware execution. pub mod executor; /// Node composition and payload types. pub mod node; /// Payload service integration. pub mod payload_service; +/// Payload types for `EvPrimitives`. +pub mod payload_types; +/// RPC wiring for EvTxEnvelope support. +pub mod rpc; +/// Transaction pool wiring and validation. +pub mod txpool; /// Payload validator integration. pub mod validator; @@ -36,4 +44,5 @@ pub use error::EvolveEngineError; 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 validator::{EvolveEngineValidator, EvolveEngineValidatorBuilder}; diff --git a/crates/node/src/node.rs b/crates/node/src/node.rs index 6a3cbfb..4a9e814 100644 --- a/crates/node/src/node.rs +++ b/crates/node/src/node.rs @@ -4,6 +4,7 @@ use alloy_rpc_types::engine::{ ExecutionData, ExecutionPayloadEnvelopeV2, ExecutionPayloadEnvelopeV3, ExecutionPayloadEnvelopeV4, ExecutionPayloadEnvelopeV5, ExecutionPayloadV1, }; +use ev_primitives::EvPrimitives; use reth_ethereum::{ chainspec::ChainSpec, node::{ @@ -13,12 +14,10 @@ use reth_ethereum::{ rpc::RpcAddOns, Node, NodeAdapter, }, - node::{EthereumNetworkBuilder, EthereumPoolBuilder}, - EthereumEthApiBuilder, + node::EthereumNetworkBuilder, }, - primitives::SealedBlock, }; -use reth_payload_builder::EthBuiltPayload; +use reth_primitives_traits::SealedBlock; use serde::{Deserialize, Serialize}; use tracing::info; @@ -26,6 +25,9 @@ use crate::{ attributes::{EvolveEnginePayloadAttributes, EvolveEnginePayloadBuilderAttributes}, executor::EvolveExecutorBuilder, payload_service::EvolvePayloadBuilderBuilder, + payload_types::EvBuiltPayload, + rpc::EvEthApiBuilder, + txpool::EvolvePoolBuilder, validator::EvolveEngineValidatorBuilder, }; @@ -36,7 +38,7 @@ pub struct EvolveEngineTypes; impl PayloadTypes for EvolveEngineTypes { type ExecutionData = ExecutionData; - type BuiltPayload = EthBuiltPayload; + type BuiltPayload = EvBuiltPayload; type PayloadAttributes = EvolveEnginePayloadAttributes; type PayloadBuilderAttributes = EvolveEnginePayloadBuilderAttributes; @@ -75,14 +77,14 @@ impl EvolveNode { } impl NodeTypes for EvolveNode { - type Primitives = reth_ethereum::EthPrimitives; + type Primitives = EvPrimitives; type ChainSpec = ChainSpec; - type Storage = reth_ethereum::provider::EthStorage; + type Storage = reth_ethereum::provider::EthStorage; type Payload = EvolveEngineTypes; } /// Evolve node addons configuring RPC types with custom engine validator. -pub type EvolveNodeAddOns = RpcAddOns; +pub type EvolveNodeAddOns = RpcAddOns; impl Node for EvolveNode where @@ -90,7 +92,7 @@ where { type ComponentsBuilder = ComponentsBuilder< N, - EthereumPoolBuilder, + EvolvePoolBuilder, BasicPayloadServiceBuilder, EthereumNetworkBuilder, EvolveExecutorBuilder, @@ -101,7 +103,7 @@ where fn components_builder(&self) -> Self::ComponentsBuilder { ComponentsBuilder::default() .node_types::() - .pool(EthereumPoolBuilder::default()) + .pool(EvolvePoolBuilder::default()) .executor(EvolveExecutorBuilder::default()) .payload(BasicPayloadServiceBuilder::new( EvolvePayloadBuilderBuilder::new(), diff --git a/crates/node/src/payload_service.rs b/crates/node/src/payload_service.rs index d966776..a0107c0 100644 --- a/crates/node/src/payload_service.rs +++ b/crates/node/src/payload_service.rs @@ -15,9 +15,8 @@ use reth_ethereum::{ }, pool::{PoolTransaction, TransactionPool}, primitives::Header, - TransactionSigned, }; -use reth_payload_builder::{EthBuiltPayload, PayloadBuilderError}; +use reth_payload_builder::PayloadBuilderError; use reth_provider::HeaderProvider; use reth_revm::cached::CachedReads; use tokio::runtime::Handle; @@ -26,8 +25,10 @@ use tracing::info; use crate::{ attributes::EvolveEnginePayloadBuilderAttributes, builder::EvolvePayloadBuilder, config::EvolvePayloadBuilderConfig, executor::EvolveEvmConfig, node::EvolveEngineTypes, + payload_types::EvBuiltPayload, }; +use ev_primitives::{EvPrimitives, TransactionSigned}; use evolve_ev_reth::config::set_current_block_gas_limit; /// Evolve payload service builder that integrates with the evolve payload builder. @@ -68,7 +69,7 @@ where Types: NodeTypes< Payload = EvolveEngineTypes, ChainSpec = ChainSpec, - Primitives = reth_ethereum::EthPrimitives, + Primitives = EvPrimitives, >, >, Pool: TransactionPool> @@ -128,7 +129,7 @@ where + 'static, { type Attributes = EvolveEnginePayloadBuilderAttributes; - type BuiltPayload = EthBuiltPayload; + type BuiltPayload = EvBuiltPayload; fn try_build( &self, @@ -193,9 +194,9 @@ where sealed_block.gas_used ); - // Convert to EthBuiltPayload. + // Convert to EvBuiltPayload. let gas_used = sealed_block.gas_used; - let built_payload = EthBuiltPayload::new( + let built_payload = EvBuiltPayload::new( attributes.payload_id(), // Use the proper payload ID from attributes. Arc::new(sealed_block), U256::from(gas_used), // Block gas used. @@ -257,7 +258,7 @@ where .map_err(PayloadBuilderError::other)?; let gas_used = sealed_block.gas_used; - Ok(EthBuiltPayload::new( + Ok(EvBuiltPayload::new( attributes.payload_id(), Arc::new(sealed_block), U256::from(gas_used), diff --git a/crates/node/src/payload_types.rs b/crates/node/src/payload_types.rs new file mode 100644 index 0000000..81085e6 --- /dev/null +++ b/crates/node/src/payload_types.rs @@ -0,0 +1,207 @@ +use std::sync::Arc; + +use alloy_eips::eip7685::Requests; +use alloy_primitives::U256; +use alloy_rpc_types_engine::{ + BlobsBundleV1, BlobsBundleV2, ExecutionPayloadEnvelopeV2, ExecutionPayloadEnvelopeV3, + ExecutionPayloadEnvelopeV4, ExecutionPayloadEnvelopeV5, ExecutionPayloadFieldV2, + ExecutionPayloadV1, ExecutionPayloadV3, PayloadId, +}; +use ev_primitives::EvPrimitives; +use reth_payload_builder::BlobSidecars; +use reth_payload_primitives::BuiltPayload; +use reth_primitives_traits::SealedBlock; + +/// Built payload for `EvPrimitives`. +#[derive(Debug, Clone)] +pub struct EvBuiltPayload { + id: PayloadId, + block: Arc>, + fees: U256, + sidecars: BlobSidecars, + requests: Option, +} + +/// Errors encountered when converting an EV payload into an engine API envelope. +#[derive(Debug, thiserror::Error)] +pub enum EvBuiltPayloadConversionError { + /// EIP-7594 sidecars are not valid for this envelope version. + #[error("unexpected EIP-7594 sidecars for this payload")] + UnexpectedEip7594Sidecars, + /// EIP-4844 sidecars are not valid for this envelope version. + #[error("unexpected EIP-4844 sidecars for this payload")] + UnexpectedEip4844Sidecars, +} + +impl EvBuiltPayload { + /// Creates a new EV built payload. + pub const fn new( + id: PayloadId, + block: Arc>, + fees: U256, + requests: Option, + ) -> Self { + Self { + id, + block, + fees, + requests, + sidecars: BlobSidecars::Empty, + } + } + + /// Returns the payload identifier. + pub const fn id(&self) -> PayloadId { + self.id + } + + /// Returns the sealed block backing this payload. + pub fn block(&self) -> &SealedBlock { + &self.block + } + + /// Returns the total fees for this payload. + pub const fn fees(&self) -> U256 { + self.fees + } + + /// Returns the sidecar bundle. + pub const fn sidecars(&self) -> &BlobSidecars { + &self.sidecars + } + + /// Attaches the provided sidecars and returns the updated payload. + pub fn with_sidecars(mut self, sidecars: impl Into) -> Self { + self.sidecars = sidecars.into(); + self + } + + /// Converts this payload into an `ExecutionPayloadEnvelopeV3`. + pub fn try_into_v3(self) -> Result { + let Self { + block, + fees, + sidecars, + .. + } = self; + + let blobs_bundle = match sidecars { + BlobSidecars::Empty => BlobsBundleV1::empty(), + BlobSidecars::Eip4844(sidecars) => BlobsBundleV1::from(sidecars), + BlobSidecars::Eip7594(_) => { + return Err(EvBuiltPayloadConversionError::UnexpectedEip7594Sidecars) + } + }; + + Ok(ExecutionPayloadEnvelopeV3 { + execution_payload: ExecutionPayloadV3::from_block_unchecked( + block.hash(), + &Arc::unwrap_or_clone(block).into_block(), + ), + block_value: fees, + should_override_builder: false, + blobs_bundle, + }) + } + + /// Converts this payload into an `ExecutionPayloadEnvelopeV4`. + pub fn try_into_v4(self) -> Result { + Ok(ExecutionPayloadEnvelopeV4 { + execution_requests: self.requests.clone().unwrap_or_default(), + envelope_inner: self.try_into()?, + }) + } + + /// Converts this payload into an `ExecutionPayloadEnvelopeV5`. + pub fn try_into_v5(self) -> Result { + let Self { + block, + fees, + sidecars, + requests, + .. + } = self; + + let blobs_bundle = match sidecars { + BlobSidecars::Empty => BlobsBundleV2::empty(), + BlobSidecars::Eip7594(sidecars) => BlobsBundleV2::from(sidecars), + BlobSidecars::Eip4844(_) => { + return Err(EvBuiltPayloadConversionError::UnexpectedEip4844Sidecars) + } + }; + + Ok(ExecutionPayloadEnvelopeV5 { + execution_payload: ExecutionPayloadV3::from_block_unchecked( + block.hash(), + &Arc::unwrap_or_clone(block).into_block(), + ), + block_value: fees, + should_override_builder: false, + blobs_bundle, + execution_requests: requests.unwrap_or_default(), + }) + } +} + +impl BuiltPayload for EvBuiltPayload { + type Primitives = EvPrimitives; + + fn block(&self) -> &SealedBlock { + &self.block + } + + fn fees(&self) -> U256 { + self.fees + } + + fn requests(&self) -> Option { + self.requests.clone() + } +} + +impl From for ExecutionPayloadV1 { + fn from(value: EvBuiltPayload) -> Self { + Self::from_block_unchecked( + value.block().hash(), + &Arc::unwrap_or_clone(value.block).into_block(), + ) + } +} + +impl From for ExecutionPayloadEnvelopeV2 { + fn from(value: EvBuiltPayload) -> Self { + let EvBuiltPayload { block, fees, .. } = value; + + Self { + block_value: fees, + execution_payload: ExecutionPayloadFieldV2::from_block_unchecked( + block.hash(), + &Arc::unwrap_or_clone(block).into_block(), + ), + } + } +} + +impl TryFrom for ExecutionPayloadEnvelopeV3 { + type Error = EvBuiltPayloadConversionError; + + fn try_from(value: EvBuiltPayload) -> Result { + value.try_into_v3() + } +} + +impl TryFrom for ExecutionPayloadEnvelopeV4 { + type Error = EvBuiltPayloadConversionError; + + fn try_from(value: EvBuiltPayload) -> Result { + value.try_into_v4() + } +} + +impl TryFrom for ExecutionPayloadEnvelopeV5 { + type Error = EvBuiltPayloadConversionError; + + fn try_from(value: EvBuiltPayload) -> Result { + value.try_into_v5() + } +} diff --git a/crates/node/src/rpc.rs b/crates/node/src/rpc.rs new file mode 100644 index 0000000..cddc891 --- /dev/null +++ b/crates/node/src/rpc.rs @@ -0,0 +1,524 @@ +//! RPC wiring for EvTxEnvelope support. + +use alloy_consensus::{ + error::ValueError, transaction::Recovered, SignableTransaction, + Transaction as ConsensusTransaction, +}; +use alloy_consensus_any::AnyReceiptEnvelope; +use alloy_network::{Ethereum, ReceiptResponse, TransactionResponse, TxSigner}; +use alloy_primitives::{Address, Signature, U256}; +use alloy_rpc_types_eth::{ + Log, Transaction, TransactionInfo, TransactionReceipt, TransactionRequest, +}; +use reth_chainspec::{ChainSpecProvider, EthChainSpec, EthereumHardforks, Hardforks}; +use reth_evm::{ConfigureEvm, SpecFor, TxEnvFor}; +use reth_node_api::{FullNodeComponents, FullNodeTypes, NodeTypes}; +use reth_node_builder::rpc::{EthApiBuilder, EthApiCtx}; +use reth_rpc::EthApi; +use reth_rpc_convert::{ + transaction::{ + ConvertReceiptInput, EthTxEnvError, ReceiptConverter, RpcTxConverter, SimTxConverter, + TryIntoSimTx, TryIntoTxEnv, TxEnvConverter, + }, + RpcConvert, RpcConverter, RpcTransaction, RpcTxReq, RpcTypes, SignTxRequestError, + SignableTxRequest, +}; +use reth_rpc_eth_api::{ + helpers::{pending_block::BuildPendingEnv, AddDevSigners}, + FromEvmError, FullEthApiServer, RpcNodeCore, +}; +use reth_rpc_eth_types::{receipt::build_receipt, EthApiError}; +use std::marker::PhantomData; + +use crate::EvolveEvmConfig; +use ev_primitives::{EvPrimitives, EvTxEnvelope}; +use ev_revm::EvTxEnv; + +/// Ev-specific RPC types using Ethereum responses with a custom request wrapper. +#[derive(Clone, Debug)] +pub struct EvRpcTypes; + +impl RpcTypes for EvRpcTypes { + type Header = ::Header; + type Receipt = EvRpcReceipt; + type TransactionResponse = EvRpcTransaction; + type TransactionRequest = EvTransactionRequest; +} + +/// RPC transaction response with optional sponsor address. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct EvRpcTransaction { + #[serde(flatten)] + inner: Transaction, + #[serde(rename = "feePayer", skip_serializing_if = "Option::is_none")] + fee_payer: Option
, +} + +impl EvRpcTransaction { + const fn new(inner: Transaction, fee_payer: Option
) -> Self { + Self { inner, fee_payer } + } + + /// Returns the optional fee payer address. + pub const fn fee_payer(&self) -> Option
{ + self.fee_payer + } + + /// Returns the inner transaction. + pub const fn inner(&self) -> &Transaction { + &self.inner + } +} + +impl ConsensusTransaction for EvRpcTransaction { + fn chain_id(&self) -> Option { + ConsensusTransaction::chain_id(&self.inner) + } + + fn nonce(&self) -> u64 { + ConsensusTransaction::nonce(&self.inner) + } + + fn gas_limit(&self) -> u64 { + ConsensusTransaction::gas_limit(&self.inner) + } + + fn gas_price(&self) -> Option { + ConsensusTransaction::gas_price(&self.inner) + } + + fn max_fee_per_gas(&self) -> u128 { + ConsensusTransaction::max_fee_per_gas(&self.inner) + } + + fn max_priority_fee_per_gas(&self) -> Option { + ConsensusTransaction::max_priority_fee_per_gas(&self.inner) + } + + fn max_fee_per_blob_gas(&self) -> Option { + ConsensusTransaction::max_fee_per_blob_gas(&self.inner) + } + + fn priority_fee_or_price(&self) -> u128 { + ConsensusTransaction::priority_fee_or_price(&self.inner) + } + + fn effective_gas_price(&self, base_fee: Option) -> u128 { + ConsensusTransaction::effective_gas_price(&self.inner, base_fee) + } + + fn is_dynamic_fee(&self) -> bool { + ConsensusTransaction::is_dynamic_fee(&self.inner) + } + + fn kind(&self) -> alloy_primitives::TxKind { + ConsensusTransaction::kind(&self.inner) + } + + fn is_create(&self) -> bool { + ConsensusTransaction::is_create(&self.inner) + } + + fn value(&self) -> U256 { + ConsensusTransaction::value(&self.inner) + } + + fn input(&self) -> &alloy_primitives::Bytes { + ConsensusTransaction::input(&self.inner) + } + + fn access_list(&self) -> Option<&alloy_eips::eip2930::AccessList> { + ConsensusTransaction::access_list(&self.inner) + } + + fn blob_versioned_hashes(&self) -> Option<&[alloy_primitives::B256]> { + ConsensusTransaction::blob_versioned_hashes(&self.inner) + } + + fn authorization_list(&self) -> Option<&[alloy_eips::eip7702::SignedAuthorization]> { + ConsensusTransaction::authorization_list(&self.inner) + } +} + +impl TransactionResponse for EvRpcTransaction { + fn tx_hash(&self) -> alloy_primitives::TxHash { + self.inner.tx_hash() + } + + fn block_hash(&self) -> Option { + self.inner.block_hash() + } + + fn block_number(&self) -> Option { + self.inner.block_number() + } + + fn transaction_index(&self) -> Option { + self.inner.transaction_index() + } + + fn from(&self) -> Address { + self.inner.from() + } +} + +impl alloy_eips::Typed2718 for EvRpcTransaction { + fn ty(&self) -> u8 { + self.inner.ty() + } +} + +/// RPC receipt response with optional sponsor address. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct EvRpcReceipt { + #[serde(flatten)] + inner: TransactionReceipt>, + #[serde(rename = "feePayer", skip_serializing_if = "Option::is_none")] + fee_payer: Option
, +} + +impl EvRpcReceipt { + const fn new( + inner: TransactionReceipt>, + fee_payer: Option
, + ) -> Self { + Self { inner, fee_payer } + } + + /// Returns the optional fee payer address. + pub const fn fee_payer(&self) -> Option
{ + self.fee_payer + } + + /// Returns the inner receipt. + pub const fn inner(&self) -> &TransactionReceipt> { + &self.inner + } +} + +impl ReceiptResponse for EvRpcReceipt { + fn contract_address(&self) -> Option
{ + self.inner.contract_address() + } + + fn status(&self) -> bool { + self.inner.status() + } + + fn block_hash(&self) -> Option { + self.inner.block_hash() + } + + fn block_number(&self) -> Option { + self.inner.block_number() + } + + fn transaction_hash(&self) -> alloy_primitives::TxHash { + self.inner.transaction_hash() + } + + fn transaction_index(&self) -> Option { + self.inner.transaction_index() + } + + fn gas_used(&self) -> u64 { + self.inner.gas_used() + } + + fn effective_gas_price(&self) -> u128 { + self.inner.effective_gas_price() + } + + fn blob_gas_used(&self) -> Option { + self.inner.blob_gas_used() + } + + fn blob_gas_price(&self) -> Option { + self.inner.blob_gas_price() + } + + fn from(&self) -> Address { + self.inner.from() + } + + fn to(&self) -> Option
{ + self.inner.to() + } + + fn cumulative_gas_used(&self) -> u64 { + self.inner.cumulative_gas_used() + } + + fn state_root(&self) -> Option { + self.inner.state_root() + } +} + +/// Transaction request wrapper to satisfy local trait bounds. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +#[serde(transparent)] +pub struct EvTransactionRequest(pub TransactionRequest); + +impl From for EvTransactionRequest { + fn from(value: TransactionRequest) -> Self { + Self(value) + } +} + +impl AsRef for EvTransactionRequest { + fn as_ref(&self) -> &TransactionRequest { + &self.0 + } +} + +impl AsMut for EvTransactionRequest { + fn as_mut(&mut self) -> &mut TransactionRequest { + &mut self.0 + } +} + +impl SignableTxRequest for EvTransactionRequest { + async fn try_build_and_sign( + self, + signer: impl TxSigner + Send, + ) -> Result { + let mut tx = self + .0 + .build_typed_tx() + .map_err(|_| SignTxRequestError::InvalidTransactionRequest)?; + let signature = signer.sign_transaction(&mut tx).await?; + let signed: reth_ethereum_primitives::TransactionSigned = tx.into_signed(signature).into(); + Ok(EvTxEnvelope::Ethereum(signed)) + } +} + +impl TryIntoSimTx for EvTransactionRequest { + fn try_into_sim_tx(self) -> Result> { + self.0 + .try_into_sim_tx() + .map(EvTxEnvelope::Ethereum) + .map_err(|err| err.map(EvTransactionRequest)) + } +} + +impl TryIntoTxEnv for EvTransactionRequest { + type Err = EthTxEnvError; + + fn try_into_tx_env( + self, + cfg_env: &reth_revm::revm::context::CfgEnv, + block_env: &reth_revm::revm::context::BlockEnv, + ) -> Result { + self.0 + .try_into_tx_env(cfg_env, block_env) + .map(EvTxEnv::from) + } +} + +/// Receipt converter for `EvPrimitives`. +#[derive(Debug, Clone)] +pub struct EvReceiptConverter { + chain_spec: std::sync::Arc, +} + +impl EvReceiptConverter { + /// Creates a new receipt converter bound to the provided chain spec. + pub const fn new(chain_spec: std::sync::Arc) -> Self { + Self { chain_spec } + } +} + +impl ReceiptConverter for EvReceiptConverter +where + ChainSpec: EthChainSpec + 'static, +{ + type RpcReceipt = EvRpcReceipt; + type Error = EthApiError; + + fn convert_receipts( + &self, + inputs: Vec>, + ) -> Result, Self::Error> { + let mut receipts = Vec::with_capacity(inputs.len()); + + for input in inputs { + let blob_params = self + .chain_spec + .blob_params_at_timestamp(input.meta.timestamp); + let fee_payer = match input.tx.inner() { + EvTxEnvelope::EvNode(ev) => ev + .tx() + .fee_payer_signature + .as_ref() + .and_then(|sig| ev.tx().recover_sponsor(input.tx.signer(), sig).ok()), + EvTxEnvelope::Ethereum(_) => None, + }; + let receipt = build_receipt(input, blob_params, |receipt, next_log_index, meta| { + let rpc_receipt = receipt.into_rpc(next_log_index, meta); + let tx_type = u8::from(rpc_receipt.tx_type); + let inner = >::from(rpc_receipt).with_bloom(); + AnyReceiptEnvelope { + inner, + r#type: tx_type, + } + }); + receipts.push(EvRpcReceipt::new(receipt, fee_payer)); + } + + Ok(receipts) + } +} + +/// RPC converter type for EvTxEnvelope-based nodes. +pub type EvRpcConvert = RpcConverter< + EvRpcTypes, + EvolveEvmConfig, + EvReceiptConverter<<::Types as NodeTypes>::ChainSpec>, + (), + (), + EvSimTxConverter, + EvRpcTxConverter, + EvTxEnvConverter, +>; + +/// Eth API type for EvTxEnvelope-based nodes. +pub type EvEthApiFor = EthApi>; + +/// Builds [`EthApi`] for `EvTxEnvelope` nodes. +#[derive(Debug, Default)] +pub struct EvEthApiBuilder; + +impl EthApiBuilder for EvEthApiBuilder +where + N: FullNodeComponents< + Types: NodeTypes< + Primitives = EvPrimitives, + ChainSpec: Hardforks + + EthereumHardforks + + EthChainSpec + + std::fmt::Debug + + Send + + Sync + + 'static, + >, + Evm = EvolveEvmConfig, + > + RpcNodeCore< + Primitives = EvPrimitives, + Provider = ::Provider, + Pool = ::Pool, + Evm = EvolveEvmConfig, + >, + ::Provider: + ChainSpecProvider::Types as NodeTypes>::ChainSpec>, + ::Evm: + ConfigureEvm>, + TxEnvFor<::Evm>: From, + EvRpcConvert: RpcConvert< + Primitives = EvPrimitives, + TxEnv = TxEnvFor<::Evm>, + Error = EthApiError, + Network = EvRpcTypes, + Spec = SpecFor<::Evm>, + >, + EthApiError: FromEvmError<::Evm>, + EvEthApiFor: FullEthApiServer< + Provider = ::Provider, + Pool = ::Pool, + > + AddDevSigners, +{ + type EthApi = EvEthApiFor; + + async fn build_eth_api(self, ctx: EthApiCtx<'_, N>) -> eyre::Result { + let receipt_converter = + EvReceiptConverter::new(FullNodeComponents::provider(ctx.components).chain_spec()); + let rpc_converter = RpcConverter::new(receipt_converter) + .with_sim_tx_converter(EvSimTxConverter) + .with_rpc_tx_converter(EvRpcTxConverter); + let rpc_converter = + rpc_converter.with_tx_env_converter(EvTxEnvConverter::::default()); + + Ok(ctx + .eth_api_builder() + .with_rpc_converter(rpc_converter) + .build()) + } +} + +/// Converts `EvTxEnvelope` into RPC transaction responses. +#[derive(Clone, Debug, Default)] +pub struct EvRpcTxConverter; + +impl RpcTxConverter, TransactionInfo> + for EvRpcTxConverter +{ + type Err = EthApiError; + + fn convert_rpc_tx( + &self, + tx: EvTxEnvelope, + signer: Address, + tx_info: TransactionInfo, + ) -> Result, Self::Err> { + let fee_payer = match &tx { + EvTxEnvelope::EvNode(ev) => ev + .tx() + .fee_payer_signature + .as_ref() + .and_then(|sig| ev.tx().recover_sponsor(signer, sig).ok()), + EvTxEnvelope::Ethereum(_) => None, + }; + let recovered = Recovered::new_unchecked(tx, signer); + Ok(EvRpcTransaction::new( + Transaction::from_transaction(recovered, tx_info), + fee_payer, + )) + } +} + +/// Converts transaction requests into simulated `EvTxEnvelope` transactions. +#[derive(Clone, Debug, Default)] +pub struct EvSimTxConverter; + +impl SimTxConverter, EvTxEnvelope> for EvSimTxConverter { + type Err = ValueError>; + + fn convert_sim_tx(&self, tx_req: RpcTxReq) -> Result { + tx_req + .0 + .try_into_sim_tx() + .map(EvTxEnvelope::Ethereum) + .map_err(|err| err.map(EvTransactionRequest)) + } +} + +/// Converts transaction requests into `EvTxEnv`. +#[derive(Clone, Debug)] +pub struct EvTxEnvConverter(PhantomData); + +impl Default for EvTxEnvConverter { + fn default() -> Self { + Self(PhantomData) + } +} + +impl TxEnvConverter, TxEnvFor, SpecFor> + for EvTxEnvConverter +where + Evm: ConfigureEvm + Send + Sync + 'static, + TxEnvFor: From, +{ + type Error = EthTxEnvError; + + fn convert_tx_env( + &self, + tx_req: RpcTxReq, + cfg_env: &reth_revm::revm::context::CfgEnv>, + block_env: &reth_revm::revm::context::BlockEnv, + ) -> Result, Self::Error> { + tx_req + .0 + .try_into_tx_env(cfg_env, block_env) + .map(EvTxEnv::from) + .map(Into::into) + } +} diff --git a/crates/node/src/txpool.rs b/crates/node/src/txpool.rs new file mode 100644 index 0000000..c700f8b --- /dev/null +++ b/crates/node/src/txpool.rs @@ -0,0 +1,525 @@ +use std::sync::Arc; + +use alloy_consensus::{ + transaction::{Recovered, TxHashRef}, + BlobTransactionValidationError, Signed, Typed2718, +}; +use alloy_eips::{ + eip2718::{Encodable2718, WithEncoded}, + eip7594::BlobTransactionSidecarVariant, + eip7840::BlobParams, + merge::EPOCH_SLOTS, +}; +use alloy_primitives::{Address, U256}; +use c_kzg::KzgSettings; +use ev_primitives::{EvNodeTransaction, EvPooledTxEnvelope, EvTxEnvelope, TransactionSigned}; +use reth_chainspec::{ChainSpecProvider, EthChainSpec, EthereumHardforks}; +use reth_node_api::{FullNodeTypes, NodeTypes}; +use reth_node_builder::{ + components::{create_blob_store_with_cache, PoolBuilder, TxPoolBuilder}, + BuilderContext, +}; +use reth_primitives_traits::NodePrimitives; +use reth_storage_api::{AccountInfoReader, StateProviderFactory}; +use reth_transaction_pool::{ + blobstore::DiskFileBlobStore, + error::{InvalidPoolTransactionError, PoolTransactionError}, + CoinbaseTipOrdering, EthBlobTransactionSidecar, EthPoolTransaction, EthPooledTransaction, + EthTransactionValidator, PoolTransaction, TransactionOrigin, TransactionValidationOutcome, + TransactionValidationTaskExecutor, TransactionValidator, +}; +use tracing::{debug, info}; + +/// Pool transaction wrapper for `EvTxEnvelope`. +#[derive(Debug, Clone)] +pub struct EvPooledTransaction { + inner: EthPooledTransaction, +} + +impl EvPooledTransaction { + /// Creates a new pooled transaction from a recovered envelope and encoded length. + pub fn new(transaction: Recovered, encoded_length: usize) -> Self { + Self { + inner: EthPooledTransaction::new(transaction, encoded_length), + } + } + + /// Returns the recovered transaction. + pub const fn transaction(&self) -> &Recovered { + self.inner.transaction() + } +} + +impl PoolTransaction for EvPooledTransaction { + type TryFromConsensusError = + alloy_consensus::error::ValueError; + type Consensus = EvTxEnvelope; + type Pooled = EvPooledTxEnvelope; + + fn clone_into_consensus(&self) -> Recovered { + self.inner.transaction().clone() + } + + fn into_consensus(self) -> Recovered { + self.inner.transaction + } + + fn into_consensus_with2718(self) -> WithEncoded> { + self.inner.transaction.into_encoded() + } + + fn from_pooled(tx: Recovered) -> Self { + let encoded_length = tx.encode_2718_len(); + let (tx, signer) = tx.into_parts(); + match tx { + EvPooledTxEnvelope::Ethereum(tx) => match tx { + reth_ethereum_primitives::PooledTransactionVariant::Eip4844(tx) => { + let (tx, sig, hash) = tx.into_parts(); + let (tx, blob) = tx.into_parts(); + let tx = Signed::new_unchecked(tx, sig, hash); + let tx = reth_ethereum_primitives::TransactionSigned::from(tx); + let tx = EvTxEnvelope::Ethereum(tx); + let tx = Recovered::new_unchecked(tx, signer); + let mut pooled = Self::new(tx, encoded_length); + pooled.inner.blob_sidecar = EthBlobTransactionSidecar::Present(blob); + pooled + } + tx => { + let tx = EvTxEnvelope::Ethereum(tx.into()); + let tx = Recovered::new_unchecked(tx, signer); + Self::new(tx, encoded_length) + } + }, + EvPooledTxEnvelope::EvNode(tx) => { + let tx = EvTxEnvelope::EvNode(tx); + let tx = Recovered::new_unchecked(tx, signer); + Self::new(tx, encoded_length) + } + } + } + + fn hash(&self) -> &alloy_primitives::TxHash { + self.inner.transaction.tx_hash() + } + + fn sender(&self) -> Address { + self.inner.transaction.signer() + } + + fn sender_ref(&self) -> &Address { + self.inner.transaction.signer_ref() + } + + fn cost(&self) -> &U256 { + &self.inner.cost + } + + fn encoded_length(&self) -> usize { + self.inner.encoded_length + } +} + +impl Typed2718 for EvPooledTransaction { + fn ty(&self) -> u8 { + self.inner.ty() + } +} + +impl reth_primitives_traits::InMemorySize for EvPooledTransaction { + fn size(&self) -> usize { + self.inner.size() + } +} + +impl alloy_consensus::Transaction for EvPooledTransaction { + fn chain_id(&self) -> Option { + self.inner.chain_id() + } + + fn nonce(&self) -> u64 { + self.inner.nonce() + } + + fn gas_limit(&self) -> u64 { + self.inner.gas_limit() + } + + fn gas_price(&self) -> Option { + self.inner.gas_price() + } + + fn max_fee_per_gas(&self) -> u128 { + self.inner.max_fee_per_gas() + } + + fn max_priority_fee_per_gas(&self) -> Option { + self.inner.max_priority_fee_per_gas() + } + + fn max_fee_per_blob_gas(&self) -> Option { + self.inner.max_fee_per_blob_gas() + } + + fn priority_fee_or_price(&self) -> u128 { + self.inner.priority_fee_or_price() + } + + fn effective_gas_price(&self, base_fee: Option) -> u128 { + self.inner.effective_gas_price(base_fee) + } + + fn is_dynamic_fee(&self) -> bool { + self.inner.is_dynamic_fee() + } + + fn kind(&self) -> alloy_primitives::TxKind { + self.inner.kind() + } + + fn is_create(&self) -> bool { + self.inner.is_create() + } + + fn value(&self) -> U256 { + self.inner.value() + } + + fn input(&self) -> &alloy_primitives::Bytes { + self.inner.input() + } + + fn access_list(&self) -> Option<&alloy_eips::eip2930::AccessList> { + self.inner.access_list() + } + + fn blob_versioned_hashes(&self) -> Option<&[alloy_primitives::B256]> { + self.inner.blob_versioned_hashes() + } + + fn authorization_list(&self) -> Option<&[alloy_eips::eip7702::SignedAuthorization]> { + self.inner.authorization_list() + } +} + +impl EthPoolTransaction for EvPooledTransaction { + fn take_blob(&mut self) -> EthBlobTransactionSidecar { + if self.is_eip4844() { + std::mem::replace( + &mut self.inner.blob_sidecar, + EthBlobTransactionSidecar::Missing, + ) + } else { + EthBlobTransactionSidecar::None + } + } + + fn try_into_pooled_eip4844( + self, + sidecar: std::sync::Arc, + ) -> Option> { + let (signed_transaction, signer) = self.into_consensus().into_parts(); + match signed_transaction { + EvTxEnvelope::Ethereum(tx) => { + let pooled_transaction = tx + .try_into_pooled_eip4844(std::sync::Arc::unwrap_or_clone(sidecar)) + .ok()?; + Some(Recovered::new_unchecked( + EvPooledTxEnvelope::Ethereum(pooled_transaction), + signer, + )) + } + EvTxEnvelope::EvNode(_) => None, + } + } + + fn try_from_eip4844( + tx: Recovered, + sidecar: BlobTransactionSidecarVariant, + ) -> Option { + let (tx, signer) = tx.into_parts(); + match tx { + EvTxEnvelope::Ethereum(tx) => tx + .try_into_pooled_eip4844(sidecar) + .ok() + .map(|tx| Recovered::new_unchecked(EvPooledTxEnvelope::Ethereum(tx), signer)) + .map(Self::from_pooled), + EvTxEnvelope::EvNode(_) => None, + } + } + + fn validate_blob( + &self, + sidecar: &BlobTransactionSidecarVariant, + settings: &KzgSettings, + ) -> Result<(), BlobTransactionValidationError> { + match self.inner.transaction.inner() { + EvTxEnvelope::Ethereum(tx) => match tx.as_eip4844() { + Some(tx) => tx.tx().validate_blob(sidecar, settings), + None => Err(BlobTransactionValidationError::NotBlobTransaction( + self.ty(), + )), + }, + EvTxEnvelope::EvNode(_) => Err(BlobTransactionValidationError::NotBlobTransaction( + self.ty(), + )), + } + } +} + +/// Errors returned by EV-specific transaction pool validation. +#[derive(Debug, thiserror::Error)] +pub enum EvTxPoolError { + /// `EvNode` transaction must include at least one call. + #[error("evnode transaction must include at least one call")] + EmptyCalls, + /// Only the first call may be a CREATE. + #[error("only the first call may be CREATE")] + InvalidCreatePosition, + /// Sponsor signature failed verification. + #[error("invalid sponsor signature")] + InvalidSponsorSignature, + /// Error while querying account info from the state provider. + #[error("state provider error: {0}")] + StateProvider(String), +} + +impl PoolTransactionError for EvTxPoolError { + fn is_bad_transaction(&self) -> bool { + matches!( + self, + Self::EmptyCalls | Self::InvalidCreatePosition | Self::InvalidSponsorSignature + ) + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} + +/// Transaction validator that adds EV-specific checks on top of the base validator. +#[derive(Debug, Clone)] +pub struct EvTransactionValidator { + inner: Arc>, +} + +impl EvTransactionValidator { + /// Wraps the provided Ethereum validator with EV-specific validation logic. + pub fn new(inner: EthTransactionValidator) -> Self { + Self { + inner: Arc::new(inner), + } + } + + fn validate_evnode_calls( + &self, + tx: &EvNodeTransaction, + ) -> Result<(), InvalidPoolTransactionError> { + if tx.calls.is_empty() { + return Err(InvalidPoolTransactionError::other( + EvTxPoolError::EmptyCalls, + )); + } + if tx.calls.iter().skip(1).any(|call| call.to.is_create()) { + return Err(InvalidPoolTransactionError::other( + EvTxPoolError::InvalidCreatePosition, + )); + } + Ok(()) + } + + fn ensure_state( + &self, + state: &mut Option>, + ) -> Result<(), InvalidPoolTransactionError> + where + Client: StateProviderFactory, + { + if state.is_none() { + let new_state = self.inner.client().latest().map_err(|err| { + InvalidPoolTransactionError::other(EvTxPoolError::StateProvider(err.to_string())) + })?; + *state = Some(Box::new(new_state)); + } + Ok(()) + } + + fn validate_sponsor_balance( + &self, + state: &mut Option>, + sponsor: Address, + gas_cost: U256, + ) -> Result<(), InvalidPoolTransactionError> + where + Client: StateProviderFactory, + { + self.ensure_state(state)?; + let state = state.as_ref().expect("state provider is set"); + let account = state + .basic_account(&sponsor) + .map_err(|err| { + InvalidPoolTransactionError::other(EvTxPoolError::StateProvider(err.to_string())) + })? + .unwrap_or_default(); + if account.balance < gas_cost { + return Err(InvalidPoolTransactionError::Overdraft { + cost: gas_cost, + balance: account.balance, + }); + } + Ok(()) + } + + fn validate_evnode( + &self, + pooled: &EvPooledTransaction, + sender_balance: U256, + state: &mut Option>, + ) -> Result<(), InvalidPoolTransactionError> + where + Client: StateProviderFactory, + { + let consensus = pooled.transaction().inner(); + let EvTxEnvelope::EvNode(tx) = consensus else { + if sender_balance < *pooled.cost() { + return Err(InvalidPoolTransactionError::Overdraft { + cost: *pooled.cost(), + balance: sender_balance, + }); + } + return Ok(()); + }; + + let tx = tx.tx(); + self.validate_evnode_calls(tx)?; + + if let Some(signature) = tx.fee_payer_signature.as_ref() { + let executor = pooled.transaction().signer(); + let sponsor = tx.recover_sponsor(executor, signature).map_err(|_| { + InvalidPoolTransactionError::other(EvTxPoolError::InvalidSponsorSignature) + })?; + + let gas_cost = U256::from(tx.max_fee_per_gas).saturating_mul(U256::from(tx.gas_limit)); + self.validate_sponsor_balance(state, sponsor, gas_cost)?; + } + + Ok(()) + } +} + +impl TransactionValidator for EvTransactionValidator +where + Client: ChainSpecProvider + StateProviderFactory, +{ + type Transaction = EvPooledTransaction; + + async fn validate_transaction( + &self, + origin: TransactionOrigin, + transaction: ::Transaction, + ) -> TransactionValidationOutcome { + let mut state = None; + let outcome = self + .inner + .validate_one_with_state(origin, transaction, &mut state); + + match outcome { + TransactionValidationOutcome::Valid { + balance, + state_nonce, + bytecode_hash, + transaction, + propagate, + authorities, + } => match self.validate_evnode(transaction.transaction(), balance, &mut state) { + Ok(()) => TransactionValidationOutcome::Valid { + balance, + state_nonce, + bytecode_hash, + transaction, + propagate, + authorities, + }, + Err(err) => { + TransactionValidationOutcome::Invalid(transaction.into_transaction(), err) + } + }, + other => other, + } + } +} + +/// Pool builder that wires the custom `EvNode` transaction validator. +#[derive(Debug, Default, Clone, Copy)] +#[non_exhaustive] +pub struct EvolvePoolBuilder; + +impl PoolBuilder for EvolvePoolBuilder +where + Types: NodeTypes< + ChainSpec: EthereumHardforks, + Primitives: NodePrimitives, + >, + Node: FullNodeTypes, +{ + type Pool = reth_transaction_pool::Pool< + TransactionValidationTaskExecutor>, + CoinbaseTipOrdering, + DiskFileBlobStore, + >; + + async fn build_pool(self, ctx: &BuilderContext) -> eyre::Result { + let pool_config = ctx.pool_config(); + + let blobs_disabled = ctx.config().txpool.blobpool_max_count == 0; + + let blob_cache_size = if let Some(blob_cache_size) = pool_config.blob_cache_size { + Some(blob_cache_size) + } else { + let current_timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs(); + let blob_params = ctx + .chain_spec() + .blob_params_at_timestamp(current_timestamp) + .unwrap_or_else(BlobParams::cancun); + + Some((blob_params.target_blob_count * EPOCH_SLOTS * 2) as u32) + }; + + let blob_store = create_blob_store_with_cache(ctx, blob_cache_size)?; + + let validator = TransactionValidationTaskExecutor::eth_builder(ctx.provider().clone()) + .with_head_timestamp(ctx.head().timestamp) + .set_eip4844(!blobs_disabled) + .kzg_settings(ctx.kzg_settings()?) + .with_max_tx_input_bytes(ctx.config().txpool.max_tx_input_bytes) + .with_local_transactions_config(pool_config.local_transactions_config.clone()) + .set_tx_fee_cap(ctx.config().rpc.rpc_tx_fee_cap) + .with_max_tx_gas_limit(ctx.config().txpool.max_tx_gas_limit) + .with_minimum_priority_fee(ctx.config().txpool.minimum_priority_fee) + .disable_balance_check() + .with_additional_tasks(ctx.config().txpool.additional_validation_tasks) + .build_with_tasks::( + ctx.task_executor().clone(), + blob_store.clone(), + ) + .map(EvTransactionValidator::new); + + if validator.validator().inner.eip4844() { + let kzg_settings = validator.validator().inner.kzg_settings().clone(); + ctx.task_executor().spawn_blocking(async move { + let _ = kzg_settings.get(); + debug!(target: "reth::cli", "Initialized KZG settings"); + }); + } + + let transaction_pool = TxPoolBuilder::new(ctx) + .with_validator(validator) + .build_and_spawn_maintenance_task(blob_store, pool_config)?; + + info!(target: "reth::cli", "Transaction pool initialized"); + debug!(target: "reth::cli", "Spawned txpool maintenance task"); + + Ok(transaction_pool) + } +} diff --git a/crates/node/src/validator.rs b/crates/node/src/validator.rs index 5b1e285..93052ad 100644 --- a/crates/node/src/validator.rs +++ b/crates/node/src/validator.rs @@ -2,7 +2,10 @@ use std::sync::Arc; +use alloy_consensus::Header; +use alloy_eips::Decodable2718; use alloy_rpc_types::engine::ExecutionData; +use ev_primitives::{Block as EvBlock, BlockBody as EvBlockBody, EvTxEnvelope}; use reth_ethereum::{ chainspec::ChainSpec, node::{ @@ -16,7 +19,7 @@ use reth_ethereum::{ }, }; use reth_ethereum_payload_builder::EthereumExecutionPayloadValidator; -use reth_primitives_traits::{Block as _, RecoveredBlock}; +use reth_primitives_traits::{Block as _, RecoveredBlock, SealedBlock}; use tracing::info; use crate::{attributes::EvolveEnginePayloadAttributes, node::EvolveEngineTypes}; @@ -43,7 +46,7 @@ impl EvolveEngineValidator { } impl PayloadValidator for EvolveEngineValidator { - type Block = reth_ethereum::Block; + type Block = ev_primitives::Block; fn ensure_well_formed_payload( &self, @@ -55,7 +58,8 @@ impl PayloadValidator for EvolveEngineValidator { match self.inner.ensure_well_formed_payload(payload.clone()) { Ok(sealed_block) => { info!("Evolve engine validator: payload validation succeeded"); - sealed_block + let ev_block = convert_sealed_block(sealed_block); + ev_block .try_recover() .map_err(|e| NewPayloadError::Other(e.into())) } @@ -63,13 +67,20 @@ impl PayloadValidator for EvolveEngineValidator { // Log the error for debugging. tracing::debug!("Evolve payload validation error: {:?}", err); - // Check if this is a block hash mismatch error - bypass it for evolve. - if matches!(err, alloy_rpc_types::engine::PayloadError::BlockHash { .. }) { - info!("Evolve engine validator: bypassing block hash mismatch for ev-reth"); - // For evolve, we trust the payload builder - just parse the block without hash validation. - let ExecutionData { payload, sidecar } = payload; - let sealed_block = payload.try_into_block_with_sidecar(&sidecar)?.seal_slow(); - sealed_block + // Check if this is an error we can bypass for evolve (block hash mismatch or + // unknown tx type for EvNode transactions). + let should_bypass = + matches!(err, alloy_rpc_types::engine::PayloadError::BlockHash { .. }) + || err.to_string().contains("unexpected tx type"); + + if should_bypass { + info!( + "Evolve engine validator: bypassing validation error for ev-reth: {:?}", + err + ); + // For evolve, we trust the payload builder - parse the block with EvNode support. + let ev_block = parse_evolve_payload(payload)?; + ev_block .try_recover() .map_err(|e| NewPayloadError::Other(e.into())) } else { @@ -90,6 +101,69 @@ impl PayloadValidator for EvolveEngineValidator { } } +fn convert_sealed_block( + sealed_block: SealedBlock, +) -> SealedBlock { + let (block, hash) = sealed_block.split(); + let ev_block = block.map_transactions(EvTxEnvelope::Ethereum); + SealedBlock::new_unchecked(ev_block, hash) +} + +/// Parses an execution payload containing `EvNode` transactions. +fn parse_evolve_payload( + payload: ExecutionData, +) -> Result, NewPayloadError> { + let ExecutionData { payload, sidecar } = payload; + + // Parse transactions using EvTxEnvelope which supports both Ethereum and EvNode types. + let transactions: Vec = payload + .transactions() + .iter() + .map(|tx| { + EvTxEnvelope::decode_2718(&mut tx.as_ref()) + .map_err(|e| NewPayloadError::Other(Box::new(e))) + }) + .collect::, _>>()?; + + // Build the block header from payload using the common accessor methods. + let v1 = payload.as_v1(); + let header = Header { + parent_hash: payload.parent_hash(), + ommers_hash: alloy_consensus::EMPTY_OMMER_ROOT_HASH, + beneficiary: payload.fee_recipient(), + state_root: v1.state_root, + transactions_root: alloy_consensus::proofs::calculate_transaction_root(&transactions), + receipts_root: v1.receipts_root, + logs_bloom: v1.logs_bloom, + difficulty: alloy_primitives::U256::ZERO, + number: payload.block_number(), + gas_limit: payload.gas_limit(), + gas_used: v1.gas_used, + timestamp: payload.timestamp(), + extra_data: v1.extra_data.clone(), + mix_hash: payload.prev_randao(), + nonce: alloy_primitives::B64::ZERO, + base_fee_per_gas: Some(payload.saturated_base_fee_per_gas()), + withdrawals_root: payload + .withdrawals() + .map(|w| alloy_consensus::proofs::calculate_withdrawals_root(w)), + blob_gas_used: payload.blob_gas_used(), + excess_blob_gas: payload.excess_blob_gas(), + parent_beacon_block_root: sidecar.parent_beacon_block_root(), + requests_hash: None, + }; + + // Build block body. + let body = EvBlockBody { + transactions, + ommers: vec![], + withdrawals: payload.withdrawals().cloned().map(Into::into), + }; + + let block = EvBlock::new(header, body); + Ok(block.seal_slow()) +} + impl EngineApiValidator for EvolveEngineValidator { fn validate_version_specific_fields( &self, @@ -135,7 +209,7 @@ where Types: NodeTypes< Payload = EvolveEngineTypes, ChainSpec = ChainSpec, - Primitives = reth_ethereum::EthPrimitives, + Primitives = ev_primitives::EvPrimitives, >, >, { diff --git a/crates/tests/Cargo.toml b/crates/tests/Cargo.toml index ea4a864..a58d8e9 100644 --- a/crates/tests/Cargo.toml +++ b/crates/tests/Cargo.toml @@ -16,6 +16,7 @@ ev-node = { path = "../node" } ev-common = { path = "../common" } ev-revm.workspace = true ev-precompiles = { path = "../ev-precompiles" } +ev-primitives = { path = "../ev-primitives" } # Reth dependencies reth-testing-utils.workspace = true diff --git a/crates/tests/assets/genesis.json b/crates/tests/assets/genesis.json index 6f24658..c40e542 100644 --- a/crates/tests/assets/genesis.json +++ b/crates/tests/assets/genesis.json @@ -1,6 +1,6 @@ { "config": { - "chainId": 1, + "chainId": 1234, "homesteadBlock": 0, "daoForkSupport": true, "eip150Block": 0, diff --git a/crates/tests/src/common.rs b/crates/tests/src/common.rs index 328ff55..fef082b 100644 --- a/crates/tests/src/common.rs +++ b/crates/tests/src/common.rs @@ -8,20 +8,19 @@ use std::sync::Arc; use alloy_consensus::{transaction::SignerRecoverable, TxLegacy, TypedTransaction}; use alloy_genesis::Genesis; use alloy_primitives::{Address, Bytes, ChainId, Signature, TxKind, B256, U256}; +use ev_primitives::{EvTxEnvelope, TransactionSigned}; use ev_revm::{ - with_ev_handler, BaseFeeRedirect, BaseFeeRedirectSettings, ContractSizeLimitSettings, - DeployAllowlistSettings, MintPrecompileSettings, + BaseFeeRedirect, BaseFeeRedirectSettings, ContractSizeLimitSettings, DeployAllowlistSettings, + EvTxEvmFactory, MintPrecompileSettings, }; use eyre::Result; use reth_chainspec::{ChainSpec, ChainSpecBuilder}; -use reth_ethereum_primitives::TransactionSigned; -use reth_evm_ethereum::EthEvmConfig; use reth_primitives::{Header, Transaction}; use reth_provider::test_utils::{ExtendedAccount, MockEthProvider}; use serde_json::json; use tempfile::TempDir; -use ev_node::{EvolvePayloadBuilder, EvolvePayloadBuilderConfig}; +use ev_node::{EvolveEvmConfig, EvolvePayloadBuilder, EvolvePayloadBuilderConfig}; use evolve_ev_reth::EvolvePayloadAttributes; // Test constants @@ -41,6 +40,11 @@ pub const TEST_GAS_LIMIT: u64 = 30_000_000; /// Base fee used in mock headers to satisfy post-London/EIP-4844 requirements pub const TEST_BASE_FEE: u64 = 0; +fn to_ev_envelope(transaction: Transaction, signature: Signature) -> TransactionSigned { + let signed = alloy_consensus::Signed::new_unhashed(transaction, signature); + EvTxEnvelope::Ethereum(reth_ethereum_primitives::TransactionSigned::from(signed)) +} + /// Creates a reusable chain specification for tests. pub fn create_test_chain_spec() -> Arc { create_test_chain_spec_with_extras(None, None, None) @@ -139,7 +143,6 @@ impl EvolveTestFixture { // Create a test chain spec with our test chain ID let test_chainspec = create_test_chain_spec(); - let evm_config = EthEvmConfig::new(test_chainspec.clone()); let config = EvolvePayloadBuilderConfig::from_chain_spec(test_chainspec.as_ref()).unwrap(); config.validate().unwrap(); @@ -157,13 +160,13 @@ impl EvolveTestFixture { let deploy_allowlist = config .deploy_allowlist_settings() .map(|(allowlist, activation)| DeployAllowlistSettings::new(allowlist, activation)); - let wrapped_evm = with_ev_handler( - evm_config, + let evm_factory = EvTxEvmFactory::new( base_fee_redirect, mint_precompile, deploy_allowlist, contract_size_limit, ); + let wrapped_evm = EvolveEvmConfig::new_with_evm_factory(test_chainspec, evm_factory); let builder = EvolvePayloadBuilder::new(Arc::new(provider.clone()), wrapped_evm, config); @@ -187,7 +190,7 @@ impl EvolveTestFixture { ); // Find which address the test signature resolves to - let test_signed = TransactionSigned::new_unhashed( + let test_signed = to_ev_envelope( Transaction::Legacy(TxLegacy { chain_id: Some(ChainId::from(TEST_CHAIN_ID)), nonce: 0, @@ -263,7 +266,7 @@ pub fn create_test_transactions(count: usize, nonce_start: u64) -> Vec Result<()> { Ok(()) } +/// Tests that a sponsored `EvNode` transaction charges gas to the sponsor, not the executor. +/// +/// # Test Flow +/// 1. Creates an executor and sponsor account from genesis-funded wallets +/// 2. Builds a sponsored `EvNode` transfer transaction (type 0x76) +/// 3. Includes the transaction in a block via the Engine API +/// 4. Verifies `feePayer` appears in the RPC tx and receipt responses +/// 5. Asserts balances: executor pays value, sponsor pays gas +/// +/// # Success Criteria +/// - Receipt and transaction expose the sponsor address +/// - Recipient receives the transfer value +/// - Executor balance decreases by exactly `value` +/// - Sponsor balance decreases by exactly `gas_used * effective_gas_price` +#[tokio::test(flavor = "multi_thread")] +async fn test_e2e_sponsored_evnode_transaction() -> Result<()> { + reth_tracing::init_test_tracing(); + + let chain_spec = create_test_chain_spec(); + let chain_id = chain_spec.chain().id(); + + let mut setup = Setup::::default() + .with_chain_spec(chain_spec) + .with_network(NetworkSetup::single_node()) + .with_dev_mode(true); + + let mut env = Environment::::default(); + setup.apply::(&mut env).await?; + + let parent_block = env.node_clients[0] + .get_block_by_number(BlockNumberOrTag::Latest) + .await? + .expect("parent block should exist"); + let mut parent_hash = parent_block.header.hash; + let mut parent_timestamp = parent_block.header.inner.timestamp; + let mut parent_number = parent_block.header.inner.number; + let gas_limit = parent_block.header.inner.gas_limit; + + let mut wallets = Wallet::new(3).with_chain_id(chain_id).wallet_gen(); + let executor = wallets.remove(0); + let sponsor = wallets.remove(0); + let recipient = Address::random(); + let executor_address = executor.address(); + let sponsor_address = sponsor.address(); + + let executor_balance_before = + EthApiClient::::balance( + &env.node_clients[0].rpc, + executor_address, + Some(BlockId::latest()), + ) + .await?; + let sponsor_balance_before = + EthApiClient::::balance( + &env.node_clients[0].rpc, + sponsor_address, + Some(BlockId::latest()), + ) + .await?; + let recipient_balance_before = + EthApiClient::::balance( + &env.node_clients[0].rpc, + recipient, + Some(BlockId::latest()), + ) + .await?; + + let executor_nonce = + EthApiClient::::transaction_count( + &env.node_clients[0].rpc, + executor_address, + Some(BlockId::latest()), + ) + .await?; + let executor_nonce = u64::try_from(executor_nonce).expect("nonce fits into u64"); + + let transfer_value = U256::from(1_000_000_000_000_000u64); // 0.001 ETH + let call = Call { + to: TxKind::Call(recipient), + value: transfer_value, + input: Bytes::default(), + }; + + let ev_tx = EvNodeTransaction { + chain_id, + nonce: executor_nonce, + max_priority_fee_per_gas: 1_000_000_000, + max_fee_per_gas: 2_000_000_000, + gas_limit: 100_000, + calls: vec![call], + access_list: AccessList::default(), + fee_payer_signature: None, + }; + + let executor_sig = executor + .sign_hash_sync(&ev_tx.signature_hash()) + .expect("executor signature"); + let mut signed = ev_tx.into_signed(executor_sig); + let sponsor_hash = signed.tx().sponsor_signing_hash(executor_address); + let sponsor_sig = sponsor + .sign_hash_sync(&sponsor_hash) + .expect("sponsor signature"); + signed.tx_mut().fee_payer_signature = Some(sponsor_sig); + + let envelope = EvTxEnvelope::EvNode(signed); + let raw_tx: Bytes = envelope.encoded_2718().into(); + let tx_hash = *envelope.tx_hash(); + + build_block_with_transactions( + &mut env, + &mut parent_hash, + &mut parent_number, + &mut parent_timestamp, + Some(gas_limit), + vec![raw_tx], + Address::ZERO, + ) + .await?; + + type EvRpcBlock = Block; + let receipt = EthApiClient::< + EvTransactionRequest, + EvRpcTransaction, + EvRpcBlock, + EvRpcReceipt, + Header, + >::transaction_receipt(&env.node_clients[0].rpc, tx_hash) + .await? + .expect("sponsored transaction receipt available"); + let receipt_inner = receipt.inner(); + assert!( + receipt_inner.status(), + "sponsored transaction should succeed" + ); + assert_eq!( + receipt.fee_payer(), + Some(sponsor_address), + "receipt should expose sponsor fee payer" + ); + + let tx = EthApiClient::::transaction_by_hash( + &env.node_clients[0].rpc, + tx_hash, + ) + .await? + .expect("sponsored transaction available"); + assert_eq!( + tx.fee_payer(), + Some(sponsor_address), + "transaction should expose sponsor fee payer" + ); + + let executor_balance_after = + EthApiClient::::balance( + &env.node_clients[0].rpc, + executor_address, + Some(BlockId::latest()), + ) + .await?; + let sponsor_balance_after = + EthApiClient::::balance( + &env.node_clients[0].rpc, + sponsor_address, + Some(BlockId::latest()), + ) + .await?; + let recipient_balance_after = + EthApiClient::::balance( + &env.node_clients[0].rpc, + recipient, + Some(BlockId::latest()), + ) + .await?; + + let executor_spent = executor_balance_before.saturating_sub(executor_balance_after); + let sponsor_spent = sponsor_balance_before.saturating_sub(sponsor_balance_after); + let recipient_gain = recipient_balance_after.saturating_sub(recipient_balance_before); + assert_eq!( + recipient_gain, transfer_value, + "recipient should receive transfer value" + ); + assert_eq!( + executor_spent, transfer_value, + "executor should only pay value when sponsored" + ); + + let expected_gas_cost = U256::from(receipt_inner.gas_used) + .saturating_mul(U256::from(receipt_inner.effective_gas_price)); + assert_eq!( + sponsor_spent, expected_gas_cost, + "sponsor should pay gas cost" + ); + + drop(setup); + + Ok(()) +} + +/// Tests that an invalid sponsor signature is skipped during payload construction. +/// +/// # Test Flow +/// 1. Creates an executor account from genesis-funded wallets +/// 2. Builds an `EvNode` transaction with an invalid sponsor signature +/// 3. Attempts to build a payload via the Engine API +/// +/// # Success Criteria +/// - Payload is built successfully +/// - Invalid transaction is not included +#[tokio::test(flavor = "multi_thread")] +async fn test_e2e_invalid_sponsor_signature_skipped() -> Result<()> { + reth_tracing::init_test_tracing(); + + let chain_spec = create_test_chain_spec(); + let chain_id = chain_spec.chain().id(); + + let mut setup = Setup::::default() + .with_chain_spec(chain_spec) + .with_network(NetworkSetup::single_node()) + .with_dev_mode(true); + + let mut env = Environment::::default(); + setup.apply::(&mut env).await?; + + let parent_block = env.node_clients[0] + .get_block_by_number(BlockNumberOrTag::Latest) + .await? + .expect("parent block should exist"); + let mut parent_hash = parent_block.header.hash; + let mut parent_timestamp = parent_block.header.inner.timestamp; + let mut parent_number = parent_block.header.inner.number; + let gas_limit = parent_block.header.inner.gas_limit; + + let mut wallets = Wallet::new(1).with_chain_id(chain_id).wallet_gen(); + let executor = wallets.remove(0); + let executor_address = executor.address(); + + let executor_nonce = + EthApiClient::::transaction_count( + &env.node_clients[0].rpc, + executor_address, + Some(BlockId::latest()), + ) + .await?; + let executor_nonce = u64::try_from(executor_nonce).expect("nonce fits into u64"); + + let call = Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::default(), + }; + + let ev_tx = EvNodeTransaction { + chain_id, + nonce: executor_nonce, + max_priority_fee_per_gas: 1_000_000_000, + max_fee_per_gas: 2_000_000_000, + gas_limit: 100_000, + calls: vec![call], + access_list: AccessList::default(), + fee_payer_signature: None, + }; + + let executor_sig = executor + .sign_hash_sync(&ev_tx.signature_hash()) + .expect("executor signature"); + let mut signed = ev_tx.into_signed(executor_sig); + + let mut invalid_sig_bytes = [0u8; 65]; + invalid_sig_bytes[64] = 27; + let invalid_sig = + Signature::from_raw_array(&invalid_sig_bytes).expect("invalid sponsor signature bytes"); + signed.tx_mut().fee_payer_signature = Some(invalid_sig); + + let envelope = EvTxEnvelope::EvNode(signed); + let raw_tx: Bytes = envelope.encoded_2718().into(); + let tx_hash = *envelope.tx_hash(); + + let payload_envelope = build_block_with_transactions( + &mut env, + &mut parent_hash, + &mut parent_number, + &mut parent_timestamp, + Some(gas_limit), + vec![raw_tx], + Address::ZERO, + ) + .await?; + + let payload_inner = payload_envelope + .execution_payload + .payload_inner + .payload_inner; + assert!( + payload_inner.transactions.is_empty(), + "invalid sponsor tx should be skipped" + ); + + let tx = EthApiClient::< + EvTransactionRequest, + EvRpcTransaction, + Block, + EvRpcReceipt, + Header, + >::transaction_by_hash(&env.node_clients[0].rpc, tx_hash) + .await?; + assert!( + tx.is_none(), + "invalid sponsor tx should not be in the block" + ); + + drop(setup); + + Ok(()) +} + +/// Tests that an `EvNode` transaction with empty calls is skipped during payload construction. +/// +/// # Test Flow +/// 1. Creates an executor account from genesis-funded wallets +/// 2. Builds an `EvNode` transaction with an empty calls list +/// 3. Attempts to build a payload via the Engine API +/// +/// # Success Criteria +/// - Payload is built successfully +/// - Invalid transaction is not included +#[tokio::test(flavor = "multi_thread")] +async fn test_e2e_empty_calls_skipped() -> Result<()> { + reth_tracing::init_test_tracing(); + + let chain_spec = create_test_chain_spec(); + let chain_id = chain_spec.chain().id(); + + let mut setup = Setup::::default() + .with_chain_spec(chain_spec) + .with_network(NetworkSetup::single_node()) + .with_dev_mode(true); + + let mut env = Environment::::default(); + setup.apply::(&mut env).await?; + + let parent_block = env.node_clients[0] + .get_block_by_number(BlockNumberOrTag::Latest) + .await? + .expect("parent block should exist"); + let mut parent_hash = parent_block.header.hash; + let mut parent_timestamp = parent_block.header.inner.timestamp; + let mut parent_number = parent_block.header.inner.number; + let gas_limit = parent_block.header.inner.gas_limit; + + let mut wallets = Wallet::new(1).with_chain_id(chain_id).wallet_gen(); + let executor = wallets.remove(0); + let executor_address = executor.address(); + + let executor_nonce = + EthApiClient::::transaction_count( + &env.node_clients[0].rpc, + executor_address, + Some(BlockId::latest()), + ) + .await?; + let executor_nonce = u64::try_from(executor_nonce).expect("nonce fits into u64"); + + let ev_tx = EvNodeTransaction { + chain_id, + nonce: executor_nonce, + max_priority_fee_per_gas: 1_000_000_000, + max_fee_per_gas: 2_000_000_000, + gas_limit: 100_000, + calls: Vec::new(), + access_list: AccessList::default(), + fee_payer_signature: None, + }; + + let executor_sig = executor + .sign_hash_sync(&ev_tx.signature_hash()) + .expect("executor signature"); + let signed = ev_tx.into_signed(executor_sig); + + let envelope = EvTxEnvelope::EvNode(signed); + let raw_tx: Bytes = envelope.encoded_2718().into(); + let tx_hash = *envelope.tx_hash(); + + let payload_envelope = build_block_with_transactions( + &mut env, + &mut parent_hash, + &mut parent_number, + &mut parent_timestamp, + Some(gas_limit), + vec![raw_tx], + Address::ZERO, + ) + .await?; + + let payload_inner = payload_envelope + .execution_payload + .payload_inner + .payload_inner; + assert!( + payload_inner.transactions.is_empty(), + "empty calls tx should be skipped" + ); + + let tx = EthApiClient::< + EvTransactionRequest, + EvRpcTransaction, + Block, + EvRpcReceipt, + Header, + >::transaction_by_hash(&env.node_clients[0].rpc, tx_hash) + .await?; + assert!(tx.is_none(), "empty calls tx should not be in the block"); + + drop(setup); + + Ok(()) +} + /// Tests minting and burning tokens to/from a dynamically generated wallet not in genesis. /// /// # Test Flow