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, +}