diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0273581..5adf40d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,3 +14,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- AdminProxy contract for administrative operations ([#97](https://github.com/evstack/ev-reth/pull/97))
- ADR 003: typed sponsorship transactions and batch execution documentation ([#96](https://github.com/evstack/ev-reth/pull/96))
- Fee system guide documentation ([#101](https://github.com/evstack/ev-reth/pull/101))
+- ADR 003 implementation: EvNode transaction type (0x76) with batch calls and fee-payer sponsorship ([#103](https://github.com/evstack/ev-reth/pull/103))
diff --git a/Cargo.lock b/Cargo.lock
index 8bcdf71..3328804 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2878,15 +2878,21 @@ name = "ev-node"
version = "0.1.0"
dependencies = [
"alloy-consensus",
+ "alloy-consensus-any",
"alloy-eips",
"alloy-evm",
"alloy-genesis",
+ "alloy-network",
"alloy-primitives",
+ "alloy-rlp",
"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",
@@ -2895,6 +2901,7 @@ dependencies = [
"reth-basic-payload-builder",
"reth-chainspec",
"reth-cli",
+ "reth-codecs",
"reth-consensus",
"reth-db",
"reth-engine-local",
@@ -2918,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",
@@ -2951,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"
@@ -3002,6 +3031,7 @@ dependencies = [
"alloy-primitives",
"alloy-sol-types",
"ev-precompiles",
+ "ev-primitives",
"reth-evm",
"reth-evm-ethereum",
"reth-primitives",
@@ -3030,6 +3060,7 @@ dependencies = [
"ev-common",
"ev-node",
"ev-precompiles",
+ "ev-primitives",
"ev-revm",
"evolve-ev-reth",
"eyre",
@@ -3080,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 6de2dbb..7f98020 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -2,12 +2,13 @@
resolver = "2"
members = [
"bin/ev-reth",
- "crates/common",
- "crates/evolve",
- "crates/node",
- "crates/tests",
- "crates/ev-precompiles",
- "crates/ev-revm",
+ "crates/common",
+ "crates/ev-primitives",
+ "crates/evolve",
+ "crates/node",
+ "crates/tests",
+ "crates/ev-precompiles",
+ "crates/ev-revm",
]
[workspace.package]
@@ -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", default-features = false }
reth-evm = { git = "https://github.com/paradigmxyz/reth.git", default-features = false, tag = "v1.8.4" }
reth-evm-ethereum = { git = "https://github.com/paradigmxyz/reth.git", default-features = false, tag = "v1.8.4" }
@@ -69,8 +71,12 @@ reth-revm = { git = "https://github.com/paradigmxyz/reth.git", default-features
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
@@ -103,12 +109,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", default-features = false }
# Core dependencies
eyre = "0.6"
diff --git a/README.md b/README.md
index b5fe930..423048a 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,7 @@ This project provides a modified version of Reth that includes:
- **Transaction Support**: Full support for including transactions in blocks via the Engine API `engine_forkchoiceUpdatedV3` method
- **Custom Consensus**: Modified consensus layer that allows multiple blocks to have the same timestamp
- **Txpool RPC Extension**: Custom `txpoolExt_getTxs` RPC method for efficient transaction retrieval with configurable size limits
+- **EvNode Transaction (Type 0x76)**: Custom transaction type supporting batch calls and gas sponsorship via a fee-payer mechanism
## Key Features
@@ -78,6 +79,48 @@ Ethereum enforces a 24KB contract size limit per [EIP-170](https://eips.ethereum
- Standard EIP-170 limit applies before activation
- See [Configuration](#custom-contract-size-limit) for setup details
+### 8. EvNode Transaction (Type 0x76)
+
+ev-reth introduces a custom EIP-2718 transaction type (`0x76`) that enables batch calls and sponsored (fee-payer) transactions.
+
+#### Batch Calls
+
+An EvNode transaction replaces the standard single `to`/`value`/`input` with a vector of `Call` structs:
+
+```
+Call { to: Option
, value: U256, input: Bytes }
+```
+
+Multiple calls are executed atomically in a single transaction: if any call reverts, the entire batch is rolled back. Only the first call in a batch may be a contract creation (`to = null`).
+
+#### Sponsored Transactions (Fee Payer)
+
+An EvNode transaction supports an optional `fee_payer_signature` field that enables gas sponsorship:
+
+- **Without sponsor**: The executor (signer) pays both gas fees and value transfers, similar to a regular Ethereum transaction.
+- **With sponsor**: The sponsor pays gas fees (`max_fee_per_gas * gas_limit`), while the executor only needs balance for value transfers. Gas refunds go to the sponsor.
+
+How signatures work:
+
+1. **Executor** signs the transaction with domain `0x76` (the sponsor field is left empty).
+2. **Sponsor** signs a separate hash with domain `0x78`, which includes the executor's address. This binding prevents signature replay across different executors.
+
+#### RPC Representation
+
+EvNode transactions are exposed through the standard Ethereum JSON-RPC with an additional `feePayer` field:
+
+- `eth_getTransactionByHash`, `eth_getBlockByNumber`, etc. return an optional `"feePayer": "0x..."` for sponsored EvNode transactions.
+- Transaction receipts also include the `feePayer` field when applicable.
+
+#### Txpool Validation
+
+EvNode transactions go through additional validation in the transaction pool:
+
+- The `calls` vector must not be empty.
+- Only the first call can be a contract creation.
+- If sponsored, the sponsor signature must be valid and the sponsor must have sufficient balance for gas costs.
+- If not sponsored, the executor must have sufficient balance for both gas and value.
+
## Installation
### Prerequisites
@@ -182,7 +225,9 @@ Ev-reth follows a modular architecture similar to Odyssey, with clear separation
- **`bin/ev-reth`**: The main executable binary
- **`crates/common`**: Shared utilities and constants used across all crates
-- **`crates/node`**: Core node implementation including the payload builder
+- **`crates/ev-primitives`**: EvNode transaction primitives (type 0x76), pool types, and serialization
+- **`crates/ev-revm`**: Custom EVM handlers for batch calls, sponsorship, base fee redirect, and precompiles
+- **`crates/node`**: Core node implementation including payload builder, txpool, RPC, and executor
- **`crates/evolve`**: Evolve-specific types, RPC extensions, and integration logic
- **`crates/tests`**: Comprehensive test suite including unit and integration tests
@@ -226,13 +271,41 @@ This modular design allows for:
- Efficient transaction retrieval with size-based limits
- Returns RLP-encoded transaction bytes for Evolve consumption
+8. **EvNode Primitives** (`crates/ev-primitives/src/tx.rs`)
+ - Custom EIP-2718 transaction type (0x76) with batch calls and sponsor support
+ - Dual-signature scheme: executor (domain 0x76) and sponsor (domain 0x78)
+ - RLP encoding/decoding and Compact serialization for database storage
+
+9. **EvNode EVM Handlers** (`crates/ev-revm/src/handler.rs`)
+ - Batch call execution with atomic rollback
+ - Sponsored transaction validation and gas deduction from sponsor
+ - Gas refund routing to sponsor for sponsored transactions
+
+10. **EvNode Transaction Pool** (`crates/node/src/txpool.rs`)
+ - Custom pool validator for EvNode transactions
+ - Sponsor balance checks and signature verification
+ - Wraps standard Ethereum pool validation for regular transactions
+
+11. **EvNode RPC Extensions** (`crates/node/src/rpc.rs`)
+ - Extended transaction and receipt types with `feePayer` field
+ - Custom converters for EvNode transaction envelope handling
+
### Transaction Flow
+**Standard path (via Engine API):**
+
1. Evolve submits transactions via Engine API payload attributes
2. `EvolveEnginePayloadAttributes` decodes and validates transactions
3. `EvolvePayloadBuilder` executes transactions and builds block
4. Block is returned via standard Engine API response
+**EvNode transaction specifics:**
+
+1. EvNode transactions (type 0x76) enter the txpool via `eth_sendRawTransaction`
+2. Pool validator checks call structure, sponsor signature, and balance requirements
+3. When included in a block, the EVM handler executes batch calls atomically
+4. For sponsored transactions, gas is deducted from the sponsor and refunds go back to the sponsor
+
## Configuration
### Redirecting the Base Fee (Custom Networks Only)
@@ -412,39 +485,57 @@ ev-reth/
│ └── ev-reth/ # Main binary
│ ├── Cargo.toml
│ └── src/
-│ └── main.rs # Binary with Engine API integration
+│ └── main.rs # Binary with Engine API integration
├── crates/
-│ ├── common/ # Shared utilities and constants
+│ ├── common/ # Shared utilities and constants
│ │ ├── Cargo.toml
│ │ └── src/
│ │ ├── lib.rs
│ │ └── constants.rs
-│ ├── node/ # Core node implementation
+│ ├── ev-primitives/ # EvNode transaction primitives
+│ │ ├── Cargo.toml
+│ │ └── src/
+│ │ ├── lib.rs # EvTxEnvelope, EvTxType
+│ │ ├── tx.rs # EvNodeTransaction (type 0x76)
+│ │ └── pool.rs # Pool transaction types
+│ ├── ev-revm/ # Custom EVM handlers
+│ │ ├── Cargo.toml
+│ │ └── src/
+│ │ ├── handler.rs # Batch calls, sponsorship, deploy allowlist
+│ │ ├── tx_env.rs # EvTxEnv with sponsor/batch metadata
+│ │ ├── evm.rs # Custom EVM configuration
+│ │ └── factory.rs # EVM factory with Evolve policies
+│ ├── node/ # Core node implementation
│ │ ├── Cargo.toml
│ │ └── src/
│ │ ├── lib.rs
-│ │ ├── builder.rs # Payload builder implementation
-│ │ └── config.rs # Configuration types
-│ ├── evolve/ # Evolve-specific types
+│ │ ├── builder.rs # Payload builder implementation
+│ │ ├── executor.rs # Block executor for EvTxEnvelope
+│ │ ├── evm_executor.rs # EVM executor and receipt builder
+│ │ ├── payload_types.rs # EvBuiltPayload and conversions
+│ │ ├── rpc.rs # RPC types with feePayer support
+│ │ ├── txpool.rs # EvNode txpool validator
+│ │ └── config.rs # Configuration types
+│ ├── evolve/ # Evolve-specific types
│ │ ├── Cargo.toml
│ │ └── src/
│ │ ├── lib.rs
-│ │ ├── config.rs # Evolve configuration
-│ │ ├── consensus.rs # Custom consensus implementation
-│ │ ├── types.rs # Evolve payload attributes
+│ │ ├── config.rs # Evolve configuration
+│ │ ├── consensus.rs # Custom consensus implementation
+│ │ ├── types.rs # Evolve payload attributes
│ │ └── rpc/
│ │ ├── mod.rs
-│ │ └── txpool.rs # Txpool RPC implementation
-│ └── tests/ # Comprehensive test suite
+│ │ └── txpool.rs # Txpool RPC implementation
+│ └── tests/ # Comprehensive test suite
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs
-│ └── *.rs # Test files
-├── etc/ # Configuration files
+│ └── *.rs # Test files
+├── etc/ # Configuration files
│ └── ev-reth-genesis.json # Genesis configuration
-├── Cargo.toml # Workspace configuration
-├── Makefile # Build automation
-└── README.md # This file
+├── Cargo.toml # Workspace configuration
+├── Makefile # Build automation
+└── README.md # This file
```
### Running Tests
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..c0f5f38
--- /dev/null
+++ b/crates/ev-primitives/src/pool.rs
@@ -0,0 +1,89 @@
+//! Pooled transaction envelope for ev-reth.
+//!
+//! This module defines [`EvPooledTxEnvelope`], the transaction type used by Reth's transaction
+//! pool. It wraps both standard Ethereum pooled transactions (which may include blob sidecars)
+//! and EvNode transactions.
+//!
+//! The traits implemented here are required by Reth's transaction pool infrastructure:
+//! - [`InMemorySize`]: Memory accounting for pool size limits
+//! - [`SignerRecoverable`]: Sender address recovery for validation
+//! - [`TxHashRef`]: Transaction hash access for deduplication
+//! - [`SignedTransaction`]: Marker trait for signed transaction types
+
+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..439d945
--- /dev/null
+++ b/crates/ev-primitives/src/tx.rs
@@ -0,0 +1,644 @@
+//! 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
+ }
+
+ /// Returns `None` because EvNode uses EIP-1559 fee market (not legacy gas price).
+ 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)
+ }
+
+ /// Returns `None` because EvNode does not support EIP-4844 blob transactions.
+ 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)
+ }
+
+ /// Returns `None` because EvNode does not support EIP-4844 blob transactions.
+ fn blob_versioned_hashes(&self) -> Option<&[B256]> {
+ None
+ }
+
+ /// Returns `None` because EvNode does not support EIP-7702 account abstraction.
+ 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)
+ }
+
+ /// Decodes `EvNodeTransaction` from compact format.
+ ///
+ /// # Panics
+ /// Panics if the RLP data is invalid. This is intentional - if data from the database
+ /// is corrupted, the node should not continue operating with invalid state.
+ 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).unwrap_or_else(|err| {
+ panic!("failed to decode EvNodeTransaction from database: {err}")
+ });
+ (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
+ }
+ }
+ }
+
+ /// Decodes `EvTxType` from compact format.
+ ///
+ /// # Panics
+ /// Panics if an unknown transaction type identifier is encountered. This indicates
+ /// database corruption or a version mismatch - the node should not continue.
+ 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!(
+ "failed to decode EvTxType from database: unknown identifier {extended_identifier:#x}"
+ ),
+ }
+ }
+ 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