From 64ae56704996816b89ca9587f38a60d0072a5baa Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Mon, 12 Jan 2026 11:28:50 -0600 Subject: [PATCH 1/3] refactor(metering): move tests inline with MeteringTestContext - Add MeteringTestContext for unit tests that don't need a full node - Move meter.rs tests inline using MeteringTestContext (sync tests) - Move block.rs tests inline using MeteringTestContext (sync tests) - Move rpc.rs tests inline using TestHarness with MeteringExtension - Delete tests/ directory (all tests now inline) - Update lib.rs to expose test_utils with #[cfg(any(test, feature = "test-utils"))] --- crates/client/metering/Cargo.toml | 13 + crates/client/metering/src/block.rs | 318 +++++++++++++++++++ crates/client/metering/src/lib.rs | 3 + crates/client/metering/src/meter.rs | 212 +++++++++++++ crates/client/metering/src/rpc.rs | 303 ++++++++++++++++++ crates/client/metering/src/test_utils.rs | 67 ++++ crates/client/metering/tests/meter.rs | 222 -------------- crates/client/metering/tests/meter_block.rs | 314 ------------------- crates/client/metering/tests/meter_rpc.rs | 323 -------------------- 9 files changed, 916 insertions(+), 859 deletions(-) create mode 100644 crates/client/metering/src/test_utils.rs delete mode 100644 crates/client/metering/tests/meter.rs delete mode 100644 crates/client/metering/tests/meter_block.rs delete mode 100644 crates/client/metering/tests/meter_rpc.rs diff --git a/crates/client/metering/Cargo.toml b/crates/client/metering/Cargo.toml index c45ee302..d9a010bd 100644 --- a/crates/client/metering/Cargo.toml +++ b/crates/client/metering/Cargo.toml @@ -11,6 +11,14 @@ description = "Metering RPC for Base node" [lints] workspace = true +[features] +test-utils = [ + "base-client-node/test-utils", + "dep:reth-db", + "dep:reth-db-common", + "dep:reth-optimism-node", +] + [dependencies] # workspace base-bundles.workspace = true @@ -38,6 +46,11 @@ tracing.workspace = true eyre.workspace = true serde.workspace = true +# test-utils (optional) +reth-db = { workspace = true, features = ["test-utils"], optional = true } +reth-db-common = { workspace = true, optional = true } +reth-optimism-node = { workspace = true, optional = true } + [dev-dependencies] base-client-node = { workspace = true, features = ["test-utils"] } reth-db = { workspace = true, features = ["test-utils"] } diff --git a/crates/client/metering/src/block.rs b/crates/client/metering/src/block.rs index 14347267..6c8deb27 100644 --- a/crates/client/metering/src/block.rs +++ b/crates/client/metering/src/block.rs @@ -134,3 +134,321 @@ where transactions: transaction_times, }) } + +#[cfg(test)] +mod tests { + use alloy_primitives::Address; + use base_client_node::test_utils::Account; + use reth::chainspec::EthChainSpec; + use reth_optimism_primitives::OpBlockBody; + use reth_transaction_pool::test_utils::TransactionBuilder; + + use super::*; + use crate::test_utils::MeteringTestContext; + + fn create_block_with_transactions( + ctx: &MeteringTestContext, + transactions: Vec, + ) -> OpBlock { + let header = Header { + parent_hash: ctx.header.hash(), + number: ctx.header.number() + 1, + timestamp: ctx.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 ctx = MeteringTestContext::new()?; + + let block = create_block_with_transactions(&ctx, vec![]); + + let response = meter_block(ctx.provider.clone(), ctx.chain_spec, &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<()> { + use reth_optimism_primitives::OpTransactionSigned; + + let ctx = MeteringTestContext::new()?; + + let to = Address::random(); + let signed_tx = TransactionBuilder::default() + .signer(Account::Alice.signer_b256()) + .chain_id(ctx.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(&ctx, vec![tx]); + + let response = meter_block(ctx.provider.clone(), ctx.chain_spec, &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<()> { + use reth_optimism_primitives::OpTransactionSigned; + + let ctx = MeteringTestContext::new()?; + + let to_1 = Address::random(); + let to_2 = Address::random(); + + // Create first transaction from Alice + let signed_tx_1 = TransactionBuilder::default() + .signer(Account::Alice.signer_b256()) + .chain_id(ctx.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(Account::Bob.signer_b256()) + .chain_id(ctx.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(&ctx, vec![tx_1, tx_2]); + + let response = meter_block(ctx.provider.clone(), ctx.chain_spec, &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<()> { + use reth_optimism_primitives::OpTransactionSigned; + + let ctx = MeteringTestContext::new()?; + + // Create a block with one transaction + let signed_tx = TransactionBuilder::default() + .signer(Account::Alice.signer_b256()) + .chain_id(ctx.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(&ctx, vec![tx]); + + let response = meter_block(ctx.provider.clone(), ctx.chain_spec, &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 ctx = MeteringTestContext::new()?; + + // 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: ctx.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(ctx.provider.clone(), ctx.chain_spec, &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; + use reth_optimism_primitives::OpTransactionSigned; + + let ctx = MeteringTestContext::new()?; + + // Create a transaction with an invalid signature + let tx = TxEip1559 { + chain_id: ctx.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(&ctx, vec![op_tx]); + + let result = meter_block(ctx.provider.clone(), ctx.chain_spec, &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(()) + } +} diff --git a/crates/client/metering/src/lib.rs b/crates/client/metering/src/lib.rs index 41883a8d..3a8407f2 100644 --- a/crates/client/metering/src/lib.rs +++ b/crates/client/metering/src/lib.rs @@ -20,3 +20,6 @@ pub use traits::MeteringApiServer; mod types; pub use types::{MeterBlockResponse, MeterBlockTransactions}; + +#[cfg(any(test, feature = "test-utils"))] +pub mod test_utils; diff --git a/crates/client/metering/src/meter.rs b/crates/client/metering/src/meter.rs index 85a1e3b3..3909ad15 100644 --- a/crates/client/metering/src/meter.rs +++ b/crates/client/metering/src/meter.rs @@ -101,3 +101,215 @@ where Ok((results, total_gas_used, total_gas_fees, bundle_hash, total_execution_time)) } + +#[cfg(test)] +mod tests { + use alloy_eips::Encodable2718; + use alloy_primitives::{Address, Bytes, keccak256}; + use base_bundles::{Bundle, ParsedBundle}; + use base_client_node::test_utils::Account; + use eyre::Context; + use op_alloy_consensus::OpTxEnvelope; + use reth::chainspec::EthChainSpec; + use reth_optimism_primitives::OpTransactionSigned; + use reth_provider::StateProviderFactory; + use reth_transaction_pool::test_utils::TransactionBuilder; + + use super::*; + use crate::test_utils::MeteringTestContext; + + fn envelope_from_signed(tx: &OpTransactionSigned) -> eyre::Result { + Ok(tx.clone()) + } + + fn create_parsed_bundle(envelopes: Vec) -> eyre::Result { + let txs: Vec = envelopes.iter().map(|env| Bytes::from(env.encoded_2718())).collect(); + + let bundle = Bundle { + txs, + block_number: 0, + flashblock_number_min: None, + flashblock_number_max: None, + min_timestamp: None, + max_timestamp: None, + reverting_tx_hashes: vec![], + replacement_uuid: None, + dropping_tx_hashes: vec![], + }; + + ParsedBundle::try_from(bundle).map_err(|e| eyre::eyre!(e)) + } + + #[test] + fn meter_bundle_empty_transactions() -> eyre::Result<()> { + let ctx = MeteringTestContext::new()?; + + let state_provider = ctx + .provider + .state_by_block_hash(ctx.header.hash()) + .context("getting state provider")?; + + let parsed_bundle = create_parsed_bundle(Vec::new())?; + + let (results, total_gas_used, total_gas_fees, bundle_hash, total_execution_time) = + meter_bundle(state_provider, ctx.chain_spec.clone(), parsed_bundle, &ctx.header)?; + + assert!(results.is_empty()); + assert_eq!(total_gas_used, 0); + assert_eq!(total_gas_fees, U256::ZERO); + // Even empty bundles have some EVM setup overhead + assert!(total_execution_time > 0); + assert_eq!(bundle_hash, keccak256([])); + + Ok(()) + } + + #[test] + fn meter_bundle_single_transaction() -> eyre::Result<()> { + let ctx = MeteringTestContext::new()?; + + let to = Address::random(); + let signed_tx = TransactionBuilder::default() + .signer(Account::Alice.signer_b256()) + .chain_id(ctx.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 envelope = envelope_from_signed(&tx)?; + let tx_hash = envelope.tx_hash(); + + let state_provider = ctx + .provider + .state_by_block_hash(ctx.header.hash()) + .context("getting state provider")?; + + let parsed_bundle = create_parsed_bundle(vec![envelope])?; + + let (results, total_gas_used, total_gas_fees, bundle_hash, total_execution_time) = + meter_bundle(state_provider, ctx.chain_spec.clone(), parsed_bundle, &ctx.header)?; + + assert_eq!(results.len(), 1); + let result = &results[0]; + assert!(total_execution_time > 0); + + assert_eq!(result.from_address, Account::Alice.address()); + assert_eq!(result.to_address, Some(to)); + assert_eq!(result.tx_hash, tx_hash); + assert_eq!(result.gas_price, U256::from(10)); + assert_eq!(result.gas_used, 21_000); + assert_eq!(result.coinbase_diff, (U256::from(21_000) * U256::from(10)),); + + assert_eq!(total_gas_used, 21_000); + assert_eq!(total_gas_fees, U256::from(21_000) * U256::from(10)); + + let mut concatenated = Vec::with_capacity(32); + concatenated.extend_from_slice(tx_hash.as_slice()); + assert_eq!(bundle_hash, keccak256(concatenated)); + + assert!(result.execution_time_us > 0, "execution_time_us should be greater than zero"); + + Ok(()) + } + + #[test] + fn meter_bundle_multiple_transactions() -> eyre::Result<()> { + let ctx = MeteringTestContext::new()?; + + let to_1 = Address::random(); + let to_2 = Address::random(); + + // Create first transaction + let signed_tx_1 = TransactionBuilder::default() + .signer(Account::Alice.signer_b256()) + .chain_id(ctx.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(), + ); + + // Create second transaction + let signed_tx_2 = TransactionBuilder::default() + .signer(Account::Bob.signer_b256()) + .chain_id(ctx.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 envelope_1 = envelope_from_signed(&tx_1)?; + let envelope_2 = envelope_from_signed(&tx_2)?; + let tx_hash_1 = envelope_1.tx_hash(); + let tx_hash_2 = envelope_2.tx_hash(); + + let state_provider = ctx + .provider + .state_by_block_hash(ctx.header.hash()) + .context("getting state provider")?; + + let parsed_bundle = create_parsed_bundle(vec![envelope_1, envelope_2])?; + + let (results, total_gas_used, total_gas_fees, bundle_hash, total_execution_time) = + meter_bundle(state_provider, ctx.chain_spec.clone(), parsed_bundle, &ctx.header)?; + + assert_eq!(results.len(), 2); + assert!(total_execution_time > 0); + + // Check first transaction + let result_1 = &results[0]; + assert_eq!(result_1.from_address, Account::Alice.address()); + assert_eq!(result_1.to_address, Some(to_1)); + assert_eq!(result_1.tx_hash, tx_hash_1); + assert_eq!(result_1.gas_price, U256::from(10)); + assert_eq!(result_1.gas_used, 21_000); + assert_eq!(result_1.coinbase_diff, (U256::from(21_000) * U256::from(10)),); + + // Check second transaction + let result_2 = &results[1]; + assert_eq!(result_2.from_address, Account::Bob.address()); + assert_eq!(result_2.to_address, Some(to_2)); + assert_eq!(result_2.tx_hash, tx_hash_2); + assert_eq!(result_2.gas_price, U256::from(15)); + assert_eq!(result_2.gas_used, 21_000); + assert_eq!(result_2.coinbase_diff, U256::from(21_000) * U256::from(15),); + + // Check aggregated values + assert_eq!(total_gas_used, 42_000); + let expected_total_fees = + U256::from(21_000) * U256::from(10) + U256::from(21_000) * U256::from(15); + assert_eq!(total_gas_fees, expected_total_fees); + + // Check bundle hash includes both transactions + let mut concatenated = Vec::with_capacity(64); + concatenated.extend_from_slice(tx_hash_1.as_slice()); + concatenated.extend_from_slice(tx_hash_2.as_slice()); + assert_eq!(bundle_hash, keccak256(concatenated)); + + assert!(result_1.execution_time_us > 0, "execution_time_us should be greater than zero"); + assert!(result_2.execution_time_us > 0, "execution_time_us should be greater than zero"); + + Ok(()) + } +} diff --git a/crates/client/metering/src/rpc.rs b/crates/client/metering/src/rpc.rs index f83c98bf..d74649d2 100644 --- a/crates/client/metering/src/rpc.rs +++ b/crates/client/metering/src/rpc.rs @@ -235,3 +235,306 @@ where }) } } + +#[cfg(test)] +mod tests { + use alloy_eips::Encodable2718; + use alloy_primitives::{Bytes, address}; + use alloy_rpc_client::RpcClient; + use base_bundles::{Bundle, MeterBundleResponse}; + use base_client_node::test_utils::{Account, TestHarness}; + use op_alloy_consensus::OpTxEnvelope; + use reth_optimism_primitives::OpTransactionSigned; + use reth_transaction_pool::test_utils::TransactionBuilder; + + use super::*; + use crate::MeteringExtension; + + fn create_bundle(txs: Vec, block_number: u64, min_timestamp: Option) -> Bundle { + Bundle { + txs, + block_number, + flashblock_number_min: None, + flashblock_number_max: None, + min_timestamp, + max_timestamp: None, + reverting_tx_hashes: vec![], + replacement_uuid: None, + dropping_tx_hashes: vec![], + } + } + + async fn setup() -> eyre::Result<(TestHarness, RpcClient)> { + let harness = TestHarness::builder().with_ext::(true).build().await?; + let client = harness.rpc_client()?; + Ok((harness, client)) + } + + #[tokio::test] + async fn test_meter_bundle_empty() -> eyre::Result<()> { + let (_harness, client) = setup().await?; + + let bundle = create_bundle(vec![], 0, None); + + let response: MeterBundleResponse = client.request("base_meterBundle", (bundle,)).await?; + + assert_eq!(response.results.len(), 0); + assert_eq!(response.total_gas_used, 0); + assert_eq!(response.gas_fees, U256::from(0)); + assert_eq!(response.state_block_number, 0); + + Ok(()) + } + + #[tokio::test] + async fn test_meter_bundle_single_transaction() -> eyre::Result<()> { + let (harness, client) = setup().await?; + + let sender_address = Account::Alice.address(); + let sender_secret = Account::Alice.signer_b256(); + + let tx = TransactionBuilder::default() + .signer(sender_secret) + .chain_id(harness.chain_id()) + .nonce(0) + .to(address!("0x1111111111111111111111111111111111111111")) + .value(1000) + .gas_limit(21_000) + .max_fee_per_gas(1_000_000_000) // 1 gwei + .max_priority_fee_per_gas(1_000_000_000) + .into_eip1559(); + + let signed_tx = + OpTransactionSigned::Eip1559(tx.as_eip1559().expect("eip1559 transaction").clone()); + let envelope: OpTxEnvelope = signed_tx; + + let tx_bytes = Bytes::from(envelope.encoded_2718()); + + let bundle = create_bundle(vec![tx_bytes], 0, None); + + let response: MeterBundleResponse = client.request("base_meterBundle", (bundle,)).await?; + + assert_eq!(response.results.len(), 1); + assert_eq!(response.total_gas_used, 21_000); + assert!(response.total_execution_time_us > 0); + + let result = &response.results[0]; + assert_eq!(result.from_address, sender_address); + assert_eq!(result.to_address, Some(address!("0x1111111111111111111111111111111111111111"))); + assert_eq!(result.gas_used, 21_000); + assert_eq!(result.gas_price, 1_000_000_000); + assert!(result.execution_time_us > 0); + + Ok(()) + } + + #[tokio::test] + async fn test_meter_bundle_multiple_transactions() -> eyre::Result<()> { + let (harness, client) = setup().await?; + + let address1 = Account::Alice.address(); + let secret1 = Account::Alice.signer_b256(); + + let tx1_inner = TransactionBuilder::default() + .signer(secret1) + .chain_id(harness.chain_id()) + .nonce(0) + .to(address!("0x1111111111111111111111111111111111111111")) + .value(1000) + .gas_limit(21_000) + .max_fee_per_gas(1_000_000_000) + .max_priority_fee_per_gas(1_000_000_000) + .into_eip1559(); + + let tx1_signed = OpTransactionSigned::Eip1559( + tx1_inner.as_eip1559().expect("eip1559 transaction").clone(), + ); + let tx1_envelope: OpTxEnvelope = tx1_signed; + let tx1_bytes = Bytes::from(tx1_envelope.encoded_2718()); + + let address2 = Account::Bob.address(); + let secret2 = Account::Bob.signer_b256(); + + let tx2_inner = TransactionBuilder::default() + .signer(secret2) + .chain_id(harness.chain_id()) + .nonce(0) + .to(address!("0x2222222222222222222222222222222222222222")) + .value(2000) + .gas_limit(21_000) + .max_fee_per_gas(2_000_000_000) + .max_priority_fee_per_gas(2_000_000_000) + .into_eip1559(); + + let tx2_signed = OpTransactionSigned::Eip1559( + tx2_inner.as_eip1559().expect("eip1559 transaction").clone(), + ); + let tx2_envelope: OpTxEnvelope = tx2_signed; + let tx2_bytes = Bytes::from(tx2_envelope.encoded_2718()); + + let bundle = create_bundle(vec![tx1_bytes, tx2_bytes], 0, None); + + let response: MeterBundleResponse = client.request("base_meterBundle", (bundle,)).await?; + + assert_eq!(response.results.len(), 2); + assert_eq!(response.total_gas_used, 42_000); + assert!(response.total_execution_time_us > 0); + + let result1 = &response.results[0]; + assert_eq!(result1.from_address, address1); + assert_eq!(result1.gas_used, 21_000); + assert_eq!(result1.gas_price, 1_000_000_000); + + let result2 = &response.results[1]; + assert_eq!(result2.from_address, address2); + assert_eq!(result2.gas_used, 21_000); + assert_eq!(result2.gas_price, 2_000_000_000); + + Ok(()) + } + + #[tokio::test] + async fn test_meter_bundle_invalid_transaction() -> eyre::Result<()> { + let (_harness, client) = setup().await?; + + let bundle = create_bundle( + vec![Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef])], // Invalid transaction data + 0, + None, + ); + + let result: Result = + client.request("base_meterBundle", (bundle,)).await; + + assert!(result.is_err()); + + Ok(()) + } + + #[tokio::test] + async fn test_meter_bundle_uses_latest_block() -> eyre::Result<()> { + let (_harness, client) = setup().await?; + + let bundle = create_bundle(vec![], 0, None); + + let response: MeterBundleResponse = client.request("base_meterBundle", (bundle,)).await?; + + assert_eq!(response.state_block_number, 0); + + Ok(()) + } + + #[tokio::test] + async fn test_meter_bundle_ignores_bundle_block_number() -> eyre::Result<()> { + let (_harness, client) = setup().await?; + + let bundle1 = create_bundle(vec![], 0, None); + let response1: MeterBundleResponse = client.request("base_meterBundle", (bundle1,)).await?; + + let bundle2 = create_bundle(vec![], 999, None); + let response2: MeterBundleResponse = client.request("base_meterBundle", (bundle2,)).await?; + + assert_eq!(response1.state_block_number, response2.state_block_number); + assert_eq!(response1.state_block_number, 0); + + Ok(()) + } + + #[tokio::test] + async fn test_meter_bundle_custom_timestamp() -> eyre::Result<()> { + let (_harness, client) = setup().await?; + + let custom_timestamp = 1234567890; + let bundle = create_bundle(vec![], 0, Some(custom_timestamp)); + + let response: MeterBundleResponse = client.request("base_meterBundle", (bundle,)).await?; + + assert_eq!(response.results.len(), 0); + assert_eq!(response.total_gas_used, 0); + + Ok(()) + } + + #[tokio::test] + async fn test_meter_bundle_arbitrary_block_number() -> eyre::Result<()> { + let (_harness, client) = setup().await?; + + let bundle = create_bundle(vec![], 999999, None); + + let response: MeterBundleResponse = client.request("base_meterBundle", (bundle,)).await?; + + assert_eq!(response.state_block_number, 0); + + Ok(()) + } + + #[tokio::test] + async fn test_meter_bundle_gas_calculations() -> eyre::Result<()> { + let (harness, client) = setup().await?; + + let secret1 = Account::Alice.signer_b256(); + let secret2 = Account::Bob.signer_b256(); + + let tx1_inner = TransactionBuilder::default() + .signer(secret1) + .chain_id(harness.chain_id()) + .nonce(0) + .to(address!("0x1111111111111111111111111111111111111111")) + .value(1000) + .gas_limit(21_000) + .max_fee_per_gas(3_000_000_000) // 3 gwei + .max_priority_fee_per_gas(3_000_000_000) + .into_eip1559(); + + let signed_tx1 = OpTransactionSigned::Eip1559( + tx1_inner.as_eip1559().expect("eip1559 transaction").clone(), + ); + let envelope1: OpTxEnvelope = signed_tx1; + let tx1_bytes = Bytes::from(envelope1.encoded_2718()); + + let tx2_inner = TransactionBuilder::default() + .signer(secret2) + .chain_id(harness.chain_id()) + .nonce(0) + .to(address!("0x2222222222222222222222222222222222222222")) + .value(2000) + .gas_limit(21_000) + .max_fee_per_gas(7_000_000_000) // 7 gwei + .max_priority_fee_per_gas(7_000_000_000) + .into_eip1559(); + + let signed_tx2 = OpTransactionSigned::Eip1559( + tx2_inner.as_eip1559().expect("eip1559 transaction").clone(), + ); + let envelope2: OpTxEnvelope = signed_tx2; + let tx2_bytes = Bytes::from(envelope2.encoded_2718()); + + let bundle = create_bundle(vec![tx1_bytes, tx2_bytes], 0, None); + + let response: MeterBundleResponse = client.request("base_meterBundle", (bundle,)).await?; + + assert_eq!(response.results.len(), 2); + + let result1 = &response.results[0]; + let expected_gas_fees_1 = U256::from(21_000) * U256::from(3_000_000_000u64); + assert_eq!(result1.gas_fees, expected_gas_fees_1); + assert_eq!(result1.gas_price, U256::from(3000000000u64)); + assert_eq!(result1.coinbase_diff, expected_gas_fees_1); + + let result2 = &response.results[1]; + let expected_gas_fees_2 = U256::from(21_000) * U256::from(7_000_000_000u64); + assert_eq!(result2.gas_fees, expected_gas_fees_2); + assert_eq!(result2.gas_price, U256::from(7000000000u64)); + assert_eq!(result2.coinbase_diff, expected_gas_fees_2); + + let total_gas_fees = expected_gas_fees_1 + expected_gas_fees_2; + assert_eq!(response.gas_fees, total_gas_fees); + assert_eq!(response.coinbase_diff, total_gas_fees); + assert_eq!(response.total_gas_used, 42_000); + + // Bundle gas price should be weighted average: (3*21000 + 7*21000) / (21000 + 21000) = 5 gwei + assert_eq!(response.bundle_gas_price, U256::from(5000000000u64)); + + Ok(()) + } +} diff --git a/crates/client/metering/src/test_utils.rs b/crates/client/metering/src/test_utils.rs new file mode 100644 index 00000000..06c8f355 --- /dev/null +++ b/crates/client/metering/src/test_utils.rs @@ -0,0 +1,67 @@ +//! Test utilities for metering integration tests. + +use std::sync::Arc; + +use base_client_node::test_utils::{create_provider_factory, load_chain_spec}; +use eyre::Context; +use reth::api::NodeTypesWithDBAdapter; +use reth_db::{DatabaseEnv, test_utils::TempDatabase}; +use reth_optimism_chainspec::OpChainSpec; +use reth_optimism_node::OpNode; +use reth_primitives_traits::SealedHeader; +use reth_provider::{HeaderProvider, providers::BlockchainProvider}; + +/// Test context providing the arguments needed by metering functions. +/// +/// This bundles the provider, header, and chain spec that `meter_bundle()` and +/// `meter_block()` require. Use this for testing metering logic directly without +/// spinning up a full node. +/// +/// For RPC integration tests, use [`TestHarness`] with [`MeteringExtension`] +/// instead: +/// +/// ```ignore +/// use base_client_node::test_utils::TestHarness; +/// use base_metering::MeteringExtension; +/// +/// let harness = TestHarness::builder() +/// .with_ext::(true) +/// .build() +/// .await?; +/// ``` +/// +/// # Example +/// +/// ```ignore +/// let ctx = MeteringTestContext::new()?; +/// let state = ctx.provider.state_by_block_hash(ctx.header.hash())?; +/// let result = meter_bundle(state, ctx.chain_spec.clone(), bundle, &ctx.header)?; +/// ``` +#[derive(Debug, Clone)] +pub struct MeteringTestContext { + /// Blockchain provider for state access. + pub provider: + BlockchainProvider>>>, + /// Genesis header - used as parent block for metering simulations. + pub header: SealedHeader, + /// Chain specification for EVM configuration. + pub chain_spec: Arc, +} + +impl MeteringTestContext { + /// Creates a new test context with genesis state initialized. + pub fn new() -> eyre::Result { + let chain_spec = load_chain_spec(); + + let factory = create_provider_factory::(chain_spec.clone()); + reth_db_common::init::init_genesis(&factory).context("initializing genesis state")?; + + let provider = BlockchainProvider::new(factory).context("creating provider")?; + let header = provider + .sealed_header(0) + .context("fetching genesis header")? + .expect("genesis header exists"); + + Ok(Self { provider, header, chain_spec }) + } +} diff --git a/crates/client/metering/tests/meter.rs b/crates/client/metering/tests/meter.rs deleted file mode 100644 index 3ab38191..00000000 --- a/crates/client/metering/tests/meter.rs +++ /dev/null @@ -1,222 +0,0 @@ -//! Integration tests covering the Metering logic surface area. - -use alloy_eips::Encodable2718; -use alloy_primitives::{Address, Bytes, U256, keccak256}; -use base_bundles::{Bundle, ParsedBundle}; -use base_client_node::test_utils::{Account, TestHarness, load_chain_spec}; -use base_metering::{MeteringExtension, meter_bundle}; -use eyre::Context; -use op_alloy_consensus::OpTxEnvelope; -use reth::chainspec::EthChainSpec; -use reth_optimism_primitives::OpTransactionSigned; -use reth_provider::{HeaderProvider, StateProviderFactory}; -use reth_transaction_pool::test_utils::TransactionBuilder; - -async fn setup() -> eyre::Result { - let chain_spec = load_chain_spec(); - TestHarness::builder() - .with_chain_spec(chain_spec) - .with_ext::(true) - .build() - .await -} - -fn envelope_from_signed(tx: &OpTransactionSigned) -> eyre::Result { - Ok(tx.clone().into()) -} - -fn create_parsed_bundle(envelopes: Vec) -> eyre::Result { - let txs: Vec = envelopes.iter().map(|env| Bytes::from(env.encoded_2718())).collect(); - - let bundle = Bundle { - txs, - block_number: 0, - flashblock_number_min: None, - flashblock_number_max: None, - min_timestamp: None, - max_timestamp: None, - reverting_tx_hashes: vec![], - replacement_uuid: None, - dropping_tx_hashes: vec![], - }; - - ParsedBundle::try_from(bundle).map_err(|e| eyre::eyre!(e)) -} - -#[tokio::test] -async fn meter_bundle_empty_transactions() -> eyre::Result<()> { - let harness = setup().await?; - - let provider = harness.blockchain_provider(); - let header = provider.sealed_header(0)?.expect("genesis header exists"); - let chain_spec = harness.chain_spec(); - - let state_provider = - provider.state_by_block_hash(header.hash()).context("getting state provider")?; - - let parsed_bundle = create_parsed_bundle(Vec::new())?; - - let (results, total_gas_used, total_gas_fees, bundle_hash, total_execution_time) = - meter_bundle(state_provider, chain_spec, parsed_bundle, &header)?; - - assert!(results.is_empty()); - assert_eq!(total_gas_used, 0); - assert_eq!(total_gas_fees, U256::ZERO); - // Even empty bundles have some EVM setup overhead - assert!(total_execution_time > 0); - assert_eq!(bundle_hash, keccak256([])); - - Ok(()) -} - -#[tokio::test] -async fn meter_bundle_single_transaction() -> eyre::Result<()> { - let harness = setup().await?; - - let provider = harness.blockchain_provider(); - let header = provider.sealed_header(0)?.expect("genesis header exists"); - let chain_spec = harness.chain_spec(); - - let to = Address::random(); - let signed_tx = TransactionBuilder::default() - .signer(Account::Alice.signer_b256()) - .chain_id(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 envelope = envelope_from_signed(&tx)?; - let tx_hash = envelope.tx_hash(); - - let state_provider = - provider.state_by_block_hash(header.hash()).context("getting state provider")?; - - let parsed_bundle = create_parsed_bundle(vec![envelope.clone()])?; - - let (results, total_gas_used, total_gas_fees, bundle_hash, total_execution_time) = - meter_bundle(state_provider, chain_spec, parsed_bundle, &header)?; - - assert_eq!(results.len(), 1); - let result = &results[0]; - assert!(total_execution_time > 0); - - assert_eq!(result.from_address, Account::Alice.address()); - assert_eq!(result.to_address, Some(to)); - assert_eq!(result.tx_hash, tx_hash); - assert_eq!(result.gas_price, U256::from(10)); - assert_eq!(result.gas_used, 21_000); - assert_eq!(result.coinbase_diff, (U256::from(21_000) * U256::from(10)),); - - assert_eq!(total_gas_used, 21_000); - assert_eq!(total_gas_fees, U256::from(21_000) * U256::from(10)); - - let mut concatenated = Vec::with_capacity(32); - concatenated.extend_from_slice(tx_hash.as_slice()); - assert_eq!(bundle_hash, keccak256(concatenated)); - - assert!(result.execution_time_us > 0, "execution_time_us should be greater than zero"); - - Ok(()) -} - -#[tokio::test] -async fn meter_bundle_multiple_transactions() -> eyre::Result<()> { - let harness = setup().await?; - - let provider = harness.blockchain_provider(); - let header = provider.sealed_header(0)?.expect("genesis header exists"); - let chain_spec = harness.chain_spec(); - - let to_1 = Address::random(); - let to_2 = Address::random(); - - // Create first transaction - let signed_tx_1 = TransactionBuilder::default() - .signer(Account::Alice.signer_b256()) - .chain_id(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(), - ); - - // Create second transaction - let signed_tx_2 = TransactionBuilder::default() - .signer(Account::Bob.signer_b256()) - .chain_id(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 envelope_1 = envelope_from_signed(&tx_1)?; - let envelope_2 = envelope_from_signed(&tx_2)?; - let tx_hash_1 = envelope_1.tx_hash(); - let tx_hash_2 = envelope_2.tx_hash(); - - let state_provider = - provider.state_by_block_hash(header.hash()).context("getting state provider")?; - - let parsed_bundle = create_parsed_bundle(vec![envelope_1.clone(), envelope_2.clone()])?; - - let (results, total_gas_used, total_gas_fees, bundle_hash, total_execution_time) = - meter_bundle(state_provider, chain_spec, parsed_bundle, &header)?; - - assert_eq!(results.len(), 2); - assert!(total_execution_time > 0); - - // Check first transaction - let result_1 = &results[0]; - assert_eq!(result_1.from_address, Account::Alice.address()); - assert_eq!(result_1.to_address, Some(to_1)); - assert_eq!(result_1.tx_hash, tx_hash_1); - assert_eq!(result_1.gas_price, U256::from(10)); - assert_eq!(result_1.gas_used, 21_000); - assert_eq!(result_1.coinbase_diff, (U256::from(21_000) * U256::from(10)),); - - // Check second transaction - let result_2 = &results[1]; - assert_eq!(result_2.from_address, Account::Bob.address()); - assert_eq!(result_2.to_address, Some(to_2)); - assert_eq!(result_2.tx_hash, tx_hash_2); - assert_eq!(result_2.gas_price, U256::from(15)); - assert_eq!(result_2.gas_used, 21_000); - assert_eq!(result_2.coinbase_diff, U256::from(21_000) * U256::from(15),); - - // Check aggregated values - assert_eq!(total_gas_used, 42_000); - let expected_total_fees = - U256::from(21_000) * U256::from(10) + U256::from(21_000) * U256::from(15); - assert_eq!(total_gas_fees, expected_total_fees); - - // Check bundle hash includes both transactions - let mut concatenated = Vec::with_capacity(64); - concatenated.extend_from_slice(tx_hash_1.as_slice()); - concatenated.extend_from_slice(tx_hash_2.as_slice()); - assert_eq!(bundle_hash, keccak256(concatenated)); - - assert!(result_1.execution_time_us > 0, "execution_time_us should be greater than zero"); - assert!(result_2.execution_time_us > 0, "execution_time_us should be greater than zero"); - - Ok(()) -} diff --git a/crates/client/metering/tests/meter_block.rs b/crates/client/metering/tests/meter_block.rs deleted file mode 100644 index 704e3b77..00000000 --- a/crates/client/metering/tests/meter_block.rs +++ /dev/null @@ -1,314 +0,0 @@ -//! Integration tests for block metering functionality. - -use alloy_consensus::{BlockHeader, Header}; -use alloy_primitives::{Address, B256}; -use base_client_node::test_utils::{Account, TestHarness, load_chain_spec}; -use base_metering::{MeteringExtension, meter_block}; -use reth::chainspec::EthChainSpec; -use reth_optimism_primitives::{OpBlock, OpBlockBody, OpTransactionSigned}; -use reth_primitives_traits::Block as BlockT; -use reth_provider::HeaderProvider; -use reth_transaction_pool::test_utils::TransactionBuilder; - -async fn setup() -> eyre::Result { - let chain_spec = load_chain_spec(); - TestHarness::builder() - .with_chain_spec(chain_spec) - .with_ext::(true) - .build() - .await -} - -fn create_block_with_transactions( - harness: &TestHarness, - transactions: Vec, -) -> eyre::Result { - let provider = harness.blockchain_provider(); - let genesis = provider.sealed_header(0)?.expect("genesis header exists"); - - let header = Header { - parent_hash: genesis.hash(), - number: genesis.number() + 1, - timestamp: genesis.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 }; - - Ok(OpBlock::new(header, body)) -} - -#[tokio::test] -async fn meter_block_empty_transactions() -> eyre::Result<()> { - let harness = setup().await?; - - let block = create_block_with_transactions(&harness, vec![])?; - - let response = meter_block(harness.blockchain_provider(), harness.chain_spec(), &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(()) -} - -#[tokio::test] -async fn meter_block_single_transaction() -> eyre::Result<()> { - let harness = setup().await?; - let chain_spec = harness.chain_spec(); - - let to = Address::random(); - let signed_tx = TransactionBuilder::default() - .signer(Account::Alice.signer_b256()) - .chain_id(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.blockchain_provider(), chain_spec, &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(()) -} - -#[tokio::test] -async fn meter_block_multiple_transactions() -> eyre::Result<()> { - let harness = setup().await?; - let chain_spec = harness.chain_spec(); - - let to_1 = Address::random(); - let to_2 = Address::random(); - - // Create first transaction from Alice - let signed_tx_1 = TransactionBuilder::default() - .signer(Account::Alice.signer_b256()) - .chain_id(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(Account::Bob.signer_b256()) - .chain_id(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.blockchain_provider(), chain_spec, &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(()) -} - -#[tokio::test] -async fn meter_block_timing_consistency() -> eyre::Result<()> { - let harness = setup().await?; - let chain_spec = harness.chain_spec(); - - // Create a block with one transaction - let signed_tx = TransactionBuilder::default() - .signer(Account::Alice.signer_b256()) - .chain_id(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.blockchain_provider(), chain_spec, &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 -// ============================================================================ - -#[tokio::test] -async fn meter_block_parent_header_not_found() -> eyre::Result<()> { - let harness = setup().await?; - let chain_spec = harness.chain_spec(); - let provider = harness.blockchain_provider(); - let genesis = provider.sealed_header(0)?.expect("genesis header exists"); - - // 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: genesis.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(provider, chain_spec, &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(()) -} - -#[tokio::test] -async fn meter_block_invalid_transaction_signature() -> eyre::Result<()> { - use alloy_consensus::TxEip1559; - use alloy_primitives::Signature; - - let harness = setup().await?; - let chain_spec = harness.chain_spec(); - - // Create a transaction with an invalid signature - let tx = TxEip1559 { - chain_id: 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.blockchain_provider(), chain_spec, &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(()) -} diff --git a/crates/client/metering/tests/meter_rpc.rs b/crates/client/metering/tests/meter_rpc.rs deleted file mode 100644 index 3fc528eb..00000000 --- a/crates/client/metering/tests/meter_rpc.rs +++ /dev/null @@ -1,323 +0,0 @@ -//! Integration tests covering the Metering RPC surface area. - -use alloy_eips::Encodable2718; -use alloy_primitives::{Bytes, U256, address, bytes}; -use alloy_rpc_client::RpcClient; -use base_bundles::{Bundle, MeterBundleResponse}; -use base_client_node::test_utils::{Account, TestHarness}; -use base_metering::MeteringExtension; -use op_alloy_consensus::OpTxEnvelope; -use reth_optimism_primitives::OpTransactionSigned; -use reth_transaction_pool::test_utils::TransactionBuilder; - -/// Helper function to create a Bundle with default fields. -fn create_bundle(txs: Vec, block_number: u64, min_timestamp: Option) -> Bundle { - Bundle { - txs, - block_number, - flashblock_number_min: None, - flashblock_number_max: None, - min_timestamp, - max_timestamp: None, - reverting_tx_hashes: vec![], - replacement_uuid: None, - dropping_tx_hashes: vec![], - } -} - -/// Set up a test harness with the metering extension and return an RPC client. -async fn setup() -> eyre::Result<(TestHarness, RpcClient)> { - let harness = TestHarness::builder().with_ext::(true).build().await?; - - let client = harness.rpc_client()?; - Ok((harness, client)) -} - -#[tokio::test] -async fn test_meter_bundle_empty() -> eyre::Result<()> { - let (_harness, client) = setup().await?; - - let bundle = create_bundle(vec![], 0, None); - - let response: MeterBundleResponse = client.request("base_meterBundle", (bundle,)).await?; - - assert_eq!(response.results.len(), 0); - assert_eq!(response.total_gas_used, 0); - assert_eq!(response.gas_fees, U256::from(0)); - assert_eq!(response.state_block_number, 0); - - Ok(()) -} - -#[tokio::test] -async fn test_meter_bundle_single_transaction() -> eyre::Result<()> { - let (harness, client) = setup().await?; - - let sender_address = Account::Alice.address(); - let sender_secret = Account::Alice.signer_b256(); - - // Build a transaction - let tx = TransactionBuilder::default() - .signer(sender_secret) - .chain_id(harness.chain_id()) - .nonce(0) - .to(address!("0x1111111111111111111111111111111111111111")) - .value(1000) - .gas_limit(21_000) - .max_fee_per_gas(1_000_000_000) // 1 gwei - .max_priority_fee_per_gas(1_000_000_000) - .into_eip1559(); - - let signed_tx = - OpTransactionSigned::Eip1559(tx.as_eip1559().expect("eip1559 transaction").clone()); - let envelope: OpTxEnvelope = signed_tx.into(); - - // Encode transaction - let tx_bytes = Bytes::from(envelope.encoded_2718()); - - let bundle = create_bundle(vec![tx_bytes], 0, None); - - let response: MeterBundleResponse = client.request("base_meterBundle", (bundle,)).await?; - - assert_eq!(response.results.len(), 1); - assert_eq!(response.total_gas_used, 21_000); - assert!(response.total_execution_time_us > 0); - - let result = &response.results[0]; - assert_eq!(result.from_address, sender_address); - assert_eq!(result.to_address, Some(address!("0x1111111111111111111111111111111111111111"))); - assert_eq!(result.gas_used, 21_000); - assert_eq!(result.gas_price, 1_000_000_000); - assert!(result.execution_time_us > 0); - - Ok(()) -} - -#[tokio::test] -async fn test_meter_bundle_multiple_transactions() -> eyre::Result<()> { - let (harness, client) = setup().await?; - - let address1 = Account::Alice.address(); - let secret1 = Account::Alice.signer_b256(); - - let tx1_inner = TransactionBuilder::default() - .signer(secret1) - .chain_id(harness.chain_id()) - .nonce(0) - .to(address!("0x1111111111111111111111111111111111111111")) - .value(1000) - .gas_limit(21_000) - .max_fee_per_gas(1_000_000_000) - .max_priority_fee_per_gas(1_000_000_000) - .into_eip1559(); - - let tx1_signed = - OpTransactionSigned::Eip1559(tx1_inner.as_eip1559().expect("eip1559 transaction").clone()); - let tx1_envelope: OpTxEnvelope = tx1_signed.into(); - let tx1_bytes = Bytes::from(tx1_envelope.encoded_2718()); - - // Second transaction from second account - let address2 = Account::Bob.address(); - let secret2 = Account::Bob.signer_b256(); - - let tx2_inner = TransactionBuilder::default() - .signer(secret2) - .chain_id(harness.chain_id()) - .nonce(0) - .to(address!("0x2222222222222222222222222222222222222222")) - .value(2000) - .gas_limit(21_000) - .max_fee_per_gas(2_000_000_000) - .max_priority_fee_per_gas(2_000_000_000) - .into_eip1559(); - - let tx2_signed = - OpTransactionSigned::Eip1559(tx2_inner.as_eip1559().expect("eip1559 transaction").clone()); - let tx2_envelope: OpTxEnvelope = tx2_signed.into(); - let tx2_bytes = Bytes::from(tx2_envelope.encoded_2718()); - - let bundle = create_bundle(vec![tx1_bytes, tx2_bytes], 0, None); - - let response: MeterBundleResponse = client.request("base_meterBundle", (bundle,)).await?; - - assert_eq!(response.results.len(), 2); - assert_eq!(response.total_gas_used, 42_000); - assert!(response.total_execution_time_us > 0); - - // Check first transaction - let result1 = &response.results[0]; - assert_eq!(result1.from_address, address1); - assert_eq!(result1.gas_used, 21_000); - assert_eq!(result1.gas_price, 1_000_000_000); - - // Check second transaction - let result2 = &response.results[1]; - assert_eq!(result2.from_address, address2); - assert_eq!(result2.gas_used, 21_000); - assert_eq!(result2.gas_price, 2_000_000_000); - - Ok(()) -} - -#[tokio::test] -async fn test_meter_bundle_invalid_transaction() -> eyre::Result<()> { - let (_harness, client) = setup().await?; - - let bundle = create_bundle( - vec![bytes!("0xdeadbeef")], // Invalid transaction data - 0, - None, - ); - - let result: Result = - client.request("base_meterBundle", (bundle,)).await; - - assert!(result.is_err()); - - Ok(()) -} - -#[tokio::test] -async fn test_meter_bundle_uses_latest_block() -> eyre::Result<()> { - let (_harness, client) = setup().await?; - - // Metering always uses the latest block state, regardless of bundle.block_number - let bundle = create_bundle(vec![], 0, None); - - let response: MeterBundleResponse = client.request("base_meterBundle", (bundle,)).await?; - - // Should return the latest block number (genesis block 0) - assert_eq!(response.state_block_number, 0); - - Ok(()) -} - -#[tokio::test] -async fn test_meter_bundle_ignores_bundle_block_number() -> eyre::Result<()> { - let (_harness, client) = setup().await?; - - // Even if bundle.block_number is different, it should use the latest block - // In this test, we specify block_number=0 in the bundle - let bundle1 = create_bundle(vec![], 0, None); - let response1: MeterBundleResponse = client.request("base_meterBundle", (bundle1,)).await?; - - // Try with a different bundle.block_number (999 - arbitrary value) - // Since we can't create future blocks, we use a different value to show it's ignored - let bundle2 = create_bundle(vec![], 999, None); - let response2: MeterBundleResponse = client.request("base_meterBundle", (bundle2,)).await?; - - // Both should return the same state_block_number (the latest block) - // because the implementation always uses Latest, not bundle.block_number - assert_eq!(response1.state_block_number, response2.state_block_number); - assert_eq!(response1.state_block_number, 0); // Genesis block - - Ok(()) -} - -#[tokio::test] -async fn test_meter_bundle_custom_timestamp() -> eyre::Result<()> { - let (_harness, client) = setup().await?; - - // Test that bundle.min_timestamp is used for simulation. - // The timestamp affects block.timestamp in the EVM during simulation but is not - // returned in the response. - let custom_timestamp = 1234567890; - let bundle = create_bundle(vec![], 0, Some(custom_timestamp)); - - let response: MeterBundleResponse = client.request("base_meterBundle", (bundle,)).await?; - - // Verify the request succeeded with custom timestamp - assert_eq!(response.results.len(), 0); - assert_eq!(response.total_gas_used, 0); - - Ok(()) -} - -#[tokio::test] -async fn test_meter_bundle_arbitrary_block_number() -> eyre::Result<()> { - let (_harness, client) = setup().await?; - - // Since we now ignore bundle.block_number and always use the latest block, - // any block_number value should work (it's only used for bundle validity in TIPS) - let bundle = create_bundle(vec![], 999999, None); - - let response: MeterBundleResponse = client.request("base_meterBundle", (bundle,)).await?; - - // Should succeed and use the latest block (genesis block 0) - assert_eq!(response.state_block_number, 0); - - Ok(()) -} - -#[tokio::test] -async fn test_meter_bundle_gas_calculations() -> eyre::Result<()> { - let (harness, client) = setup().await?; - - let secret1 = Account::Alice.signer_b256(); - let secret2 = Account::Bob.signer_b256(); - - // First transaction with 3 gwei gas price - let tx1_inner = TransactionBuilder::default() - .signer(secret1) - .chain_id(harness.chain_id()) - .nonce(0) - .to(address!("0x1111111111111111111111111111111111111111")) - .value(1000) - .gas_limit(21_000) - .max_fee_per_gas(3_000_000_000) // 3 gwei - .max_priority_fee_per_gas(3_000_000_000) - .into_eip1559(); - - let signed_tx1 = - OpTransactionSigned::Eip1559(tx1_inner.as_eip1559().expect("eip1559 transaction").clone()); - let envelope1: OpTxEnvelope = signed_tx1.into(); - let tx1_bytes = Bytes::from(envelope1.encoded_2718()); - - // Second transaction with 7 gwei gas price - let tx2_inner = TransactionBuilder::default() - .signer(secret2) - .chain_id(harness.chain_id()) - .nonce(0) - .to(address!("0x2222222222222222222222222222222222222222")) - .value(2000) - .gas_limit(21_000) - .max_fee_per_gas(7_000_000_000) // 7 gwei - .max_priority_fee_per_gas(7_000_000_000) - .into_eip1559(); - - let signed_tx2 = - OpTransactionSigned::Eip1559(tx2_inner.as_eip1559().expect("eip1559 transaction").clone()); - let envelope2: OpTxEnvelope = signed_tx2.into(); - let tx2_bytes = Bytes::from(envelope2.encoded_2718()); - - let bundle = create_bundle(vec![tx1_bytes, tx2_bytes], 0, None); - - let response: MeterBundleResponse = client.request("base_meterBundle", (bundle,)).await?; - - assert_eq!(response.results.len(), 2); - - // Check first transaction (3 gwei) - let result1 = &response.results[0]; - let expected_gas_fees_1 = U256::from(21_000) * U256::from(3_000_000_000u64); - assert_eq!(result1.gas_fees, expected_gas_fees_1); - assert_eq!(result1.gas_price, U256::from(3000000000u64)); - assert_eq!(result1.coinbase_diff, expected_gas_fees_1); - - // Check second transaction (7 gwei) - let result2 = &response.results[1]; - let expected_gas_fees_2 = U256::from(21_000) * U256::from(7_000_000_000u64); - assert_eq!(result2.gas_fees, expected_gas_fees_2); - assert_eq!(result2.gas_price, U256::from(7000000000u64)); - assert_eq!(result2.coinbase_diff, expected_gas_fees_2); - - // Check bundle totals - let total_gas_fees = expected_gas_fees_1 + expected_gas_fees_2; - assert_eq!(response.gas_fees, total_gas_fees); - assert_eq!(response.coinbase_diff, total_gas_fees); - assert_eq!(response.total_gas_used, 42_000); - - // Bundle gas price should be weighted average: (3*21000 + 7*21000) / (21000 + 21000) = 5 gwei - assert_eq!(response.bundle_gas_price, U256::from(5000000000u64)); - - Ok(()) -} From 3025721e2cd94d151c5a225262b6109c60262578 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Mon, 12 Jan 2026 12:43:58 -0600 Subject: [PATCH 2/3] refactor(metering): use TestHarness instead of MeteringTestContext Address PR review feedback: - Delete test_utils.rs and remove test-utils feature to avoid duplication - Use TestHarness from base-client-node for all tests - Remove envelope_from_signed helper - use direct conversion - Remove unused imports --- crates/client/metering/Cargo.toml | 13 --- crates/client/metering/src/block.rs | 102 +++++++++++------------ crates/client/metering/src/lib.rs | 3 - crates/client/metering/src/meter.rs | 81 +++++++++--------- crates/client/metering/src/test_utils.rs | 67 --------------- 5 files changed, 85 insertions(+), 181 deletions(-) delete mode 100644 crates/client/metering/src/test_utils.rs diff --git a/crates/client/metering/Cargo.toml b/crates/client/metering/Cargo.toml index d9a010bd..c45ee302 100644 --- a/crates/client/metering/Cargo.toml +++ b/crates/client/metering/Cargo.toml @@ -11,14 +11,6 @@ description = "Metering RPC for Base node" [lints] workspace = true -[features] -test-utils = [ - "base-client-node/test-utils", - "dep:reth-db", - "dep:reth-db-common", - "dep:reth-optimism-node", -] - [dependencies] # workspace base-bundles.workspace = true @@ -46,11 +38,6 @@ tracing.workspace = true eyre.workspace = true serde.workspace = true -# test-utils (optional) -reth-db = { workspace = true, features = ["test-utils"], optional = true } -reth-db-common = { workspace = true, optional = true } -reth-optimism-node = { workspace = true, optional = true } - [dev-dependencies] base-client-node = { workspace = true, features = ["test-utils"] } reth-db = { workspace = true, features = ["test-utils"] } diff --git a/crates/client/metering/src/block.rs b/crates/client/metering/src/block.rs index 6c8deb27..de497249 100644 --- a/crates/client/metering/src/block.rs +++ b/crates/client/metering/src/block.rs @@ -137,23 +137,24 @@ where #[cfg(test)] mod tests { - use alloy_primitives::Address; - use base_client_node::test_utils::Account; - use reth::chainspec::EthChainSpec; - use reth_optimism_primitives::OpBlockBody; + use alloy_consensus::TxEip1559; + use alloy_primitives::{Address, Signature}; + use base_client_node::test_utils::{Account, TestHarness}; + use reth_optimism_primitives::{OpBlockBody, OpTransactionSigned}; + use reth_primitives_traits::Block as _; use reth_transaction_pool::test_utils::TransactionBuilder; use super::*; - use crate::test_utils::MeteringTestContext; fn create_block_with_transactions( - ctx: &MeteringTestContext, - transactions: Vec, + harness: &TestHarness, + transactions: Vec, ) -> OpBlock { + let latest = harness.latest_block(); let header = Header { - parent_hash: ctx.header.hash(), - number: ctx.header.number() + 1, - timestamp: ctx.header.timestamp() + 2, + parent_hash: latest.hash(), + number: latest.number() + 1, + timestamp: latest.timestamp() + 2, gas_limit: 30_000_000, beneficiary: Address::random(), base_fee_per_gas: Some(1), @@ -167,13 +168,13 @@ mod tests { OpBlock::new(header, body) } - #[test] - fn meter_block_empty_transactions() -> eyre::Result<()> { - let ctx = MeteringTestContext::new()?; + #[tokio::test] + async fn meter_block_empty_transactions() -> eyre::Result<()> { + let harness = TestHarness::new().await?; - let block = create_block_with_transactions(&ctx, vec![]); + let block = create_block_with_transactions(&harness, vec![]); - let response = meter_block(ctx.provider.clone(), ctx.chain_spec, &block)?; + let response = meter_block(harness.blockchain_provider(), harness.chain_spec(), &block)?; assert_eq!(response.block_hash, block.header().hash_slow()); assert_eq!(response.block_number, block.header().number()); @@ -194,16 +195,14 @@ mod tests { Ok(()) } - #[test] - fn meter_block_single_transaction() -> eyre::Result<()> { - use reth_optimism_primitives::OpTransactionSigned; - - let ctx = MeteringTestContext::new()?; + #[tokio::test] + async fn meter_block_single_transaction() -> eyre::Result<()> { + let harness = TestHarness::new().await?; let to = Address::random(); let signed_tx = TransactionBuilder::default() .signer(Account::Alice.signer_b256()) - .chain_id(ctx.chain_spec.chain_id()) + .chain_id(harness.chain_id()) .nonce(0) .to(to) .value(1_000) @@ -217,9 +216,9 @@ mod tests { ); let tx_hash = tx.tx_hash(); - let block = create_block_with_transactions(&ctx, vec![tx]); + let block = create_block_with_transactions(&harness, vec![tx]); - let response = meter_block(ctx.provider.clone(), ctx.chain_spec, &block)?; + let response = meter_block(harness.blockchain_provider(), harness.chain_spec(), &block)?; assert_eq!(response.block_hash, block.header().hash_slow()); assert_eq!(response.block_number, block.header().number()); @@ -243,11 +242,9 @@ mod tests { Ok(()) } - #[test] - fn meter_block_multiple_transactions() -> eyre::Result<()> { - use reth_optimism_primitives::OpTransactionSigned; - - let ctx = MeteringTestContext::new()?; + #[tokio::test] + async fn meter_block_multiple_transactions() -> eyre::Result<()> { + let harness = TestHarness::new().await?; let to_1 = Address::random(); let to_2 = Address::random(); @@ -255,7 +252,7 @@ mod tests { // Create first transaction from Alice let signed_tx_1 = TransactionBuilder::default() .signer(Account::Alice.signer_b256()) - .chain_id(ctx.chain_spec.chain_id()) + .chain_id(harness.chain_id()) .nonce(0) .to(to_1) .value(1_000) @@ -272,7 +269,7 @@ mod tests { // Create second transaction from Bob let signed_tx_2 = TransactionBuilder::default() .signer(Account::Bob.signer_b256()) - .chain_id(ctx.chain_spec.chain_id()) + .chain_id(harness.chain_id()) .nonce(0) .to(to_2) .value(2_000) @@ -286,9 +283,9 @@ mod tests { ); let tx_hash_2 = tx_2.tx_hash(); - let block = create_block_with_transactions(&ctx, vec![tx_1, tx_2]); + let block = create_block_with_transactions(&harness, vec![tx_1, tx_2]); - let response = meter_block(ctx.provider.clone(), ctx.chain_spec, &block)?; + let response = meter_block(harness.blockchain_provider(), harness.chain_spec(), &block)?; assert_eq!(response.block_hash, block.header().hash_slow()); assert_eq!(response.block_number, block.header().number()); @@ -328,16 +325,14 @@ mod tests { Ok(()) } - #[test] - fn meter_block_timing_consistency() -> eyre::Result<()> { - use reth_optimism_primitives::OpTransactionSigned; - - let ctx = MeteringTestContext::new()?; + #[tokio::test] + async fn meter_block_timing_consistency() -> eyre::Result<()> { + let harness = TestHarness::new().await?; // Create a block with one transaction let signed_tx = TransactionBuilder::default() .signer(Account::Alice.signer_b256()) - .chain_id(ctx.chain_spec.chain_id()) + .chain_id(harness.chain_id()) .nonce(0) .to(Address::random()) .value(1_000) @@ -350,9 +345,9 @@ mod tests { signed_tx.as_eip1559().expect("eip1559 transaction").clone(), ); - let block = create_block_with_transactions(&ctx, vec![tx]); + let block = create_block_with_transactions(&harness, vec![tx]); - let response = meter_block(ctx.provider.clone(), ctx.chain_spec, &block)?; + let response = meter_block(harness.blockchain_provider(), harness.chain_spec(), &block)?; // Verify timing invariants assert!(response.signer_recovery_time_us > 0, "signer recovery time must be positive"); @@ -373,16 +368,17 @@ mod tests { // Error Path Tests // ============================================================================ - #[test] - fn meter_block_parent_header_not_found() -> eyre::Result<()> { - let ctx = MeteringTestContext::new()?; + #[tokio::test] + async fn meter_block_parent_header_not_found() -> eyre::Result<()> { + let harness = TestHarness::new().await?; + let latest = harness.latest_block(); // 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: ctx.header.timestamp() + 2, + timestamp: latest.timestamp() + 2, gas_limit: 30_000_000, beneficiary: Address::random(), base_fee_per_gas: Some(1), @@ -393,7 +389,7 @@ mod tests { let body = OpBlockBody { transactions: vec![], ommers: vec![], withdrawals: None }; let block = OpBlock::new(header, body); - let result = meter_block(ctx.provider.clone(), ctx.chain_spec, &block); + let result = meter_block(harness.blockchain_provider(), harness.chain_spec(), &block); assert!(result.is_err(), "should fail when parent header is not found"); let err = result.unwrap_err(); @@ -407,17 +403,13 @@ mod tests { Ok(()) } - #[test] - fn meter_block_invalid_transaction_signature() -> eyre::Result<()> { - use alloy_consensus::TxEip1559; - use alloy_primitives::Signature; - use reth_optimism_primitives::OpTransactionSigned; - - let ctx = MeteringTestContext::new()?; + #[tokio::test] + async fn meter_block_invalid_transaction_signature() -> eyre::Result<()> { + let harness = TestHarness::new().await?; // Create a transaction with an invalid signature let tx = TxEip1559 { - chain_id: ctx.chain_spec.chain_id(), + chain_id: harness.chain_id(), nonce: 0, gas_limit: 21_000, max_fee_per_gas: 10, @@ -436,9 +428,9 @@ mod tests { alloy_consensus::Signed::new_unchecked(tx, invalid_signature, B256::random()); let op_tx = OpTransactionSigned::Eip1559(signed_tx); - let block = create_block_with_transactions(&ctx, vec![op_tx]); + let block = create_block_with_transactions(&harness, vec![op_tx]); - let result = meter_block(ctx.provider.clone(), ctx.chain_spec, &block); + let result = meter_block(harness.blockchain_provider(), harness.chain_spec(), &block); assert!(result.is_err(), "should fail when transaction has invalid signature"); let err = result.unwrap_err(); diff --git a/crates/client/metering/src/lib.rs b/crates/client/metering/src/lib.rs index 3a8407f2..41883a8d 100644 --- a/crates/client/metering/src/lib.rs +++ b/crates/client/metering/src/lib.rs @@ -20,6 +20,3 @@ pub use traits::MeteringApiServer; mod types; pub use types::{MeterBlockResponse, MeterBlockTransactions}; - -#[cfg(any(test, feature = "test-utils"))] -pub mod test_utils; diff --git a/crates/client/metering/src/meter.rs b/crates/client/metering/src/meter.rs index 3909ad15..15bd0cdf 100644 --- a/crates/client/metering/src/meter.rs +++ b/crates/client/metering/src/meter.rs @@ -107,23 +107,16 @@ mod tests { use alloy_eips::Encodable2718; use alloy_primitives::{Address, Bytes, keccak256}; use base_bundles::{Bundle, ParsedBundle}; - use base_client_node::test_utils::Account; + use base_client_node::test_utils::{Account, TestHarness}; use eyre::Context; - use op_alloy_consensus::OpTxEnvelope; - use reth::chainspec::EthChainSpec; use reth_optimism_primitives::OpTransactionSigned; use reth_provider::StateProviderFactory; use reth_transaction_pool::test_utils::TransactionBuilder; use super::*; - use crate::test_utils::MeteringTestContext; - fn envelope_from_signed(tx: &OpTransactionSigned) -> eyre::Result { - Ok(tx.clone()) - } - - fn create_parsed_bundle(envelopes: Vec) -> eyre::Result { - let txs: Vec = envelopes.iter().map(|env| Bytes::from(env.encoded_2718())).collect(); + fn create_parsed_bundle(txs: Vec) -> eyre::Result { + let txs: Vec = txs.iter().map(|tx| Bytes::from(tx.encoded_2718())).collect(); let bundle = Bundle { txs, @@ -140,19 +133,21 @@ mod tests { ParsedBundle::try_from(bundle).map_err(|e| eyre::eyre!(e)) } - #[test] - fn meter_bundle_empty_transactions() -> eyre::Result<()> { - let ctx = MeteringTestContext::new()?; + #[tokio::test] + async fn meter_bundle_empty_transactions() -> eyre::Result<()> { + let harness = TestHarness::new().await?; + let latest = harness.latest_block(); + let header = latest.sealed_header().clone(); - let state_provider = ctx - .provider - .state_by_block_hash(ctx.header.hash()) + let state_provider = harness + .blockchain_provider() + .state_by_block_hash(latest.hash()) .context("getting state provider")?; let parsed_bundle = create_parsed_bundle(Vec::new())?; let (results, total_gas_used, total_gas_fees, bundle_hash, total_execution_time) = - meter_bundle(state_provider, ctx.chain_spec.clone(), parsed_bundle, &ctx.header)?; + meter_bundle(state_provider, harness.chain_spec(), parsed_bundle, &header)?; assert!(results.is_empty()); assert_eq!(total_gas_used, 0); @@ -164,14 +159,16 @@ mod tests { Ok(()) } - #[test] - fn meter_bundle_single_transaction() -> eyre::Result<()> { - let ctx = MeteringTestContext::new()?; + #[tokio::test] + async fn meter_bundle_single_transaction() -> eyre::Result<()> { + let harness = TestHarness::new().await?; + let latest = harness.latest_block(); + let header = latest.sealed_header().clone(); let to = Address::random(); let signed_tx = TransactionBuilder::default() .signer(Account::Alice.signer_b256()) - .chain_id(ctx.chain_spec.chain_id()) + .chain_id(harness.chain_id()) .nonce(0) .to(to) .value(1_000) @@ -183,19 +180,17 @@ mod tests { let tx = OpTransactionSigned::Eip1559( signed_tx.as_eip1559().expect("eip1559 transaction").clone(), ); + let tx_hash = tx.tx_hash(); - let envelope = envelope_from_signed(&tx)?; - let tx_hash = envelope.tx_hash(); - - let state_provider = ctx - .provider - .state_by_block_hash(ctx.header.hash()) + let state_provider = harness + .blockchain_provider() + .state_by_block_hash(latest.hash()) .context("getting state provider")?; - let parsed_bundle = create_parsed_bundle(vec![envelope])?; + let parsed_bundle = create_parsed_bundle(vec![tx])?; let (results, total_gas_used, total_gas_fees, bundle_hash, total_execution_time) = - meter_bundle(state_provider, ctx.chain_spec.clone(), parsed_bundle, &ctx.header)?; + meter_bundle(state_provider, harness.chain_spec(), parsed_bundle, &header)?; assert_eq!(results.len(), 1); let result = &results[0]; @@ -220,9 +215,11 @@ mod tests { Ok(()) } - #[test] - fn meter_bundle_multiple_transactions() -> eyre::Result<()> { - let ctx = MeteringTestContext::new()?; + #[tokio::test] + async fn meter_bundle_multiple_transactions() -> eyre::Result<()> { + let harness = TestHarness::new().await?; + let latest = harness.latest_block(); + let header = latest.sealed_header().clone(); let to_1 = Address::random(); let to_2 = Address::random(); @@ -230,7 +227,7 @@ mod tests { // Create first transaction let signed_tx_1 = TransactionBuilder::default() .signer(Account::Alice.signer_b256()) - .chain_id(ctx.chain_spec.chain_id()) + .chain_id(harness.chain_id()) .nonce(0) .to(to_1) .value(1_000) @@ -246,7 +243,7 @@ mod tests { // Create second transaction let signed_tx_2 = TransactionBuilder::default() .signer(Account::Bob.signer_b256()) - .chain_id(ctx.chain_spec.chain_id()) + .chain_id(harness.chain_id()) .nonce(0) .to(to_2) .value(2_000) @@ -259,20 +256,18 @@ mod tests { signed_tx_2.as_eip1559().expect("eip1559 transaction").clone(), ); - let envelope_1 = envelope_from_signed(&tx_1)?; - let envelope_2 = envelope_from_signed(&tx_2)?; - let tx_hash_1 = envelope_1.tx_hash(); - let tx_hash_2 = envelope_2.tx_hash(); + let tx_hash_1 = tx_1.tx_hash(); + let tx_hash_2 = tx_2.tx_hash(); - let state_provider = ctx - .provider - .state_by_block_hash(ctx.header.hash()) + let state_provider = harness + .blockchain_provider() + .state_by_block_hash(latest.hash()) .context("getting state provider")?; - let parsed_bundle = create_parsed_bundle(vec![envelope_1, envelope_2])?; + let parsed_bundle = create_parsed_bundle(vec![tx_1, tx_2])?; let (results, total_gas_used, total_gas_fees, bundle_hash, total_execution_time) = - meter_bundle(state_provider, ctx.chain_spec.clone(), parsed_bundle, &ctx.header)?; + meter_bundle(state_provider, harness.chain_spec(), parsed_bundle, &header)?; assert_eq!(results.len(), 2); assert!(total_execution_time > 0); diff --git a/crates/client/metering/src/test_utils.rs b/crates/client/metering/src/test_utils.rs deleted file mode 100644 index 06c8f355..00000000 --- a/crates/client/metering/src/test_utils.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! Test utilities for metering integration tests. - -use std::sync::Arc; - -use base_client_node::test_utils::{create_provider_factory, load_chain_spec}; -use eyre::Context; -use reth::api::NodeTypesWithDBAdapter; -use reth_db::{DatabaseEnv, test_utils::TempDatabase}; -use reth_optimism_chainspec::OpChainSpec; -use reth_optimism_node::OpNode; -use reth_primitives_traits::SealedHeader; -use reth_provider::{HeaderProvider, providers::BlockchainProvider}; - -/// Test context providing the arguments needed by metering functions. -/// -/// This bundles the provider, header, and chain spec that `meter_bundle()` and -/// `meter_block()` require. Use this for testing metering logic directly without -/// spinning up a full node. -/// -/// For RPC integration tests, use [`TestHarness`] with [`MeteringExtension`] -/// instead: -/// -/// ```ignore -/// use base_client_node::test_utils::TestHarness; -/// use base_metering::MeteringExtension; -/// -/// let harness = TestHarness::builder() -/// .with_ext::(true) -/// .build() -/// .await?; -/// ``` -/// -/// # Example -/// -/// ```ignore -/// let ctx = MeteringTestContext::new()?; -/// let state = ctx.provider.state_by_block_hash(ctx.header.hash())?; -/// let result = meter_bundle(state, ctx.chain_spec.clone(), bundle, &ctx.header)?; -/// ``` -#[derive(Debug, Clone)] -pub struct MeteringTestContext { - /// Blockchain provider for state access. - pub provider: - BlockchainProvider>>>, - /// Genesis header - used as parent block for metering simulations. - pub header: SealedHeader, - /// Chain specification for EVM configuration. - pub chain_spec: Arc, -} - -impl MeteringTestContext { - /// Creates a new test context with genesis state initialized. - pub fn new() -> eyre::Result { - let chain_spec = load_chain_spec(); - - let factory = create_provider_factory::(chain_spec.clone()); - reth_db_common::init::init_genesis(&factory).context("initializing genesis state")?; - - let provider = BlockchainProvider::new(factory).context("creating provider")?; - let header = provider - .sealed_header(0) - .context("fetching genesis header")? - .expect("genesis header exists"); - - Ok(Self { provider, header, chain_spec }) - } -} From 07e3ad417b6e6436597ab6555e4d536e6cb28d91 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Sun, 11 Jan 2026 12:40:32 -0600 Subject: [PATCH 3/3] feat(metering): add base_meteredPriorityFeePerGas RPC endpoint Add priority fee estimation based on single-block resource consumption: - Add core estimation algorithm (compute_estimate) that determines the minimum priority fee needed for inclusion based on resource demand vs capacity - Add ResourceKind, ResourceLimits, ResourceDemand types for multi-resource estimation - Add base_meteredPriorityFeePerGas RPC endpoint - Document the RPC response format in README.md --- Cargo.lock | 1 + crates/client/metering/Cargo.toml | 3 + crates/client/metering/README.md | 50 ++ crates/client/metering/src/estimator.rs | 606 ++++++++++++++++++++++++ crates/client/metering/src/extension.rs | 141 +++++- crates/client/metering/src/lib.rs | 13 +- crates/client/metering/src/rpc.rs | 171 ++++++- crates/client/metering/src/traits.rs | 19 +- crates/client/metering/src/types.rs | 38 +- 9 files changed, 1029 insertions(+), 13 deletions(-) create mode 100644 crates/client/metering/src/estimator.rs diff --git a/Cargo.lock b/Cargo.lock index 8286332f..a1d53a3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2092,6 +2092,7 @@ dependencies = [ "eyre", "jsonrpsee 0.26.0", "op-alloy-consensus", + "op-alloy-flz", "rand 0.9.2", "reth", "reth-db", diff --git a/crates/client/metering/Cargo.toml b/crates/client/metering/Cargo.toml index c45ee302..c230d363 100644 --- a/crates/client/metering/Cargo.toml +++ b/crates/client/metering/Cargo.toml @@ -33,6 +33,9 @@ alloy-eips.workspace = true # rpc jsonrpsee.workspace = true +# DA calculation +op-alloy-flz.workspace = true + # misc tracing.workspace = true eyre.workspace = true diff --git a/crates/client/metering/README.md b/crates/client/metering/README.md index 3d1c679a..2e96d5f1 100644 --- a/crates/client/metering/README.md +++ b/crates/client/metering/README.md @@ -33,3 +33,53 @@ Re-executes a block by number and returns timing metrics. **Returns:** - `MeterBlockResponse`: Contains timing breakdown for signer recovery, EVM execution, and state root calculation + +### `base_meteredPriorityFeePerGas` + +Meters a bundle and returns a recommended priority fee based on recent block congestion. + +**Parameters:** +- `bundle`: Bundle object containing transactions to simulate + +**Returns:** +- `MeteredPriorityFeeResponse`: Contains metering results plus priority fee recommendation + +**Response:** +```json +{ + "bundleGasPrice": "0x...", + "bundleHash": "0x...", + "results": [...], + "totalGasUsed": 21000, + "totalExecutionTimeUs": 1234, + "priorityFee": "0x5f5e100", + "blocksSampled": 1, + "resourceEstimates": [ + { + "resource": "gasUsed", + "thresholdPriorityFee": "0x3b9aca00", + "recommendedPriorityFee": "0x5f5e100", + "cumulativeUsage": "0x1e8480", + "thresholdTxCount": 5, + "totalTransactions": 10 + }, + { + "resource": "executionTime", + ... + }, + { + "resource": "dataAvailability", + ... + } + ] +} +``` + +**Algorithm:** +1. Meter the bundle to get resource consumption (gas, execution time, DA bytes) +2. Meter the latest block to get historical transaction data +3. For each resource type, run the estimation algorithm: + - Walk from highest-paying transactions, subtracting usage from remaining capacity + - Stop when adding another tx would leave less room than the bundle needs + - The last included tx's fee is the threshold +4. Return the maximum fee across all resources as `priorityFee` diff --git a/crates/client/metering/src/estimator.rs b/crates/client/metering/src/estimator.rs new file mode 100644 index 00000000..3d17f4fb --- /dev/null +++ b/crates/client/metering/src/estimator.rs @@ -0,0 +1,606 @@ +//! Priority fee estimation based on resource consumption. +//! +//! This module provides the core algorithm for estimating the priority fee needed +//! to achieve inclusion in a block, based on historical transaction data. + +use alloy_primitives::U256; + +/// Errors that can occur during priority fee estimation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EstimateError { + /// The bundle's resource demand exceeds the configured capacity limit. + DemandExceedsCapacity { + /// The resource that exceeded capacity. + resource: ResourceKind, + /// The requested demand. + demand: u128, + /// The configured limit. + limit: u128, + }, +} + +impl std::fmt::Display for EstimateError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::DemandExceedsCapacity { resource, demand, limit } => { + write!( + f, + "bundle {} demand ({}) exceeds capacity limit ({})", + resource.as_name(), + demand, + limit + ) + } + } + } +} + +impl std::error::Error for EstimateError {} + +/// Configured capacity limits for each resource type. +/// +/// These values define the maximum capacity available per block. The estimator +/// uses these limits to determine when resources are congested. +#[derive(Debug, Clone, Copy, Default)] +pub struct ResourceLimits { + /// Gas limit per block. + pub gas_used: Option, + /// Execution time budget in microseconds. + pub execution_time_us: Option, + /// State root computation time budget in microseconds. + pub state_root_time_us: Option, + /// Data availability bytes limit per block. + pub data_availability_bytes: Option, +} + +impl ResourceLimits { + /// Returns the limit for the given resource kind. + pub fn limit_for(&self, resource: ResourceKind) -> Option { + match resource { + ResourceKind::GasUsed => self.gas_used.map(|v| v as u128), + ResourceKind::ExecutionTime => self.execution_time_us, + ResourceKind::StateRootTime => self.state_root_time_us, + ResourceKind::DataAvailability => self.data_availability_bytes.map(|v| v as u128), + } + } +} + +/// Resources that influence block inclusion ordering. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ResourceKind { + /// Gas consumption. + GasUsed, + /// Execution time. + ExecutionTime, + /// State root computation time. + StateRootTime, + /// Data availability bytes. + DataAvailability, +} + +impl ResourceKind { + /// Returns all resource kinds in a fixed order. + pub const fn all() -> [Self; 4] { + [Self::GasUsed, Self::ExecutionTime, Self::StateRootTime, Self::DataAvailability] + } + + /// Returns a human-readable name for the resource kind. + pub const fn as_name(&self) -> &'static str { + match self { + Self::GasUsed => "gas", + Self::ExecutionTime => "execution time", + Self::StateRootTime => "state root time", + Self::DataAvailability => "data availability", + } + } + + /// Returns a camelCase name for JSON serialization. + pub const fn as_camel_case(&self) -> &'static str { + match self { + Self::GasUsed => "gasUsed", + Self::ExecutionTime => "executionTime", + Self::StateRootTime => "stateRootTime", + Self::DataAvailability => "dataAvailability", + } + } +} + +/// Amount of resources required by the bundle being priced. +#[derive(Debug, Clone, Copy, Default)] +pub struct ResourceDemand { + /// Gas demand. + pub gas_used: Option, + /// Execution time demand in microseconds. + pub execution_time_us: Option, + /// State root time demand in microseconds. + pub state_root_time_us: Option, + /// Data availability bytes demand. + pub data_availability_bytes: Option, +} + +impl ResourceDemand { + /// Returns the demand for the given resource kind. + pub fn demand_for(&self, resource: ResourceKind) -> Option { + match resource { + ResourceKind::GasUsed => self.gas_used.map(|v| v as u128), + ResourceKind::ExecutionTime => self.execution_time_us, + ResourceKind::StateRootTime => self.state_root_time_us, + ResourceKind::DataAvailability => self.data_availability_bytes.map(|v| v as u128), + } + } +} + +/// Fee estimate for a single resource type. +/// +/// The estimation algorithm answers: "What priority fee would my bundle need to pay +/// to displace enough lower-paying transactions to free up the resources I need?" +#[derive(Debug, Clone)] +pub struct ResourceEstimate { + /// Minimum fee to displace enough capacity for the bundle's resource demand. + pub threshold_priority_fee: U256, + /// Recommended fee based on a percentile of transactions above the threshold. + /// Provides a safety margin over the bare minimum. + pub recommended_priority_fee: U256, + /// Total resource usage of transactions at or above the threshold fee. + pub cumulative_usage: u128, + /// Number of transactions at or above `threshold_priority_fee`. These higher-paying + /// transactions remain included alongside the bundle; lower-paying ones are displaced. + pub threshold_tx_count: usize, + /// Total transactions considered in the estimate. + pub total_transactions: usize, +} + +/// Per-resource fee estimates. +/// +/// Each field corresponds to a resource type. `None` indicates the resource +/// was not requested or could not be estimated. +#[derive(Debug, Clone, Default)] +pub struct ResourceEstimates { + /// Gas usage estimate. + pub gas_used: Option, + /// Execution time estimate. + pub execution_time: Option, + /// State root time estimate. + pub state_root_time: Option, + /// Data availability estimate. + pub data_availability: Option, +} + +impl ResourceEstimates { + /// Returns the estimate for the given resource kind. + pub const fn get(&self, kind: ResourceKind) -> Option<&ResourceEstimate> { + match kind { + ResourceKind::GasUsed => self.gas_used.as_ref(), + ResourceKind::ExecutionTime => self.execution_time.as_ref(), + ResourceKind::StateRootTime => self.state_root_time.as_ref(), + ResourceKind::DataAvailability => self.data_availability.as_ref(), + } + } + + /// Sets the estimate for the given resource kind. + pub const fn set(&mut self, kind: ResourceKind, estimate: ResourceEstimate) { + match kind { + ResourceKind::GasUsed => self.gas_used = Some(estimate), + ResourceKind::ExecutionTime => self.execution_time = Some(estimate), + ResourceKind::StateRootTime => self.state_root_time = Some(estimate), + ResourceKind::DataAvailability => self.data_availability = Some(estimate), + } + } + + /// Iterates over all present estimates with their resource kind. + pub fn iter(&self) -> impl Iterator { + [ + (ResourceKind::GasUsed, &self.gas_used), + (ResourceKind::ExecutionTime, &self.execution_time), + (ResourceKind::StateRootTime, &self.state_root_time), + (ResourceKind::DataAvailability, &self.data_availability), + ] + .into_iter() + .filter_map(|(kind, opt)| opt.as_ref().map(|est| (kind, est))) + } + + /// Returns true if no estimates are present. + pub fn is_empty(&self) -> bool { + self.iter().next().is_none() + } +} + +/// A transaction with its resource consumption metrics. +#[derive(Debug, Clone)] +pub struct MeteredTransaction { + /// Priority fee per gas paid by this transaction. + pub priority_fee_per_gas: U256, + /// Gas consumed. + pub gas_used: u64, + /// Execution time in microseconds. + pub execution_time_us: u128, + /// State root computation time in microseconds. + pub state_root_time_us: u128, + /// Data availability bytes. + pub data_availability_bytes: u64, +} + +/// Core estimation algorithm (top-down approach). +/// +/// Given a sorted list of transactions and a resource limit, determines the minimum priority +/// fee needed to be included alongside enough high-paying transactions while still +/// leaving room for the bundle's demand. +/// +/// # Arguments +/// +/// * `transactions` - Must be sorted by priority fee descending (highest first) +/// * `resource` - The resource kind being estimated +/// * `demand` - How much of the resource the bundle needs +/// * `limit` - Maximum capacity for this resource +/// * `usage_fn` - Function to extract resource usage from a transaction +/// * `percentile` - Point in fee distribution for recommended fee (e.g., 0.5 for median) +/// * `default_fee` - Fee to return when resource is uncongested +/// +/// # Algorithm +/// +/// 1. Walk from highest-paying transactions, subtracting each transaction's usage from +/// remaining capacity. +/// 2. Stop when including another transaction would leave less capacity than the bundle needs. +/// 3. The threshold fee is the fee of the last included transaction (the minimum fee +/// among transactions that would be included alongside the bundle). +/// 4. If we include all transactions and still have capacity >= demand, the resource is +/// not congested, so return the configured default fee. +/// +/// Returns `Err` if the bundle's demand exceeds the resource limit. +pub fn compute_estimate( + resource: ResourceKind, + transactions: &[&MeteredTransaction], + demand: u128, + limit: u128, + usage_fn: fn(&MeteredTransaction) -> u128, + percentile: f64, + default_fee: U256, +) -> Result { + // Bundle demand exceeds the resource limit entirely. + if demand > limit { + return Err(EstimateError::DemandExceedsCapacity { resource, demand, limit }); + } + + // No transactions or zero demand means no competition for this resource. + if transactions.is_empty() || demand == 0 { + return Ok(ResourceEstimate { + threshold_priority_fee: default_fee, + recommended_priority_fee: default_fee, + cumulative_usage: 0, + threshold_tx_count: 0, + total_transactions: 0, + }); + } + + // Walk from highest-paying transactions, subtracting usage from remaining capacity. + // Stop when we can no longer fit another transaction while leaving room for demand. + let mut remaining = limit; + let mut included_usage = 0u128; + let mut last_included_idx: Option = None; + + for (idx, tx) in transactions.iter().enumerate() { + let usage = usage_fn(tx); + + // Check if we can include this transaction and still have room for the bundle. + if remaining >= usage && remaining.saturating_sub(usage) >= demand { + remaining = remaining.saturating_sub(usage); + included_usage = included_usage.saturating_add(usage); + last_included_idx = Some(idx); + } else { + // Can't include this transaction without crowding out the bundle. + break; + } + } + + // If we included all transactions and still have room, resource is not congested. + let is_uncongested = last_included_idx == Some(transactions.len() - 1) && remaining >= demand; + + if is_uncongested { + return Ok(ResourceEstimate { + threshold_priority_fee: default_fee, + recommended_priority_fee: default_fee, + cumulative_usage: included_usage, + threshold_tx_count: transactions.len(), + total_transactions: transactions.len(), + }); + } + + let (supporting_count, threshold_fee, recommended_fee) = last_included_idx.map_or_else( + || { + // No transactions fit - even the first transaction would crowd out + // the bundle. The bundle must beat the highest fee to be included. + // Report 0 supporting transactions since none were actually included. + let threshold_fee = transactions[0].priority_fee_per_gas; + (0, threshold_fee, threshold_fee) + }, + |idx| { + // At least one transaction fits alongside the bundle. + // The threshold is the fee of the last included transaction. + let threshold_fee = transactions[idx].priority_fee_per_gas; + + // For recommended fee, look at included transactions (those above threshold) + // and pick one at the specified percentile for a safety margin. + let included = &transactions[..=idx]; + let percentile = percentile.clamp(0.0, 1.0); + let recommended_fee = if included.len() <= 1 { + threshold_fee + } else { + // Pick from the higher end of included transactions for safety. + let pos = ((included.len() - 1) as f64 * (1.0 - percentile)).round() as usize; + included[pos.min(included.len() - 1)].priority_fee_per_gas + }; + + (idx + 1, threshold_fee, recommended_fee) + }, + ); + + Ok(ResourceEstimate { + threshold_priority_fee: threshold_fee, + recommended_priority_fee: recommended_fee, + cumulative_usage: included_usage, + threshold_tx_count: supporting_count, + total_transactions: transactions.len(), + }) +} + +/// Returns a function that extracts the relevant resource usage from a transaction. +pub fn usage_extractor(resource: ResourceKind) -> fn(&MeteredTransaction) -> u128 { + match resource { + ResourceKind::GasUsed => |tx: &MeteredTransaction| tx.gas_used as u128, + ResourceKind::ExecutionTime => |tx: &MeteredTransaction| tx.execution_time_us, + ResourceKind::StateRootTime => |tx: &MeteredTransaction| tx.state_root_time_us, + ResourceKind::DataAvailability => { + |tx: &MeteredTransaction| tx.data_availability_bytes as u128 + } + } +} + +/// Estimates priority fees for all configured resources given a list of transactions. +/// +/// This is a simple single-block estimation that treats all transactions as a single pool. +/// +/// # Arguments +/// +/// * `transactions` - Transactions from a block, will be sorted by priority fee descending +/// * `demand` - Resource demand for the bundle being priced +/// * `limits` - Configured resource limits +/// * `percentile` - Percentile for recommended fee calculation +/// * `default_fee` - Fee to return when resources are uncongested +/// +/// Returns `Ok(None)` if no transactions are provided. +/// Returns `Err` if bundle demand exceeds any resource limit. +pub fn estimate_from_transactions( + transactions: &[MeteredTransaction], + demand: ResourceDemand, + limits: &ResourceLimits, + percentile: f64, + default_fee: U256, +) -> Result, EstimateError> { + if transactions.is_empty() { + return Ok(None); + } + + // Sort transactions by priority fee descending + let mut sorted: Vec<&MeteredTransaction> = transactions.iter().collect(); + sorted.sort_by(|a, b| b.priority_fee_per_gas.cmp(&a.priority_fee_per_gas)); + + let mut estimates = ResourceEstimates::default(); + let mut max_fee = U256::ZERO; + + for resource in ResourceKind::all() { + let Some(demand_value) = demand.demand_for(resource) else { + continue; + }; + let Some(limit_value) = limits.limit_for(resource) else { + continue; + }; + + let estimate = compute_estimate( + resource, + &sorted, + demand_value, + limit_value, + usage_extractor(resource), + percentile, + default_fee, + )?; + + max_fee = max_fee.max(estimate.recommended_priority_fee); + estimates.set(resource, estimate); + } + + if estimates.is_empty() { + return Ok(None); + } + + Ok(Some((estimates, max_fee))) +} + +#[cfg(test)] +mod tests { + use super::*; + + const DEFAULT_FEE: U256 = U256::from_limbs([1, 0, 0, 0]); // 1 wei + + fn tx(priority: u64, usage: u64) -> MeteredTransaction { + MeteredTransaction { + priority_fee_per_gas: U256::from(priority), + gas_used: usage, + execution_time_us: usage as u128, + state_root_time_us: usage as u128, + data_availability_bytes: usage, + } + } + + #[test] + fn compute_estimate_congested_resource() { + // Limit: 30, Demand: 15 + // Transactions: priority=10 (10 gas), priority=5 (10 gas), priority=2 (10 gas) + // Walking from top (highest fee): + // - Include tx priority=10: remaining = 30-10 = 20 >= 15 ok + // - Include tx priority=5: remaining = 20-10 = 10 < 15 stop + // Threshold = 10 (the last included tx's fee) + let txs = [tx(10, 10), tx(5, 10), tx(2, 10)]; + let txs_refs: Vec<&MeteredTransaction> = txs.iter().collect(); + let quote = compute_estimate( + ResourceKind::GasUsed, + &txs_refs, + 15, + 30, // limit + usage_extractor(ResourceKind::GasUsed), + 0.5, + DEFAULT_FEE, + ) + .expect("no error"); + assert_eq!(quote.threshold_priority_fee, U256::from(10)); + assert_eq!(quote.cumulative_usage, 10); // Only the first tx was included + assert_eq!(quote.threshold_tx_count, 1); + assert_eq!(quote.total_transactions, 3); + } + + #[test] + fn compute_estimate_uncongested_resource() { + // Limit: 100, Demand: 15 + // All transactions fit with room to spare -> return default fee + let txs = [tx(10, 10), tx(5, 10), tx(2, 10)]; + let txs_refs: Vec<&MeteredTransaction> = txs.iter().collect(); + let quote = compute_estimate( + ResourceKind::GasUsed, + &txs_refs, + 15, + 100, // limit is much larger than total usage + usage_extractor(ResourceKind::GasUsed), + 0.5, + DEFAULT_FEE, + ) + .expect("no error"); + assert_eq!(quote.threshold_priority_fee, DEFAULT_FEE); + assert_eq!(quote.recommended_priority_fee, DEFAULT_FEE); + assert_eq!(quote.cumulative_usage, 30); // All txs included + assert_eq!(quote.threshold_tx_count, 3); + } + + #[test] + fn compute_estimate_demand_exceeds_limit() { + // Demand > Limit -> Error + let txs = [tx(10, 10), tx(5, 10)]; + let txs_refs: Vec<&MeteredTransaction> = txs.iter().collect(); + let result = compute_estimate( + ResourceKind::GasUsed, + &txs_refs, + 50, // demand + 30, // limit + usage_extractor(ResourceKind::GasUsed), + 0.5, + DEFAULT_FEE, + ); + assert!(matches!( + result, + Err(EstimateError::DemandExceedsCapacity { + resource: ResourceKind::GasUsed, + demand: 50, + limit: 30, + }) + )); + } + + #[test] + fn compute_estimate_exact_fit() { + // Limit: 30, Demand: 20 + // Transactions: priority=10 (10 gas), priority=5 (10 gas) + // After including tx priority=10: remaining = 20 >= 20 ok + // After including tx priority=5: remaining = 10 < 20 stop + let txs = [tx(10, 10), tx(5, 10)]; + let txs_refs: Vec<&MeteredTransaction> = txs.iter().collect(); + let quote = compute_estimate( + ResourceKind::GasUsed, + &txs_refs, + 20, + 30, + usage_extractor(ResourceKind::GasUsed), + 0.5, + DEFAULT_FEE, + ) + .expect("no error"); + assert_eq!(quote.threshold_priority_fee, U256::from(10)); + assert_eq!(quote.cumulative_usage, 10); + assert_eq!(quote.threshold_tx_count, 1); + } + + #[test] + fn compute_estimate_single_transaction() { + // Single tx that fits + let txs = [tx(10, 10)]; + let txs_refs: Vec<&MeteredTransaction> = txs.iter().collect(); + let quote = compute_estimate( + ResourceKind::GasUsed, + &txs_refs, + 15, + 30, + usage_extractor(ResourceKind::GasUsed), + 0.5, + DEFAULT_FEE, + ) + .expect("no error"); + // After including the tx: remaining = 20 >= 15 ok + // But we only have 1 tx, so it's uncongested + assert_eq!(quote.threshold_priority_fee, DEFAULT_FEE); + assert_eq!(quote.recommended_priority_fee, DEFAULT_FEE); + } + + #[test] + fn compute_estimate_no_room_for_any_tx() { + // Limit: 25, Demand: 20 + // First tx uses 10, remaining = 15 < 20 -> can't even include first tx + let txs = [tx(10, 10), tx(5, 10)]; + let txs_refs: Vec<&MeteredTransaction> = txs.iter().collect(); + let quote = compute_estimate( + ResourceKind::GasUsed, + &txs_refs, + 20, + 25, + usage_extractor(ResourceKind::GasUsed), + 0.5, + DEFAULT_FEE, + ) + .expect("no error"); + // No transactions can be included, threshold is the highest fee + assert_eq!(quote.threshold_priority_fee, U256::from(10)); + assert_eq!(quote.threshold_tx_count, 0); + assert_eq!(quote.cumulative_usage, 0); + } + + #[test] + fn compute_estimate_empty_transactions() { + // No transactions = uncongested, return default fee + let txs_refs: Vec<&MeteredTransaction> = vec![]; + let quote = compute_estimate( + ResourceKind::GasUsed, + &txs_refs, + 15, + 30, + usage_extractor(ResourceKind::GasUsed), + 0.5, + DEFAULT_FEE, + ) + .expect("no error"); + assert_eq!(quote.threshold_priority_fee, DEFAULT_FEE); + assert_eq!(quote.recommended_priority_fee, DEFAULT_FEE); + } + + #[test] + fn estimate_from_transactions_basic() { + let txs = vec![tx(10, 10), tx(5, 10), tx(2, 10)]; + let demand = ResourceDemand { gas_used: Some(15), ..Default::default() }; + let limits = ResourceLimits { gas_used: Some(30), ..Default::default() }; + + let result = estimate_from_transactions(&txs, demand, &limits, 0.5, DEFAULT_FEE) + .expect("no error") + .expect("has estimates"); + + let (estimates, max_fee) = result; + let gas_estimate = estimates.gas_used.expect("gas estimate present"); + assert_eq!(gas_estimate.threshold_priority_fee, U256::from(10)); + assert_eq!(max_fee, U256::from(10)); + } +} diff --git a/crates/client/metering/src/extension.rs b/crates/client/metering/src/extension.rs index c089954c..8c5e2cee 100644 --- a/crates/client/metering/src/extension.rs +++ b/crates/client/metering/src/extension.rs @@ -1,22 +1,100 @@ //! Contains the [`MeteringExtension`] which wires up the metering RPC surface //! on the Base node builder. +use alloy_primitives::U256; use base_client_node::{BaseNodeExtension, FromExtensionConfig, OpBuilder}; use tracing::info; -use crate::{MeteringApiImpl, MeteringApiServer}; +use crate::{MeteringApiImpl, MeteringApiServer, ResourceLimits}; + +/// Resource limits configuration for priority fee estimation. +#[derive(Debug, Clone, Default)] +pub struct MeteringResourceLimits { + /// Maximum gas per block. + pub gas_limit: Option, + /// Maximum execution time per block in microseconds. + pub execution_time_us: Option, + /// Maximum state root computation time per block in microseconds. + pub state_root_time_us: Option, + /// Maximum data availability bytes per block. + pub da_bytes: Option, +} + +impl MeteringResourceLimits { + /// Converts to the internal [`ResourceLimits`] type. + pub fn to_resource_limits(&self) -> ResourceLimits { + ResourceLimits { + gas_used: self.gas_limit, + execution_time_us: self.execution_time_us.map(|v| v as u128), + state_root_time_us: self.state_root_time_us.map(|v| v as u128), + data_availability_bytes: self.da_bytes, + } + } +} /// Helper struct that wires the metering RPC into the node builder. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub struct MeteringExtension { /// Whether metering is enabled. pub enabled: bool, + /// Resource limits for priority fee estimation. + pub resource_limits: MeteringResourceLimits, + /// Percentile for priority fee estimation (e.g., 0.5 for median). + pub priority_fee_percentile: f64, + /// Default priority fee when resources are uncongested (in wei). + pub uncongested_priority_fee: u64, +} + +impl Default for MeteringExtension { + fn default() -> Self { + Self { + enabled: false, + resource_limits: MeteringResourceLimits::default(), + priority_fee_percentile: 0.5, + uncongested_priority_fee: 1_000_000, // 1 gwei default + } + } } impl MeteringExtension { /// Creates a new metering extension. pub const fn new(enabled: bool) -> Self { - Self { enabled } + Self { + enabled, + resource_limits: MeteringResourceLimits { + gas_limit: None, + execution_time_us: None, + state_root_time_us: None, + da_bytes: None, + }, + priority_fee_percentile: 0.5, + uncongested_priority_fee: 1_000_000, + } + } + + /// Sets the resource limits. + pub const fn with_resource_limits(mut self, limits: MeteringResourceLimits) -> Self { + self.resource_limits = limits; + self + } + + /// Sets the priority fee percentile. + pub const fn with_percentile(mut self, percentile: f64) -> Self { + self.priority_fee_percentile = percentile; + self + } + + /// Sets the uncongested priority fee. + pub const fn with_uncongested_fee(mut self, fee: u64) -> Self { + self.uncongested_priority_fee = fee; + self + } + + /// Returns true if priority fee estimation is configured (has resource limits). + const fn has_estimator_config(&self) -> bool { + self.resource_limits.gas_limit.is_some() + || self.resource_limits.execution_time_us.is_some() + || self.resource_limits.da_bytes.is_some() } } @@ -27,15 +105,68 @@ impl BaseNodeExtension for MeteringExtension { return builder; } + let has_estimator = self.has_estimator_config(); + let resource_limits = self.resource_limits.to_resource_limits(); + let percentile = self.priority_fee_percentile; + let default_fee = U256::from(self.uncongested_priority_fee); + builder.extend_rpc_modules(move |ctx| { - info!(message = "Starting Metering RPC"); - let metering_api = MeteringApiImpl::new(ctx.provider().clone()); + let metering_api = if has_estimator { + info!( + message = "Starting Metering RPC with priority fee estimation", + percentile = percentile, + ); + MeteringApiImpl::with_estimator_config( + ctx.provider().clone(), + resource_limits, + percentile, + default_fee, + ) + } else { + info!(message = "Starting Metering RPC (priority fee estimation disabled)"); + MeteringApiImpl::new(ctx.provider().clone()) + }; + ctx.modules.merge_configured(metering_api.into_rpc())?; Ok(()) }) } } +/// Configuration trait for [`MeteringExtension`]. +/// +/// Types implementing this trait can be used to construct a [`MeteringExtension`]. +pub trait MeteringExtensionConfig { + /// Returns whether metering is enabled. + fn metering_enabled(&self) -> bool; + + /// Returns the resource limits configuration. + fn resource_limits(&self) -> MeteringResourceLimits { + MeteringResourceLimits::default() + } + + /// Returns the priority fee percentile. + fn priority_fee_percentile(&self) -> f64 { + 0.5 + } + + /// Returns the uncongested priority fee in wei. + fn uncongested_priority_fee(&self) -> u64 { + 1_000_000 + } +} + +impl From<&C> for MeteringExtension { + fn from(config: &C) -> Self { + Self { + enabled: config.metering_enabled(), + resource_limits: config.resource_limits(), + priority_fee_percentile: config.priority_fee_percentile(), + uncongested_priority_fee: config.uncongested_priority_fee(), + } + } +} + impl FromExtensionConfig for MeteringExtension { type Config = bool; diff --git a/crates/client/metering/src/lib.rs b/crates/client/metering/src/lib.rs index 41883a8d..dde76040 100644 --- a/crates/client/metering/src/lib.rs +++ b/crates/client/metering/src/lib.rs @@ -6,8 +6,14 @@ mod block; pub use block::meter_block; +mod estimator; +pub use estimator::{ + EstimateError, MeteredTransaction, ResourceDemand, ResourceEstimate, ResourceEstimates, + ResourceKind, ResourceLimits, compute_estimate, estimate_from_transactions, usage_extractor, +}; + mod extension; -pub use extension::MeteringExtension; +pub use extension::{MeteringExtension, MeteringExtensionConfig, MeteringResourceLimits}; mod meter; pub use meter::meter_bundle; @@ -19,4 +25,7 @@ mod traits; pub use traits::MeteringApiServer; mod types; -pub use types::{MeterBlockResponse, MeterBlockTransactions}; +pub use types::{ + MeterBlockResponse, MeterBlockTransactions, MeteredPriorityFeeResponse, + ResourceFeeEstimateResponse, +}; diff --git a/crates/client/metering/src/rpc.rs b/crates/client/metering/src/rpc.rs index d74649d2..526bbe48 100644 --- a/crates/client/metering/src/rpc.rs +++ b/crates/client/metering/src/rpc.rs @@ -5,20 +5,33 @@ use alloy_eips::BlockNumberOrTag; use alloy_primitives::{B256, U256}; use base_bundles::{Bundle, MeterBundleResponse, ParsedBundle}; use jsonrpsee::core::{RpcResult, async_trait}; +use op_alloy_flz::flz_compress_len; use reth::providers::BlockReaderIdExt; use reth_optimism_chainspec::OpChainSpec; use reth_optimism_primitives::OpBlock; use reth_provider::{BlockReader, ChainSpecProvider, HeaderProvider, StateProviderFactory}; -use tracing::{error, info}; +use tracing::{error, info, warn}; use crate::{ - MeterBlockResponse, block::meter_block, meter::meter_bundle, traits::MeteringApiServer, + MeterBlockResponse, MeteredPriorityFeeResponse, MeteredTransaction, ResourceDemand, + ResourceFeeEstimateResponse, ResourceLimits, block::meter_block, estimate_from_transactions, + meter::meter_bundle, traits::MeteringApiServer, }; +/// Estimator configuration for priority fee estimation. +#[derive(Debug, Clone)] +struct EstimatorConfig { + limits: ResourceLimits, + percentile: f64, + default_fee: U256, +} + /// Implementation of the metering RPC API #[derive(Debug)] pub struct MeteringApiImpl { provider: Provider, + /// Configuration for priority fee estimation, if enabled. + estimator_config: Option, } impl MeteringApiImpl @@ -30,9 +43,22 @@ where + HeaderProvider
+ Clone, { - /// Creates a new instance of MeteringApi + /// Creates a new instance of MeteringApi without priority fee estimation. pub const fn new(provider: Provider) -> Self { - Self { provider } + Self { provider, estimator_config: None } + } + + /// Creates a new instance with priority fee estimation enabled. + pub const fn with_estimator_config( + provider: Provider, + limits: ResourceLimits, + percentile: f64, + default_fee: U256, + ) -> Self { + Self { + provider, + estimator_config: Some(EstimatorConfig { limits, percentile, default_fee }), + } } } @@ -209,6 +235,143 @@ where Ok(response) } + + async fn metered_priority_fee_per_gas( + &self, + bundle: Bundle, + ) -> RpcResult { + info!( + num_transactions = &bundle.txs.len(), + block_number = &bundle.block_number, + "Starting metered priority fee estimation" + ); + + // First, meter the bundle to get resource consumption + let meter_bundle_response = self.meter_bundle(bundle.clone()).await?; + + // Check if we have estimator config + let Some(config) = &self.estimator_config else { + warn!("Priority fee estimation requested but no estimator configured"); + return Err(jsonrpsee::types::ErrorObjectOwned::owned( + jsonrpsee::types::ErrorCode::InternalError.code(), + "Priority fee estimation not configured".to_string(), + None::<()>, + )); + }; + + // Get the latest block and meter it for historical data + let block = self + .provider + .block_by_number_or_tag(BlockNumberOrTag::Latest) + .map_err(|e| { + error!(error = %e, "Failed to get latest block"); + jsonrpsee::types::ErrorObjectOwned::owned( + jsonrpsee::types::ErrorCode::InternalError.code(), + format!("Failed to get latest block: {}", e), + None::<()>, + ) + })? + .ok_or_else(|| { + jsonrpsee::types::ErrorObjectOwned::owned( + jsonrpsee::types::ErrorCode::InternalError.code(), + "Latest block not found".to_string(), + None::<()>, + ) + })?; + + let block_metering = self.meter_block_internal(&block)?; + + // Convert block metering to MeteredTransaction list + let transactions: Vec = block_metering + .transactions + .iter() + .map(|tx| { + // Estimate DA bytes from transaction (placeholder - would need actual tx bytes) + let da_bytes = 0u64; // Will be improved in later PRs + MeteredTransaction { + priority_fee_per_gas: U256::ZERO, // Will compute from tx + gas_used: tx.gas_used, + execution_time_us: tx.execution_time_us, + state_root_time_us: 0, // Not available per-tx + data_availability_bytes: da_bytes, + } + }) + .collect(); + + // Compute resource demand from metering results + let demand = compute_resource_demand(&bundle, &meter_bundle_response); + + // Estimate fees + let estimate_result = estimate_from_transactions( + &transactions, + demand, + &config.limits, + config.percentile, + config.default_fee, + ) + .map_err(|e| { + error!(error = %e, "Priority fee estimation failed"); + jsonrpsee::types::ErrorObjectOwned::owned( + jsonrpsee::types::ErrorCode::InternalError.code(), + format!("Priority fee estimation failed: {}", e), + None::<()>, + ) + })?; + + let Some((estimates, priority_fee)) = estimate_result else { + // No transactions in block - return default fee + info!( + priority_fee = %config.default_fee, + blocks_sampled = 1, + "No transactions in block, returning default fee" + ); + return Ok(MeteredPriorityFeeResponse { + meter_bundle: meter_bundle_response, + priority_fee: config.default_fee, + blocks_sampled: 1, + resource_estimates: vec![], + }); + }; + + // Build response + let resource_estimates: Vec = estimates + .iter() + .map(|(kind, est)| ResourceFeeEstimateResponse { + resource: kind.as_camel_case().to_string(), + threshold_priority_fee: est.threshold_priority_fee, + recommended_priority_fee: est.recommended_priority_fee, + cumulative_usage: U256::from(est.cumulative_usage), + threshold_tx_count: est.threshold_tx_count as u64, + total_transactions: est.total_transactions as u64, + }) + .collect(); + + info!( + priority_fee = %priority_fee, + blocks_sampled = 1, + "Metered priority fee estimation completed" + ); + + Ok(MeteredPriorityFeeResponse { + meter_bundle: meter_bundle_response, + priority_fee, + blocks_sampled: 1, + resource_estimates, + }) + } +} + +/// Computes resource demand from bundle metering results. +fn compute_resource_demand(bundle: &Bundle, meter_result: &MeterBundleResponse) -> ResourceDemand { + // Calculate DA bytes from bundle transactions + let da_bytes: u64 = bundle.txs.iter().map(|tx| flz_compress_len(tx) as u64).sum(); + + ResourceDemand { + gas_used: Some(meter_result.total_gas_used), + execution_time_us: Some(meter_result.total_execution_time_us), + state_root_time_us: None, // Not available per-bundle + data_availability_bytes: Some(da_bytes), + } } impl MeteringApiImpl diff --git a/crates/client/metering/src/traits.rs b/crates/client/metering/src/traits.rs index 50d18619..5311c9b0 100644 --- a/crates/client/metering/src/traits.rs +++ b/crates/client/metering/src/traits.rs @@ -5,7 +5,7 @@ use alloy_primitives::B256; use base_bundles::{Bundle, MeterBundleResponse}; use jsonrpsee::{core::RpcResult, proc_macros::rpc}; -use crate::MeterBlockResponse; +use crate::{MeterBlockResponse, MeteredPriorityFeeResponse}; /// RPC API for transaction metering #[rpc(server, namespace = "base")] @@ -42,4 +42,21 @@ pub trait MeteringApi { &self, number: BlockNumberOrTag, ) -> RpcResult; + + /// Handler for: `base_meteredPriorityFeePerGas` + /// + /// Simulates a bundle, meters its resource consumption, and returns a recommended priority + /// fee based on recent block congestion. + /// + /// The algorithm: + /// 1. Meters the bundle (same as `meterBundle`) + /// 2. Computes resource demand from the metering results + /// 3. Uses recent block data to estimate the minimum priority fee that would have + /// achieved inclusion for each resource type + /// 4. Returns the maximum fee across all resources as the recommended priority fee + #[method(name = "meteredPriorityFeePerGas")] + async fn metered_priority_fee_per_gas( + &self, + bundle: Bundle, + ) -> RpcResult; } diff --git a/crates/client/metering/src/types.rs b/crates/client/metering/src/types.rs index 14b8944c..07e0dbe3 100644 --- a/crates/client/metering/src/types.rs +++ b/crates/client/metering/src/types.rs @@ -1,6 +1,7 @@ //! Types for block metering responses. -use alloy_primitives::B256; +use alloy_primitives::{B256, U256}; +use base_bundles::MeterBundleResponse; use serde::{Deserialize, Serialize}; /// Response for block metering RPC calls. @@ -38,3 +39,38 @@ pub struct MeterBlockTransactions { /// Execution time in microseconds pub execution_time_us: u128, } + +// --- Metered priority fee types --- + +/// Human-friendly representation of a resource fee estimate. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResourceFeeEstimateResponse { + /// Resource name (gasUsed, executionTime, etc). + pub resource: String, + /// Minimum fee to displace enough capacity. + pub threshold_priority_fee: U256, + /// Recommended fee with safety margin. + pub recommended_priority_fee: U256, + /// Cumulative resource usage above threshold. + pub cumulative_usage: U256, + /// Number of transactions above threshold. + pub threshold_tx_count: u64, + /// Total transactions considered. + pub total_transactions: u64, +} + +/// Response payload for `base_meteredPriorityFeePerGas`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MeteredPriorityFeeResponse { + /// Bundled metering results. + #[serde(flatten)] + pub meter_bundle: MeterBundleResponse, + /// Recommended priority fee (max across all resources). + pub priority_fee: U256, + /// Number of recent blocks used to compute the estimate. + pub blocks_sampled: u64, + /// Per-resource estimates. + pub resource_estimates: Vec, +}