diff --git a/crates/rpc/Cargo.toml b/crates/rpc/Cargo.toml
index 390fdf1a..a44ac84c 100644
--- a/crates/rpc/Cargo.toml
+++ b/crates/rpc/Cargo.toml
@@ -24,6 +24,7 @@ reth-primitives-traits.workspace = true
reth-evm.workspace = true
reth-optimism-evm.workspace = true
reth-optimism-chainspec.workspace = true
+reth-optimism-primitives.workspace = true
reth-transaction-pool = { workspace = true, features = ["test-utils"] }
reth-rpc.workspace = true
reth-rpc-eth-api.workspace = true
diff --git a/crates/rpc/src/base/block.rs b/crates/rpc/src/base/block.rs
new file mode 100644
index 00000000..c1d04051
--- /dev/null
+++ b/crates/rpc/src/base/block.rs
@@ -0,0 +1,134 @@
+use std::{sync::Arc, time::Instant};
+
+use alloy_consensus::{BlockHeader, Header, transaction::SignerRecoverable};
+use alloy_primitives::B256;
+use eyre::{Result as EyreResult, eyre};
+use reth::revm::db::State;
+use reth_evm::{ConfigureEvm, execute::BlockBuilder};
+use reth_optimism_chainspec::OpChainSpec;
+use reth_optimism_evm::{OpEvmConfig, OpNextBlockEnvAttributes};
+use reth_optimism_primitives::OpBlock;
+use reth_primitives_traits::Block as BlockT;
+use reth_provider::{HeaderProvider, StateProviderFactory};
+
+use super::types::{MeterBlockResponse, MeterBlockTransactions};
+
+/// Re-executes a block and meters execution time, state root calculation time, and total time.
+///
+/// Takes a provider, the chain spec, and the block to meter.
+///
+/// Returns `MeterBlockResponse` containing:
+/// - Block hash
+/// - Signer recovery time (can be parallelized)
+/// - EVM execution time for all transactions
+/// - State root calculation time
+/// - Total time
+/// - Per-transaction timing information
+///
+/// # Note
+///
+/// If the parent block's state has been pruned, this function will return an error.
+///
+/// State root calculation timing is most accurate for recent blocks where state tries are
+/// cached. For older blocks, trie nodes may not be cached, which can significantly inflate
+/// the `state_root_time_us` value.
+pub fn meter_block
(
+ provider: P,
+ chain_spec: Arc,
+ block: &OpBlock,
+) -> EyreResult
+where
+ P: StateProviderFactory + HeaderProvider,
+{
+ let block_hash = block.header().hash_slow();
+ let block_number = block.header().number();
+ let transactions: Vec<_> = block.body().transactions().cloned().collect();
+ let tx_count = transactions.len();
+
+ // Get parent header
+ let parent_hash = block.header().parent_hash();
+ let parent_header = provider
+ .sealed_header_by_hash(parent_hash)?
+ .ok_or_else(|| eyre!("Parent header not found: {}", parent_hash))?;
+
+ // Get state provider at parent block
+ let state_provider = provider.state_by_block_hash(parent_hash)?;
+
+ // Create state database from parent state
+ let state_db = reth::revm::database::StateProviderDatabase::new(&state_provider);
+ let mut db = State::builder().with_database(state_db).with_bundle_update().build();
+
+ // Set up block attributes from the actual block header
+ let attributes = OpNextBlockEnvAttributes {
+ timestamp: block.header().timestamp(),
+ suggested_fee_recipient: block.header().beneficiary(),
+ prev_randao: block.header().mix_hash().unwrap_or(B256::random()),
+ gas_limit: block.header().gas_limit(),
+ parent_beacon_block_root: block.header().parent_beacon_block_root(),
+ extra_data: block.header().extra_data().clone(),
+ };
+
+ // Recover signers first (this can be parallelized in production)
+ let signer_recovery_start = Instant::now();
+ let recovered_transactions: Vec<_> = transactions
+ .iter()
+ .map(|tx| {
+ let tx_hash = tx.tx_hash();
+ let signer = tx
+ .recover_signer()
+ .map_err(|e| eyre!("Failed to recover signer for tx {}: {}", tx_hash, e))?;
+ Ok(alloy_consensus::transaction::Recovered::new_unchecked(tx.clone(), signer))
+ })
+ .collect::>>()?;
+ let signer_recovery_time = signer_recovery_start.elapsed().as_micros();
+
+ // Execute transactions and measure time
+ let mut transaction_times = Vec::with_capacity(tx_count);
+
+ let evm_start = Instant::now();
+ {
+ let evm_config = OpEvmConfig::optimism(chain_spec);
+ let mut builder = evm_config.builder_for_next_block(&mut db, &parent_header, attributes)?;
+
+ builder.apply_pre_execution_changes()?;
+
+ for recovered_tx in recovered_transactions {
+ let tx_start = Instant::now();
+ let tx_hash = recovered_tx.tx_hash();
+
+ let gas_used = builder
+ .execute_transaction(recovered_tx)
+ .map_err(|e| eyre!("Transaction {} execution failed: {}", tx_hash, e))?;
+
+ let execution_time = tx_start.elapsed().as_micros();
+
+ transaction_times.push(MeterBlockTransactions {
+ tx_hash,
+ gas_used,
+ execution_time_us: execution_time,
+ });
+ }
+ }
+ let execution_time = evm_start.elapsed().as_micros();
+
+ // Calculate state root and measure time
+ let state_root_start = Instant::now();
+ let bundle_state = db.bundle_state.clone();
+ let hashed_state = state_provider.hashed_post_state(&bundle_state);
+ let _state_root = state_provider
+ .state_root(hashed_state)
+ .map_err(|e| eyre!("Failed to calculate state root: {}", e))?;
+ let state_root_time = state_root_start.elapsed().as_micros();
+
+ let total_time = signer_recovery_time + execution_time + state_root_time;
+
+ Ok(MeterBlockResponse {
+ block_hash,
+ block_number,
+ signer_recovery_time_us: signer_recovery_time,
+ execution_time_us: execution_time,
+ state_root_time_us: state_root_time,
+ total_time_us: total_time,
+ transactions: transaction_times,
+ })
+}
diff --git a/crates/rpc/src/base/meter_rpc.rs b/crates/rpc/src/base/meter_rpc.rs
index 9a99aa32..631d287f 100644
--- a/crates/rpc/src/base/meter_rpc.rs
+++ b/crates/rpc/src/base/meter_rpc.rs
@@ -1,14 +1,17 @@
use alloy_consensus::Header;
use alloy_eips::BlockNumberOrTag;
-use alloy_primitives::U256;
+use alloy_primitives::{B256, U256};
use jsonrpsee::core::{RpcResult, async_trait};
use reth::providers::BlockReaderIdExt;
use reth_optimism_chainspec::OpChainSpec;
-use reth_provider::{ChainSpecProvider, StateProviderFactory};
+use reth_optimism_primitives::OpBlock;
+use reth_provider::{BlockReader, ChainSpecProvider, HeaderProvider, StateProviderFactory};
use tips_core::types::{Bundle, MeterBundleResponse, ParsedBundle};
use tracing::{error, info};
-use crate::{MeteringApiServer, meter_bundle};
+use super::{
+ block::meter_block, meter::meter_bundle, traits::MeteringApiServer, types::MeterBlockResponse,
+};
/// Implementation of the metering RPC API
#[derive(Debug)]
@@ -21,6 +24,8 @@ where
Provider: StateProviderFactory
+ ChainSpecProvider
+ BlockReaderIdExt
+ + BlockReader
+ + HeaderProvider
+ Clone,
{
/// Creates a new instance of MeteringApi
@@ -35,6 +40,8 @@ where
Provider: StateProviderFactory
+ ChainSpecProvider
+ BlockReaderIdExt
+ + BlockReader
+ + HeaderProvider
+ Clone
+ Send
+ Sync
@@ -124,4 +131,105 @@ where
total_execution_time_us: total_execution_time,
})
}
+
+ async fn meter_block_by_hash(&self, hash: B256) -> RpcResult {
+ info!(block_hash = %hash, "Starting block metering by hash");
+
+ let block = self
+ .provider
+ .block_by_hash(hash)
+ .map_err(|e| {
+ error!(error = %e, "Failed to get block by hash");
+ jsonrpsee::types::ErrorObjectOwned::owned(
+ jsonrpsee::types::ErrorCode::InternalError.code(),
+ format!("Failed to get block: {}", e),
+ None::<()>,
+ )
+ })?
+ .ok_or_else(|| {
+ jsonrpsee::types::ErrorObjectOwned::owned(
+ jsonrpsee::types::ErrorCode::InvalidParams.code(),
+ format!("Block not found: {}", hash),
+ None::<()>,
+ )
+ })?;
+
+ let response = self.meter_block_internal(&block)?;
+
+ info!(
+ block_hash = %hash,
+ signer_recovery_time_us = response.signer_recovery_time_us,
+ execution_time_us = response.execution_time_us,
+ state_root_time_us = response.state_root_time_us,
+ total_time_us = response.total_time_us,
+ "Block metering completed successfully"
+ );
+
+ Ok(response)
+ }
+
+ async fn meter_block_by_number(
+ &self,
+ number: BlockNumberOrTag,
+ ) -> RpcResult {
+ info!(block_number = ?number, "Starting block metering by number");
+
+ let block = self
+ .provider
+ .block_by_number_or_tag(number)
+ .map_err(|e| {
+ error!(error = %e, "Failed to get block by number");
+ jsonrpsee::types::ErrorObjectOwned::owned(
+ jsonrpsee::types::ErrorCode::InternalError.code(),
+ format!("Failed to get block: {}", e),
+ None::<()>,
+ )
+ })?
+ .ok_or_else(|| {
+ jsonrpsee::types::ErrorObjectOwned::owned(
+ jsonrpsee::types::ErrorCode::InvalidParams.code(),
+ format!("Block not found: {:?}", number),
+ None::<()>,
+ )
+ })?;
+
+ let response = self.meter_block_internal(&block)?;
+
+ info!(
+ block_number = ?number,
+ block_hash = %response.block_hash,
+ signer_recovery_time_us = response.signer_recovery_time_us,
+ execution_time_us = response.execution_time_us,
+ state_root_time_us = response.state_root_time_us,
+ total_time_us = response.total_time_us,
+ "Block metering completed successfully"
+ );
+
+ Ok(response)
+ }
+}
+
+impl MeteringApiImpl
+where
+ Provider: StateProviderFactory
+ + ChainSpecProvider
+ + BlockReaderIdExt
+ + BlockReader
+ + HeaderProvider
+ + Clone
+ + Send
+ + Sync
+ + 'static,
+{
+ /// Internal helper to meter a block's execution
+ fn meter_block_internal(&self, block: &OpBlock) -> RpcResult {
+ meter_block(self.provider.clone(), self.provider.chain_spec(), block).map_err(|e| {
+ error!(error = %e, "Block metering failed");
+ jsonrpsee::types::ErrorObjectOwned::owned(
+ jsonrpsee::types::ErrorCode::InternalError.code(),
+ format!("Block metering failed: {}", e),
+ None::<()>,
+ )
+ })
+ }
}
diff --git a/crates/rpc/src/base/mod.rs b/crates/rpc/src/base/mod.rs
index b4097c40..05aea14f 100644
--- a/crates/rpc/src/base/mod.rs
+++ b/crates/rpc/src/base/mod.rs
@@ -1,3 +1,4 @@
+pub(crate) mod block;
pub(crate) mod meter;
pub(crate) mod meter_rpc;
pub(crate) mod pubsub;
diff --git a/crates/rpc/src/base/traits.rs b/crates/rpc/src/base/traits.rs
index 4f80de59..00ca4c1a 100644
--- a/crates/rpc/src/base/traits.rs
+++ b/crates/rpc/src/base/traits.rs
@@ -1,9 +1,10 @@
//! Traits for the RPC module.
-use alloy_primitives::TxHash;
+use alloy_eips::BlockNumberOrTag;
+use alloy_primitives::{B256, TxHash};
use jsonrpsee::{core::RpcResult, proc_macros::rpc};
-use crate::{Bundle, MeterBundleResponse, TransactionStatusResponse};
+use crate::{Bundle, MeterBlockResponse, MeterBundleResponse, TransactionStatusResponse};
/// RPC API for transaction metering
#[rpc(server, namespace = "base")]
@@ -11,6 +12,35 @@ pub trait MeteringApi {
/// Simulates and meters a bundle of transactions
#[method(name = "meterBundle")]
async fn meter_bundle(&self, bundle: Bundle) -> RpcResult;
+
+ /// Handler for: `base_meterBlockByHash`
+ ///
+ /// Re-executes a block and returns timing metrics for EVM execution and state root calculation.
+ ///
+ /// This method fetches the block by hash, re-executes all transactions against the parent
+ /// block's state, and measures:
+ /// - `executionTimeUs`: Time to execute all transactions in the EVM
+ /// - `stateRootTimeUs`: Time to compute the state root after execution
+ /// - `totalTimeUs`: Sum of execution and state root calculation time
+ /// - `meteredTransactions`: Per-transaction execution times and gas usage
+ #[method(name = "meterBlockByHash")]
+ async fn meter_block_by_hash(&self, hash: B256) -> RpcResult;
+
+ /// Handler for: `base_meterBlockByNumber`
+ ///
+ /// Re-executes a block and returns timing metrics for EVM execution and state root calculation.
+ ///
+ /// This method fetches the block by number, re-executes all transactions against the parent
+ /// block's state, and measures:
+ /// - `executionTimeUs`: Time to execute all transactions in the EVM
+ /// - `stateRootTimeUs`: Time to compute the state root after execution
+ /// - `totalTimeUs`: Sum of execution and state root calculation time
+ /// - `meteredTransactions`: Per-transaction execution times and gas usage
+ #[method(name = "meterBlockByNumber")]
+ async fn meter_block_by_number(
+ &self,
+ number: BlockNumberOrTag,
+ ) -> RpcResult;
}
/// RPC API for transaction status
diff --git a/crates/rpc/src/base/types.rs b/crates/rpc/src/base/types.rs
index 3340a80e..13b443a6 100644
--- a/crates/rpc/src/base/types.rs
+++ b/crates/rpc/src/base/types.rs
@@ -1,5 +1,6 @@
//! Types for the transaction status rpc
+use alloy_primitives::B256;
use alloy_rpc_types_eth::pubsub::SubscriptionKind;
use serde::{Deserialize, Serialize};
@@ -95,3 +96,41 @@ impl From for ExtendedSubscriptionKind {
Self::Base(kind)
}
}
+
+// Block metering types
+
+/// Response for block metering RPC calls.
+/// Contains the block hash plus timing information for EVM execution and state root calculation.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct MeterBlockResponse {
+ /// The block hash that was metered
+ pub block_hash: B256,
+ /// The block number that was metered
+ pub block_number: u64,
+ /// Duration of signer recovery in microseconds (can be parallelized)
+ pub signer_recovery_time_us: u128,
+ /// Duration of EVM execution in microseconds
+ pub execution_time_us: u128,
+ /// Duration of state root calculation in microseconds.
+ ///
+ /// Note: This timing is most accurate for recent blocks where state tries are cached.
+ /// For older blocks, trie nodes may not be cached, which can significantly inflate this value.
+ pub state_root_time_us: u128,
+ /// Total duration (signer recovery + EVM execution + state root calculation) in microseconds
+ pub total_time_us: u128,
+ /// Per-transaction metering data
+ pub transactions: Vec,
+}
+
+/// Metering data for a single transaction
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct MeterBlockTransactions {
+ /// Transaction hash
+ pub tx_hash: B256,
+ /// Gas used by this transaction
+ pub gas_used: u64,
+ /// Execution time in microseconds
+ pub execution_time_us: u128,
+}
diff --git a/crates/rpc/src/lib.rs b/crates/rpc/src/lib.rs
index 5a5db0a0..6f839e5d 100644
--- a/crates/rpc/src/lib.rs
+++ b/crates/rpc/src/lib.rs
@@ -8,12 +8,16 @@ pub use tips_core::types::{Bundle, MeterBundleResponse, TransactionResult};
mod base;
pub use base::{
+ block::meter_block,
meter::meter_bundle,
meter_rpc::MeteringApiImpl,
pubsub::{EthPubSub, EthPubSubApiServer},
traits::{MeteringApiServer, TransactionStatusApiServer},
transaction_rpc::TransactionStatusApiImpl,
- types::{BaseSubscriptionKind, ExtendedSubscriptionKind, Status, TransactionStatusResponse},
+ types::{
+ BaseSubscriptionKind, ExtendedSubscriptionKind, MeterBlockResponse, MeterBlockTransactions,
+ Status, TransactionStatusResponse,
+ },
};
mod eth;
diff --git a/crates/rpc/tests/meter_block.rs b/crates/rpc/tests/meter_block.rs
new file mode 100644
index 00000000..fc3b54b2
--- /dev/null
+++ b/crates/rpc/tests/meter_block.rs
@@ -0,0 +1,380 @@
+//! Integration tests for block metering functionality.
+
+use std::sync::Arc;
+
+use alloy_consensus::{BlockHeader, Header, crypto::secp256k1::public_key_to_address};
+use alloy_genesis::GenesisAccount;
+use alloy_primitives::{Address, B256, U256};
+use base_reth_rpc::meter_block;
+use base_reth_test_utils::create_provider_factory;
+use eyre::Context;
+use rand::{SeedableRng, rngs::StdRng};
+use reth::{api::NodeTypesWithDBAdapter, chainspec::EthChainSpec};
+use reth_db::{DatabaseEnv, test_utils::TempDatabase};
+use reth_optimism_chainspec::{BASE_MAINNET, OpChainSpec, OpChainSpecBuilder};
+use reth_optimism_node::OpNode;
+use reth_optimism_primitives::{OpBlock, OpBlockBody, OpTransactionSigned};
+use reth_primitives_traits::Block as BlockT;
+use reth_provider::{HeaderProvider, providers::BlockchainProvider};
+use reth_testing_utils::generators::generate_keys;
+use reth_transaction_pool::test_utils::TransactionBuilder;
+
+type NodeTypes = NodeTypesWithDBAdapter>>;
+
+#[derive(Eq, PartialEq, Debug, Hash, Clone, Copy)]
+enum User {
+ Alice,
+ Bob,
+}
+
+#[derive(Debug, Clone)]
+struct TestHarness {
+ provider: BlockchainProvider,
+ genesis_header_hash: B256,
+ genesis_header_number: u64,
+ genesis_header_timestamp: u64,
+ chain_spec: Arc,
+ user_to_private_key: std::collections::HashMap,
+}
+
+impl TestHarness {
+ fn signer(&self, u: User) -> B256 {
+ self.user_to_private_key[&u]
+ }
+}
+
+fn create_chain_spec(seed: u64) -> (Arc, std::collections::HashMap) {
+ let keys = generate_keys(&mut StdRng::seed_from_u64(seed), 2);
+
+ let mut private_keys = std::collections::HashMap::new();
+
+ let alice_key = keys[0];
+ let alice_address = public_key_to_address(alice_key.public_key());
+ let alice_secret = B256::from(alice_key.secret_bytes());
+ private_keys.insert(User::Alice, alice_secret);
+
+ let bob_key = keys[1];
+ let bob_address = public_key_to_address(bob_key.public_key());
+ let bob_secret = B256::from(bob_key.secret_bytes());
+ private_keys.insert(User::Bob, bob_secret);
+
+ let genesis = BASE_MAINNET
+ .genesis
+ .clone()
+ .extend_accounts(vec![
+ (alice_address, GenesisAccount::default().with_balance(U256::from(1_000_000_000_u64))),
+ (bob_address, GenesisAccount::default().with_balance(U256::from(1_000_000_000_u64))),
+ ])
+ .with_gas_limit(100_000_000);
+
+ let spec =
+ Arc::new(OpChainSpecBuilder::base_mainnet().genesis(genesis).isthmus_activated().build());
+
+ (spec, private_keys)
+}
+
+fn setup_harness() -> eyre::Result {
+ let (chain_spec, user_to_private_key) = create_chain_spec(1337);
+ let factory = create_provider_factory::(chain_spec.clone());
+
+ reth_db_common::init::init_genesis(&factory).context("initializing genesis state")?;
+
+ let provider = BlockchainProvider::new(factory.clone()).context("creating provider")?;
+ let header = provider
+ .sealed_header(0)
+ .context("fetching genesis header")?
+ .expect("genesis header exists");
+
+ Ok(TestHarness {
+ provider,
+ genesis_header_hash: header.hash(),
+ genesis_header_number: header.number(),
+ genesis_header_timestamp: header.timestamp(),
+ chain_spec,
+ user_to_private_key,
+ })
+}
+
+fn create_block_with_transactions(
+ harness: &TestHarness,
+ transactions: Vec,
+) -> OpBlock {
+ let header = Header {
+ parent_hash: harness.genesis_header_hash,
+ number: harness.genesis_header_number + 1,
+ timestamp: harness.genesis_header_timestamp + 2,
+ gas_limit: 30_000_000,
+ beneficiary: Address::random(),
+ base_fee_per_gas: Some(1),
+ // Required for post-Cancun blocks (EIP-4788)
+ parent_beacon_block_root: Some(B256::ZERO),
+ ..Default::default()
+ };
+
+ let body = OpBlockBody { transactions, ommers: vec![], withdrawals: None };
+
+ OpBlock::new(header, body)
+}
+
+#[test]
+fn meter_block_empty_transactions() -> eyre::Result<()> {
+ let harness = setup_harness()?;
+
+ let block = create_block_with_transactions(&harness, vec![]);
+
+ let response = meter_block(harness.provider.clone(), harness.chain_spec.clone(), &block)?;
+
+ assert_eq!(response.block_hash, block.header().hash_slow());
+ assert_eq!(response.block_number, block.header().number());
+ assert!(response.transactions.is_empty());
+ // No transactions means minimal signer recovery time (just timing overhead)
+ assert!(response.execution_time_us > 0, "execution time should be non-zero due to EVM setup");
+ assert!(response.state_root_time_us > 0, "state root time should be non-zero");
+ assert_eq!(
+ response.total_time_us,
+ response.signer_recovery_time_us + response.execution_time_us + response.state_root_time_us
+ );
+
+ Ok(())
+}
+
+#[test]
+fn meter_block_single_transaction() -> eyre::Result<()> {
+ let harness = setup_harness()?;
+
+ let to = Address::random();
+ let signed_tx = TransactionBuilder::default()
+ .signer(harness.signer(User::Alice))
+ .chain_id(harness.chain_spec.chain_id())
+ .nonce(0)
+ .to(to)
+ .value(1_000)
+ .gas_limit(21_000)
+ .max_fee_per_gas(10)
+ .max_priority_fee_per_gas(1)
+ .into_eip1559();
+
+ let tx =
+ OpTransactionSigned::Eip1559(signed_tx.as_eip1559().expect("eip1559 transaction").clone());
+ let tx_hash = tx.tx_hash();
+
+ let block = create_block_with_transactions(&harness, vec![tx]);
+
+ let response = meter_block(harness.provider.clone(), harness.chain_spec.clone(), &block)?;
+
+ assert_eq!(response.block_hash, block.header().hash_slow());
+ assert_eq!(response.block_number, block.header().number());
+ assert_eq!(response.transactions.len(), 1);
+
+ let metered_tx = &response.transactions[0];
+ assert_eq!(metered_tx.tx_hash, tx_hash);
+ assert_eq!(metered_tx.gas_used, 21_000);
+ assert!(metered_tx.execution_time_us > 0, "transaction execution time should be non-zero");
+
+ assert!(response.signer_recovery_time_us > 0, "signer recovery should take time");
+ assert!(response.execution_time_us > 0);
+ assert!(response.state_root_time_us > 0);
+ assert_eq!(
+ response.total_time_us,
+ response.signer_recovery_time_us + response.execution_time_us + response.state_root_time_us
+ );
+
+ Ok(())
+}
+
+#[test]
+fn meter_block_multiple_transactions() -> eyre::Result<()> {
+ let harness = setup_harness()?;
+
+ let to_1 = Address::random();
+ let to_2 = Address::random();
+
+ // Create first transaction from Alice
+ let signed_tx_1 = TransactionBuilder::default()
+ .signer(harness.signer(User::Alice))
+ .chain_id(harness.chain_spec.chain_id())
+ .nonce(0)
+ .to(to_1)
+ .value(1_000)
+ .gas_limit(21_000)
+ .max_fee_per_gas(10)
+ .max_priority_fee_per_gas(1)
+ .into_eip1559();
+
+ let tx_1 = OpTransactionSigned::Eip1559(
+ signed_tx_1.as_eip1559().expect("eip1559 transaction").clone(),
+ );
+ let tx_hash_1 = tx_1.tx_hash();
+
+ // Create second transaction from Bob
+ let signed_tx_2 = TransactionBuilder::default()
+ .signer(harness.signer(User::Bob))
+ .chain_id(harness.chain_spec.chain_id())
+ .nonce(0)
+ .to(to_2)
+ .value(2_000)
+ .gas_limit(21_000)
+ .max_fee_per_gas(15)
+ .max_priority_fee_per_gas(2)
+ .into_eip1559();
+
+ let tx_2 = OpTransactionSigned::Eip1559(
+ signed_tx_2.as_eip1559().expect("eip1559 transaction").clone(),
+ );
+ let tx_hash_2 = tx_2.tx_hash();
+
+ let block = create_block_with_transactions(&harness, vec![tx_1, tx_2]);
+
+ let response = meter_block(harness.provider.clone(), harness.chain_spec.clone(), &block)?;
+
+ assert_eq!(response.block_hash, block.header().hash_slow());
+ assert_eq!(response.block_number, block.header().number());
+ assert_eq!(response.transactions.len(), 2);
+
+ // Check first transaction
+ let metered_tx_1 = &response.transactions[0];
+ assert_eq!(metered_tx_1.tx_hash, tx_hash_1);
+ assert_eq!(metered_tx_1.gas_used, 21_000);
+ assert!(metered_tx_1.execution_time_us > 0);
+
+ // Check second transaction
+ let metered_tx_2 = &response.transactions[1];
+ assert_eq!(metered_tx_2.tx_hash, tx_hash_2);
+ assert_eq!(metered_tx_2.gas_used, 21_000);
+ assert!(metered_tx_2.execution_time_us > 0);
+
+ // Check aggregate times
+ assert!(response.signer_recovery_time_us > 0, "signer recovery should take time");
+ assert!(response.execution_time_us > 0);
+ assert!(response.state_root_time_us > 0);
+ assert_eq!(
+ response.total_time_us,
+ response.signer_recovery_time_us + response.execution_time_us + response.state_root_time_us
+ );
+
+ // Ensure individual transaction times are consistent with total
+ let individual_times: u128 = response.transactions.iter().map(|t| t.execution_time_us).sum();
+ assert!(
+ individual_times <= response.execution_time_us,
+ "sum of individual times should not exceed total (due to EVM overhead)"
+ );
+
+ Ok(())
+}
+
+#[test]
+fn meter_block_timing_consistency() -> eyre::Result<()> {
+ let harness = setup_harness()?;
+
+ // Create a block with one transaction
+ let signed_tx = TransactionBuilder::default()
+ .signer(harness.signer(User::Alice))
+ .chain_id(harness.chain_spec.chain_id())
+ .nonce(0)
+ .to(Address::random())
+ .value(1_000)
+ .gas_limit(21_000)
+ .max_fee_per_gas(10)
+ .max_priority_fee_per_gas(1)
+ .into_eip1559();
+
+ let tx =
+ OpTransactionSigned::Eip1559(signed_tx.as_eip1559().expect("eip1559 transaction").clone());
+
+ let block = create_block_with_transactions(&harness, vec![tx]);
+
+ let response = meter_block(harness.provider.clone(), harness.chain_spec.clone(), &block)?;
+
+ // Verify timing invariants
+ assert!(response.signer_recovery_time_us > 0, "signer recovery time must be positive");
+ assert!(response.execution_time_us > 0, "execution time must be positive");
+ assert!(response.state_root_time_us > 0, "state root time must be positive");
+ assert_eq!(
+ response.total_time_us,
+ response.signer_recovery_time_us + response.execution_time_us + response.state_root_time_us,
+ "total time must equal signer recovery + execution + state root times"
+ );
+
+ Ok(())
+}
+
+// ============================================================================
+// Error Path Tests
+// ============================================================================
+
+#[test]
+fn meter_block_parent_header_not_found() -> eyre::Result<()> {
+ let harness = setup_harness()?;
+
+ // Create a block that references a non-existent parent
+ let fake_parent_hash = B256::random();
+ let header = Header {
+ parent_hash: fake_parent_hash, // This parent doesn't exist
+ number: 999,
+ timestamp: harness.genesis_header_timestamp + 2,
+ gas_limit: 30_000_000,
+ beneficiary: Address::random(),
+ base_fee_per_gas: Some(1),
+ parent_beacon_block_root: Some(B256::ZERO),
+ ..Default::default()
+ };
+
+ let body = OpBlockBody { transactions: vec![], ommers: vec![], withdrawals: None };
+ let block = OpBlock::new(header, body);
+
+ let result = meter_block(harness.provider.clone(), harness.chain_spec.clone(), &block);
+
+ assert!(result.is_err(), "should fail when parent header is not found");
+ let err = result.unwrap_err();
+ let err_str = err.to_string();
+ assert!(
+ err_str.contains("Parent header not found") || err_str.contains("not found"),
+ "error should indicate parent header not found: {}",
+ err_str
+ );
+
+ Ok(())
+}
+
+#[test]
+fn meter_block_invalid_transaction_signature() -> eyre::Result<()> {
+ use alloy_consensus::TxEip1559;
+ use alloy_primitives::Signature;
+
+ let harness = setup_harness()?;
+
+ // Create a transaction with an invalid signature
+ let tx = TxEip1559 {
+ chain_id: harness.chain_spec.chain_id(),
+ nonce: 0,
+ gas_limit: 21_000,
+ max_fee_per_gas: 10,
+ max_priority_fee_per_gas: 1,
+ to: alloy_primitives::TxKind::Call(Address::random()),
+ value: alloy_primitives::U256::from(1000),
+ access_list: Default::default(),
+ input: Default::default(),
+ };
+
+ // Create a signature with invalid values (all zeros is invalid for secp256k1)
+ let invalid_signature =
+ Signature::new(alloy_primitives::U256::ZERO, alloy_primitives::U256::ZERO, false);
+
+ let signed_tx = alloy_consensus::Signed::new_unchecked(tx, invalid_signature, B256::random());
+ let op_tx = OpTransactionSigned::Eip1559(signed_tx);
+
+ let block = create_block_with_transactions(&harness, vec![op_tx]);
+
+ let result = meter_block(harness.provider.clone(), harness.chain_spec.clone(), &block);
+
+ assert!(result.is_err(), "should fail when transaction has invalid signature");
+ let err = result.unwrap_err();
+ let err_str = err.to_string();
+ assert!(
+ err_str.contains("recover signer") || err_str.contains("signature"),
+ "error should indicate signer recovery failure: {}",
+ err_str
+ );
+
+ Ok(())
+}