diff --git a/Cargo.lock b/Cargo.lock
index c21d46d6..41ab737b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1624,6 +1624,7 @@ dependencies = [
"reth-testing-utils",
"reth-tracing",
"reth-transaction-pool",
+ "serde",
"serde_json",
"tips-core",
"tokio",
diff --git a/crates/metering/Cargo.toml b/crates/metering/Cargo.toml
index aeac8afa..52532f5f 100644
--- a/crates/metering/Cargo.toml
+++ b/crates/metering/Cargo.toml
@@ -40,6 +40,7 @@ jsonrpsee.workspace = true
# misc
tracing.workspace = true
eyre.workspace = true
+serde.workspace = true
[dev-dependencies]
alloy-genesis.workspace = true
diff --git a/crates/metering/src/block.rs b/crates/metering/src/block.rs
new file mode 100644
index 00000000..550ea88e
--- /dev/null
+++ b/crates/metering/src/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 crate::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/metering/src/meter.rs b/crates/metering/src/bundle.rs
similarity index 100%
rename from crates/metering/src/meter.rs
rename to crates/metering/src/bundle.rs
diff --git a/crates/metering/src/lib.rs b/crates/metering/src/lib.rs
index 138a3c7d..ce097de6 100644
--- a/crates/metering/src/lib.rs
+++ b/crates/metering/src/lib.rs
@@ -1,8 +1,12 @@
-mod meter;
+mod block;
+mod bundle;
mod rpc;
#[cfg(test)]
mod tests;
+mod types;
-pub use meter::meter_bundle;
+pub use block::meter_block;
+pub use bundle::meter_bundle;
pub use rpc::{MeteringApiImpl, MeteringApiServer};
pub use tips_core::types::{Bundle, MeterBundleResponse, TransactionResult};
+pub use types::{MeterBlockResponse, MeterBlockTransactions};
diff --git a/crates/metering/src/rpc.rs b/crates/metering/src/rpc.rs
index 19cd2eb4..d9665fb3 100644
--- a/crates/metering/src/rpc.rs
+++ b/crates/metering/src/rpc.rs
@@ -1,17 +1,18 @@
use alloy_consensus::Header;
use alloy_eips::BlockNumberOrTag;
-use alloy_primitives::U256;
+use alloy_primitives::{B256, U256};
use jsonrpsee::{
core::{RpcResult, async_trait},
proc_macros::rpc,
};
use reth::providers::BlockReaderIdExt;
use reth_optimism_chainspec::OpChainSpec;
-use reth_provider::{ChainSpecProvider, StateProviderFactory};
+use reth_optimism_primitives::OpBlock;
+use reth_provider::{BlockReader, ChainSpecProvider, StateProviderFactory};
use tips_core::types::{Bundle, MeterBundleResponse, ParsedBundle};
use tracing::{error, info};
-use crate::meter_bundle;
+use crate::{block::meter_block, bundle::meter_bundle, types::MeterBlockResponse};
/// RPC API for transaction metering
#[rpc(server, namespace = "base")]
@@ -19,6 +20,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;
}
/// Implementation of the metering RPC API
@@ -31,6 +61,7 @@ where
Provider: StateProviderFactory
+ ChainSpecProvider
+ BlockReaderIdExt
+ + BlockReader
+ Clone,
{
/// Creates a new instance of MeteringApi
@@ -45,6 +76,7 @@ where
Provider: StateProviderFactory
+ ChainSpecProvider
+ BlockReaderIdExt
+ + BlockReader
+ Clone
+ Send
+ Sync
@@ -139,4 +171,104 @@ 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,
+ 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,
+ 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
+ + 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().clone(), 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/metering/src/tests/block.rs b/crates/metering/src/tests/block.rs
new file mode 100644
index 00000000..a9a623d1
--- /dev/null
+++ b/crates/metering/src/tests/block.rs
@@ -0,0 +1,298 @@
+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 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;
+
+use super::utils::create_provider_factory;
+use crate::meter_block;
+
+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(())
+}
diff --git a/crates/metering/src/tests/meter.rs b/crates/metering/src/tests/bundle.rs
similarity index 100%
rename from crates/metering/src/tests/meter.rs
rename to crates/metering/src/tests/bundle.rs
diff --git a/crates/metering/src/tests/mod.rs b/crates/metering/src/tests/mod.rs
index 80d28813..6de9c490 100644
--- a/crates/metering/src/tests/mod.rs
+++ b/crates/metering/src/tests/mod.rs
@@ -1,5 +1,7 @@
#[cfg(test)]
-mod meter;
+mod block;
+#[cfg(test)]
+mod bundle;
#[cfg(test)]
mod rpc;
#[cfg(test)]
diff --git a/crates/metering/src/tests/rpc.rs b/crates/metering/src/tests/rpc.rs
index b77f9534..877571ba 100644
--- a/crates/metering/src/tests/rpc.rs
+++ b/crates/metering/src/tests/rpc.rs
@@ -2,11 +2,11 @@
mod tests {
use std::{any::Any, net::SocketAddr, sync::Arc};
- use alloy_eips::Encodable2718;
+ use alloy_eips::{BlockNumberOrTag, Encodable2718};
use alloy_genesis::Genesis;
- use alloy_primitives::{Bytes, U256, address, b256, bytes};
+ use alloy_primitives::{B256, Bytes, U256, address, b256, bytes};
use alloy_rpc_client::RpcClient;
- use base_reth_test_utils::tracing::init_silenced_tracing;
+ use base_reth_test_utils::{harness::TestHarness, tracing::init_silenced_tracing};
use op_alloy_consensus::OpTxEnvelope;
use reth::{
args::{DiscoveryArgs, NetworkArgs, RpcServerArgs},
@@ -24,6 +24,7 @@ mod tests {
use tips_core::types::Bundle;
use crate::rpc::{MeteringApiImpl, MeteringApiServer};
+ use crate::types::MeterBlockResponse;
pub struct NodeContext {
http_api_addr: SocketAddr,
@@ -106,6 +107,21 @@ mod tests {
})
}
+ /// Sets up a TestHarness with the metering RPC module enabled
+ async fn setup_harness_with_metering() -> eyre::Result {
+ TestHarness::with_launcher(|builder| async move {
+ builder
+ .extend_rpc_modules(|ctx| {
+ let metering_api = MeteringApiImpl::new(ctx.provider().clone());
+ ctx.modules.merge_configured(metering_api.into_rpc())?;
+ Ok(())
+ })
+ .launch()
+ .await
+ })
+ .await
+ }
+
#[tokio::test]
async fn test_meter_bundle_empty() -> eyre::Result<()> {
let node = setup_node().await?;
@@ -423,4 +439,125 @@ mod tests {
Ok(())
}
+
+ // ======================= Block Metering RPC Tests =======================
+
+ #[tokio::test]
+ async fn test_meter_block_by_number() -> eyre::Result<()> {
+ let harness = setup_harness_with_metering().await?;
+
+ // Build a block (block 1) so we have a non-genesis block to meter
+ harness.advance_chain(1).await?;
+
+ let client = RpcClient::new_http(harness.rpc_url().parse()?);
+
+ // Meter block 1
+ let response: MeterBlockResponse = client
+ .request("base_meterBlockByNumber", (BlockNumberOrTag::Number(1),))
+ .await?;
+
+ assert_eq!(response.block_number, 1);
+ // Block 1 contains the L1 block info deposit transaction
+ assert!(!response.transactions.is_empty());
+ assert!(response.signer_recovery_time_us > 0, "signer recovery time should be non-zero");
+ assert!(response.execution_time_us > 0, "execution time should be non-zero");
+ 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(())
+ }
+
+ #[tokio::test]
+ async fn test_meter_block_by_number_latest() -> eyre::Result<()> {
+ let harness = setup_harness_with_metering().await?;
+
+ // Build a few blocks
+ harness.advance_chain(3).await?;
+
+ let client = RpcClient::new_http(harness.rpc_url().parse()?);
+
+ // Meter the latest block
+ let response: MeterBlockResponse = client
+ .request("base_meterBlockByNumber", (BlockNumberOrTag::Latest,))
+ .await?;
+
+ // Latest block should be block 3
+ assert_eq!(response.block_number, 3);
+ assert!(response.execution_time_us > 0);
+ assert!(response.state_root_time_us > 0);
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_meter_block_by_hash() -> eyre::Result<()> {
+ let harness = setup_harness_with_metering().await?;
+
+ // Build a block
+ harness.advance_chain(1).await?;
+
+ // Get block hash from the latest block (which is block 1)
+ let block = harness.latest_block();
+ let block_hash = block.hash();
+
+ let client = RpcClient::new_http(harness.rpc_url().parse()?);
+
+ // Meter by hash
+ let response: MeterBlockResponse =
+ client.request("base_meterBlockByHash", (block_hash,)).await?;
+
+ assert_eq!(response.block_hash, block_hash);
+ assert_eq!(response.block_number, 1);
+ assert!(response.execution_time_us > 0);
+ assert!(response.state_root_time_us > 0);
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_meter_block_by_hash_not_found() -> eyre::Result<()> {
+ let harness = setup_harness_with_metering().await?;
+ let client = RpcClient::new_http(harness.rpc_url().parse()?);
+
+ // Try to meter a non-existent block hash
+ let fake_hash = B256::random();
+
+ let result: Result =
+ client.request("base_meterBlockByHash", (fake_hash,)).await;
+
+ assert!(result.is_err(), "should return error for non-existent block");
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_meter_block_by_number_not_found() -> eyre::Result<()> {
+ let harness = setup_harness_with_metering().await?;
+ let client = RpcClient::new_http(harness.rpc_url().parse()?);
+
+ // Try to meter a block number that doesn't exist
+ let result: Result =
+ client.request("base_meterBlockByNumber", (BlockNumberOrTag::Number(999999),)).await;
+
+ assert!(result.is_err(), "should return error for non-existent block number");
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_meter_block_genesis_fails() -> eyre::Result<()> {
+ let harness = setup_harness_with_metering().await?;
+ let client = RpcClient::new_http(harness.rpc_url().parse()?);
+
+ // Genesis block (block 0) has no parent, so metering should fail
+ let result: Result =
+ client.request("base_meterBlockByNumber", (BlockNumberOrTag::Number(0),)).await;
+
+ assert!(result.is_err(), "genesis block should fail because it has no parent");
+
+ Ok(())
+ }
}
diff --git a/crates/metering/src/types.rs b/crates/metering/src/types.rs
new file mode 100644
index 00000000..edb91a96
--- /dev/null
+++ b/crates/metering/src/types.rs
@@ -0,0 +1,40 @@
+// TODO: Move these types to tips-core alongside MeterBundleResponse
+
+use alloy_primitives::B256;
+use serde::{Deserialize, Serialize};
+
+/// 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,
+}