From fda04a51c89ba3065c65922c0923ef032adeefa4 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Tue, 28 Oct 2025 14:25:18 -0500 Subject: [PATCH 01/25] Include state root calculation time in metering Add state root calculation after transaction execution and measure its time independently. The state root calculation time is included in the total execution time and also tracked separately. --- crates/metering/src/lib.rs | 2 +- crates/metering/src/meter.rs | 63 ++++++++++++++++++++++++------ crates/metering/src/rpc.rs | 52 ++++++++++++------------ crates/metering/src/tests/meter.rs | 45 +++++++++++---------- 4 files changed, 102 insertions(+), 60 deletions(-) diff --git a/crates/metering/src/lib.rs b/crates/metering/src/lib.rs index 138a3c7d..91f7aee0 100644 --- a/crates/metering/src/lib.rs +++ b/crates/metering/src/lib.rs @@ -3,6 +3,6 @@ mod rpc; #[cfg(test)] mod tests; -pub use meter::meter_bundle; +pub use meter::{meter_bundle, MeterBundleOutput}; pub use rpc::{MeteringApiImpl, MeteringApiServer}; pub use tips_core::types::{Bundle, MeterBundleResponse, TransactionResult}; diff --git a/crates/metering/src/meter.rs b/crates/metering/src/meter.rs index 577b8ee3..93ec2626 100644 --- a/crates/metering/src/meter.rs +++ b/crates/metering/src/meter.rs @@ -7,30 +7,44 @@ use reth::revm::db::State; use reth_evm::{ConfigureEvm, execute::BlockBuilder}; use reth_optimism_chainspec::OpChainSpec; use reth_optimism_evm::{OpEvmConfig, OpNextBlockEnvAttributes}; +use reth_optimism_primitives::OpPrimitives; use reth_primitives_traits::SealedHeader; +use reth_provider::ExecutionOutcome; use tips_core::types::{BundleExtensions, BundleTxs, ParsedBundle}; use crate::TransactionResult; const BLOCK_TIME: u64 = 2; // 2 seconds per block +/// Output from metering a bundle of transactions +#[derive(Debug)] +pub struct MeterBundleOutput { + /// Transaction results with individual metrics + pub results: Vec, + /// Total gas used by all transactions + pub total_gas_used: u64, + /// Total gas fees paid by all transactions + pub total_gas_fees: U256, + /// Bundle hash + pub bundle_hash: B256, + /// Total execution time in microseconds (includes state root calculation) + pub total_execution_time_us: u128, + /// State root calculation time in microseconds + pub state_root_time_us: u128, +} + /// Simulates and meters a bundle of transactions /// -/// Takes a state provider, chain spec, decoded transactions, block header, and bundle metadata, -/// and executes transactions in sequence to measure gas usage and execution time. +/// Takes a state provider, chain spec, parsed bundle, and block header, then executes transactions +/// in sequence to measure gas usage and execution time. /// -/// Returns a tuple of: -/// - Vector of transaction results -/// - Total gas used -/// - Total gas fees paid -/// - Bundle hash -/// - Total execution time in microseconds +/// Returns [`MeterBundleOutput`] containing transaction results and aggregated metrics. pub fn meter_bundle( state_provider: SP, chain_spec: Arc, bundle: ParsedBundle, header: &SealedHeader, -) -> EyreResult<(Vec, u64, U256, B256, u128)> +) -> EyreResult where SP: reth_provider::StateProvider, { @@ -85,7 +99,7 @@ where results.push(TransactionResult { coinbase_diff: gas_fees, - eth_sent_to_coinbase: U256::from(0), + eth_sent_to_coinbase: U256::ZERO, from_address: from, gas_fees, gas_price: U256::from(gas_price), @@ -97,7 +111,32 @@ where }); } } - let total_execution_time = execution_start.elapsed().as_micros(); - Ok((results, total_gas_used, total_gas_fees, bundle_hash, total_execution_time)) + // Calculate state root and measure its calculation time + let block_number = header.number() + 1; + let bundle_update = db.take_bundle(); + let execution_outcome: ExecutionOutcome = ExecutionOutcome::new( + bundle_update, + Vec::new().into(), + block_number, + Vec::new(), + ); + + let state_provider = db.database.as_ref(); + let state_root_start = Instant::now(); + let hashed_state = state_provider.hashed_post_state(execution_outcome.state()); + let _ = state_provider.state_root_with_updates(hashed_state); + let state_root_time_us = state_root_start.elapsed().as_micros(); + + let total_execution_time_us = execution_start.elapsed().as_micros(); + + Ok(MeterBundleOutput { + results, + total_gas_used, + total_gas_fees, + bundle_hash, + total_execution_time_us, + state_root_time_us, + }) +} } diff --git a/crates/metering/src/rpc.rs b/crates/metering/src/rpc.rs index 19cd2eb4..d72ebdab 100644 --- a/crates/metering/src/rpc.rs +++ b/crates/metering/src/rpc.rs @@ -95,48 +95,48 @@ where })?; // Meter bundle using utility function - let (results, total_gas_used, total_gas_fees, bundle_hash, total_execution_time) = - meter_bundle( - state_provider, - self.provider.chain_spec().clone(), - parsed_bundle, - &header, + let result = meter_bundle( + state_provider, + self.provider.chain_spec().clone(), + parsed_bundle, + &header, + ) + .map_err(|e| { + error!(error = %e, "Bundle metering failed"); + jsonrpsee::types::ErrorObjectOwned::owned( + jsonrpsee::types::ErrorCode::InternalError.code(), + format!("Bundle metering failed: {}", e), + None::<()>, ) - .map_err(|e| { - error!(error = %e, "Bundle metering failed"); - jsonrpsee::types::ErrorObjectOwned::owned( - jsonrpsee::types::ErrorCode::InternalError.code(), - format!("Bundle metering failed: {}", e), - None::<()>, - ) - })?; + })?; // Calculate average gas price - let bundle_gas_price = if total_gas_used > 0 { - total_gas_fees / U256::from(total_gas_used) + let bundle_gas_price = if result.total_gas_used > 0 { + result.total_gas_fees / U256::from(result.total_gas_used) } else { U256::from(0) }; info!( - bundle_hash = %bundle_hash, - num_transactions = results.len(), - total_gas_used = total_gas_used, - total_execution_time_us = total_execution_time, + bundle_hash = %result.bundle_hash, + num_transactions = result.results.len(), + total_gas_used = result.total_gas_used, + total_execution_time_us = result.total_execution_time_us, + state_root_time_us = result.state_root_time_us, "Bundle metering completed successfully" ); Ok(MeterBundleResponse { bundle_gas_price, - bundle_hash, - coinbase_diff: total_gas_fees, + bundle_hash: result.bundle_hash, + coinbase_diff: result.total_gas_fees, eth_sent_to_coinbase: U256::from(0), - gas_fees: total_gas_fees, - results, + gas_fees: result.total_gas_fees, + results: result.results, state_block_number: header.number, state_flashblock_index: None, - total_gas_used, - total_execution_time_us: total_execution_time, + total_gas_used: result.total_gas_used, + total_execution_time_us: result.total_execution_time_us, }) } } diff --git a/crates/metering/src/tests/meter.rs b/crates/metering/src/tests/meter.rs index 47c2a16a..5a00cc2e 100644 --- a/crates/metering/src/tests/meter.rs +++ b/crates/metering/src/tests/meter.rs @@ -135,15 +135,16 @@ fn meter_bundle_empty_transactions() -> eyre::Result<()> { let parsed_bundle = create_parsed_bundle(Vec::new())?; - let (results, total_gas_used, total_gas_fees, bundle_hash, total_execution_time) = + let output = meter_bundle(state_provider, harness.chain_spec.clone(), parsed_bundle, &harness.header)?; - assert!(results.is_empty()); - assert_eq!(total_gas_used, 0); - assert_eq!(total_gas_fees, U256::ZERO); + assert!(output.results.is_empty()); + assert_eq!(output.total_gas_used, 0); + assert_eq!(output.total_gas_fees, U256::ZERO); // Even empty bundles have some EVM setup overhead - assert!(total_execution_time > 0); - assert_eq!(bundle_hash, keccak256([])); + assert!(output.total_execution_time_us > 0); + assert!(output.state_root_time_us > 0); + assert_eq!(output.bundle_hash, keccak256([])); Ok(()) } @@ -177,12 +178,13 @@ fn meter_bundle_single_transaction() -> eyre::Result<()> { let parsed_bundle = create_parsed_bundle(vec![envelope.clone()])?; - let (results, total_gas_used, total_gas_fees, bundle_hash, total_execution_time) = + let output = meter_bundle(state_provider, harness.chain_spec.clone(), parsed_bundle, &harness.header)?; - assert_eq!(results.len(), 1); - let result = &results[0]; - assert!(total_execution_time > 0); + assert_eq!(output.results.len(), 1); + let result = &output.results[0]; + assert!(output.total_execution_time_us > 0); + assert!(output.state_root_time_us > 0); assert_eq!(result.from_address, harness.address(User::Alice)); assert_eq!(result.to_address, Some(to)); @@ -191,12 +193,12 @@ fn meter_bundle_single_transaction() -> eyre::Result<()> { 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)); + assert_eq!(output.total_gas_used, 21_000); + assert_eq!(output.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_eq!(output.bundle_hash, keccak256(concatenated)); assert!(result.execution_time_us > 0, "execution_time_us should be greater than zero"); @@ -254,14 +256,15 @@ fn meter_bundle_multiple_transactions() -> eyre::Result<()> { 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) = + let output = meter_bundle(state_provider, harness.chain_spec.clone(), parsed_bundle, &harness.header)?; - assert_eq!(results.len(), 2); - assert!(total_execution_time > 0); + assert_eq!(output.results.len(), 2); + assert!(output.total_execution_time_us > 0); + assert!(output.state_root_time_us > 0); // Check first transaction - let result_1 = &results[0]; + let result_1 = &output.results[0]; assert_eq!(result_1.from_address, harness.address(User::Alice)); assert_eq!(result_1.to_address, Some(to_1)); assert_eq!(result_1.tx_hash, tx_hash_1); @@ -270,7 +273,7 @@ fn meter_bundle_multiple_transactions() -> eyre::Result<()> { assert_eq!(result_1.coinbase_diff, (U256::from(21_000) * U256::from(10)),); // Check second transaction - let result_2 = &results[1]; + let result_2 = &output.results[1]; assert_eq!(result_2.from_address, harness.address(User::Bob)); assert_eq!(result_2.to_address, Some(to_2)); assert_eq!(result_2.tx_hash, tx_hash_2); @@ -279,16 +282,16 @@ fn meter_bundle_multiple_transactions() -> eyre::Result<()> { assert_eq!(result_2.coinbase_diff, U256::from(21_000) * U256::from(15),); // Check aggregated values - assert_eq!(total_gas_used, 42_000); + assert_eq!(output.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); + assert_eq!(output.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_eq!(output.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"); From fe163b4e57c14118563b8ac3b25897b6312e2944 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Tue, 28 Oct 2025 14:45:40 -0500 Subject: [PATCH 02/25] Update tips-core and expose state root time in RPC response Update tips-core dependency to include state_root_time_us field in MeterBundleResponse. This exposes the state root calculation time as an independent metric in the metering RPC response. --- Cargo.lock | 12 ++++++------ Cargo.toml | 2 +- crates/metering/src/rpc.rs | 1 + 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fc818c79..30be95d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4341,7 +4341,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.1", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -4361,7 +4361,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.57.0", ] [[package]] @@ -6761,7 +6761,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2 0.6.1", + "socket2 0.5.10", "thiserror 2.0.17", "tokio", "tracing", @@ -6798,7 +6798,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.1", + "socket2 0.5.10", "tracing", "windows-sys 0.60.2", ] @@ -11726,7 +11726,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tips-core" version = "0.1.0" -source = "git+https://github.com/base/tips?rev=a21ee492dede17f31eea108c12c669a8190f31aa#a21ee492dede17f31eea108c12c669a8190f31aa" +source = "git+https://github.com/base/tips?rev=e59327bec565808e0505d3fb3a64749dfc61a41a#e59327bec565808e0505d3fb3a64749dfc61a41a" dependencies = [ "alloy-consensus", "alloy-primitives", @@ -12742,7 +12742,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6964da5e..7af6ef41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,7 +47,7 @@ base-reth-transaction-tracing = { path = "crates/transaction-tracing" } # base/tips # Note: default-features = false avoids version conflicts with reth's alloy/op-alloy dependencies -tips-core = { git = "https://github.com/base/tips", rev = "a21ee492dede17f31eea108c12c669a8190f31aa", default-features = false } +tips-core = { git = "https://github.com/base/tips", rev = "e59327bec565808e0505d3fb3a64749dfc61a41a", default-features = false } # reth reth = { git = "https://github.com/paradigmxyz/reth", tag = "v1.9.3" } diff --git a/crates/metering/src/rpc.rs b/crates/metering/src/rpc.rs index d72ebdab..0faa5bc2 100644 --- a/crates/metering/src/rpc.rs +++ b/crates/metering/src/rpc.rs @@ -137,6 +137,7 @@ where state_flashblock_index: None, total_gas_used: result.total_gas_used, total_execution_time_us: result.total_execution_time_us, + state_root_time_us: result.state_root_time_us, }) } } From 305a2ced45544c141de74d35d21b264300ce868d Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Tue, 28 Oct 2025 14:48:35 -0500 Subject: [PATCH 03/25] Add tests for state root time metrics Add unit test to verify total_execution_time_us >= state_root_time_us invariant and RPC integration test to verify state_root_time_us is properly exposed in the response. --- crates/metering/src/tests/meter.rs | 53 ++++++++++++++++++++++++++++++ crates/metering/src/tests/rpc.rs | 9 +++++ 2 files changed, 62 insertions(+) diff --git a/crates/metering/src/tests/meter.rs b/crates/metering/src/tests/meter.rs index 5a00cc2e..e8bf0160 100644 --- a/crates/metering/src/tests/meter.rs +++ b/crates/metering/src/tests/meter.rs @@ -298,3 +298,56 @@ fn meter_bundle_multiple_transactions() -> eyre::Result<()> { Ok(()) } + +#[test] +fn meter_bundle_state_root_time_invariant() -> eyre::Result<()> { + let harness = setup_harness()?; + + let to = Address::random(); + let signed_tx = TransactionBuilder::default() + .signer(harness.signer(User::Alice)) + .chain_id(harness.chain_spec.chain_id()) + .nonce(0) + .to(to) + .value(1_000) + .gas_limit(21_000) + .max_fee_per_gas(10) + .max_priority_fee_per_gas(1) + .into_eip1559(); + + let tx = + OpTransactionSigned::Eip1559(signed_tx.as_eip1559().expect("eip1559 transaction").clone()); + + let envelope = envelope_from_signed(&tx)?; + + let state_provider = harness + .provider + .state_by_block_hash(harness.header.hash()) + .context("getting state provider")?; + + let bundle_with_metadata = create_bundle_with_metadata(vec![envelope.clone()])?; + + let output = meter_bundle( + state_provider, + harness.chain_spec.clone(), + vec![envelope], + &harness.header, + &bundle_with_metadata, + )?; + + // Verify invariant: total execution time must include state root time + assert!( + output.total_execution_time_us >= output.state_root_time_us, + "total_execution_time_us ({}) should be >= state_root_time_us ({})", + output.total_execution_time_us, + output.state_root_time_us + ); + + // State root time should be non-zero + assert!( + output.state_root_time_us > 0, + "state_root_time_us should be greater than zero" + ); + + Ok(()) +} diff --git a/crates/metering/src/tests/rpc.rs b/crates/metering/src/tests/rpc.rs index b77f9534..5e165ff4 100644 --- a/crates/metering/src/tests/rpc.rs +++ b/crates/metering/src/tests/rpc.rs @@ -164,6 +164,15 @@ mod tests { assert_eq!(response.total_gas_used, 21_000); assert!(response.total_execution_time_us > 0); + // Verify state root time is present and non-zero + assert!(response.state_root_time_us > 0, "state_root_time_us should be greater than zero"); + + // Verify invariant: total execution time includes state root time + assert!( + response.total_execution_time_us >= response.state_root_time_us, + "total_execution_time_us should be >= state_root_time_us" + ); + let result = &response.results[0]; assert_eq!(result.from_address, sender_address); assert_eq!(result.to_address, Some(address!("0x1111111111111111111111111111111111111111"))); From ad9586dbfbb8b39fe312db5a51667675b044df11 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Tue, 28 Oct 2025 15:01:08 -0500 Subject: [PATCH 04/25] Simplify state root calculation by removing unnecessary ExecutionOutcome wrapper --- crates/metering/src/meter.rs | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/crates/metering/src/meter.rs b/crates/metering/src/meter.rs index 93ec2626..2361c3ac 100644 --- a/crates/metering/src/meter.rs +++ b/crates/metering/src/meter.rs @@ -7,9 +7,7 @@ use reth::revm::db::State; use reth_evm::{ConfigureEvm, execute::BlockBuilder}; use reth_optimism_chainspec::OpChainSpec; use reth_optimism_evm::{OpEvmConfig, OpNextBlockEnvAttributes}; -use reth_optimism_primitives::OpPrimitives; use reth_primitives_traits::SealedHeader; -use reth_provider::ExecutionOutcome; use tips_core::types::{BundleExtensions, BundleTxs, ParsedBundle}; use crate::TransactionResult; @@ -113,18 +111,10 @@ where } // Calculate state root and measure its calculation time - let block_number = header.number() + 1; let bundle_update = db.take_bundle(); - let execution_outcome: ExecutionOutcome = ExecutionOutcome::new( - bundle_update, - Vec::new().into(), - block_number, - Vec::new(), - ); - let state_provider = db.database.as_ref(); let state_root_start = Instant::now(); - let hashed_state = state_provider.hashed_post_state(execution_outcome.state()); + let hashed_state = state_provider.hashed_post_state(&bundle_update); let _ = state_provider.state_root_with_updates(hashed_state); let state_root_time_us = state_root_start.elapsed().as_micros(); From d741e03ac708ec2f251d759ce39b0fbffd95724e Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Wed, 29 Oct 2025 12:04:14 -0500 Subject: [PATCH 05/25] Use pending flashblocks state for bundle metering Integrate flashblocks state into metering to execute bundles on top of pending flashblock state rather than canonical block state. This ensures metered gas usage and execution time accurately reflect the effects of pending transactions (nonces, balances, storage, code changes). Implementation: - Add flashblocks-rpc dependency to metering crate - Update meter_bundle() to accept optional db_cache parameter - Implement three-layer state architecture: 1. StateProviderDatabase (canonical block base state) 2. CacheDB (applies flashblock pending changes via cache) 3. State wrapper (for EVM builder compatibility) - Update MeteringApiImpl to accept FlashblocksState - Get pending blocks and db_cache from flashblocks when available - Fall back to canonical block state when no flashblocks available - Update response to include flashblock_index in logs - Require flashblocks to be enabled for metering RPC - Update all tests to pass FlashblocksState parameter The metering RPC now uses the same state as flashblocks eth_call, ensuring consistent simulation results. --- Cargo.lock | 69 +++++++---- Cargo.toml | 3 +- crates/flashblocks-rpc/Cargo.toml | 3 + crates/flashblocks-rpc/src/pending_blocks.rs | 16 ++- crates/flashblocks-rpc/src/state.rs | 38 +++++- crates/metering/Cargo.toml | 4 + crates/metering/README.md | 4 +- crates/metering/src/lib.rs | 2 +- crates/metering/src/meter.rs | 48 ++++++-- crates/metering/src/rpc.rs | 122 +++++++++++++------ crates/metering/src/tests/meter.rs | 33 +++-- crates/metering/src/tests/rpc.rs | 9 +- crates/node/src/main.rs | 19 +-- 13 files changed, 279 insertions(+), 91 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 30be95d2..47803e34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -256,7 +256,7 @@ dependencies = [ "alloy-sol-types", "auto_impl", "derive_more", - "op-alloy-consensus", + "op-alloy-consensus 0.22.3", "op-alloy-rpc-types-engine", "op-revm", "revm", @@ -370,7 +370,7 @@ dependencies = [ "alloy-op-hardforks", "alloy-primitives", "auto_impl", - "op-alloy-consensus", + "op-alloy-consensus 0.22.3", "op-revm", "revm", "thiserror 2.0.17", @@ -1543,7 +1543,7 @@ dependencies = [ "metrics", "metrics-derive", "once_cell", - "op-alloy-consensus", + "op-alloy-consensus 0.22.3", "op-alloy-network", "op-alloy-rpc-types", "rand 0.9.2", @@ -1566,6 +1566,7 @@ dependencies = [ "reth-rpc-eth-api", "reth-testing-utils", "reth-tracing", + "revm-database", "rollup-boost", "serde", "serde_json", @@ -1586,10 +1587,11 @@ dependencies = [ "alloy-genesis", "alloy-primitives", "alloy-rpc-client", + "base-reth-flashblocks-rpc", "base-reth-test-utils", "eyre", "jsonrpsee 0.26.0", - "op-alloy-consensus", + "op-alloy-consensus 0.22.3", "rand 0.9.2", "reth", "reth-db", @@ -1608,6 +1610,7 @@ dependencies = [ "reth-tracing", "reth-transaction-pool", "revm", + "revm-database", "serde", "serde_json", "tips-core", @@ -1641,7 +1644,7 @@ dependencies = [ "metrics", "metrics-derive", "once_cell", - "op-alloy-consensus", + "op-alloy-consensus 0.22.3", "op-alloy-network", "op-alloy-rpc-jsonrpsee", "op-alloy-rpc-types", @@ -1697,7 +1700,7 @@ dependencies = [ "futures-util", "jsonrpsee 0.26.0", "once_cell", - "op-alloy-consensus", + "op-alloy-consensus 0.22.3", "op-alloy-network", "op-alloy-rpc-types", "op-alloy-rpc-types-engine", @@ -4341,7 +4344,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.1", "system-configuration", "tokio", "tower-service", @@ -4361,7 +4364,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.57.0", + "windows-core 0.61.2", ] [[package]] @@ -5875,6 +5878,20 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "op-alloy-consensus" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a501241474c3118833d6195312ae7eb7cc90bbb0d5f524cbb0b06619e49ff67" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "derive_more", + "thiserror 2.0.17", +] + [[package]] name = "op-alloy-consensus" version = "0.22.3" @@ -5913,7 +5930,7 @@ dependencies = [ "alloy-provider", "alloy-rpc-types-eth", "alloy-signer", - "op-alloy-consensus", + "op-alloy-consensus 0.22.3", "op-alloy-rpc-types", ] @@ -5940,7 +5957,7 @@ dependencies = [ "alloy-rpc-types-eth", "alloy-serde", "derive_more", - "op-alloy-consensus", + "op-alloy-consensus 0.22.3", "serde", "serde_json", "thiserror 2.0.17", @@ -5961,7 +5978,7 @@ dependencies = [ "derive_more", "ethereum_ssz", "ethereum_ssz_derive", - "op-alloy-consensus", + "op-alloy-consensus 0.22.3", "serde", "snap", "thiserror 2.0.17", @@ -6761,7 +6778,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2 0.5.10", + "socket2 0.6.1", "thiserror 2.0.17", "tokio", "tracing", @@ -6798,7 +6815,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.1", "tracing", "windows-sys 0.60.2", ] @@ -7366,7 +7383,7 @@ dependencies = [ "arbitrary", "bytes", "modular-bitfield", - "op-alloy-consensus", + "op-alloy-consensus 0.22.3", "reth-codecs-derive", "reth-zstd-compressors", "serde", @@ -8812,7 +8829,7 @@ dependencies = [ "alloy-primitives", "derive_more", "miniz_oxide", - "op-alloy-consensus", + "op-alloy-consensus 0.22.3", "op-alloy-rpc-types", "paste", "reth-chainspec", @@ -8840,7 +8857,7 @@ dependencies = [ "derive_more", "eyre", "futures-util", - "op-alloy-consensus", + "op-alloy-consensus 0.22.3", "reth-chainspec", "reth-cli", "reth-cli-commands", @@ -8912,7 +8929,7 @@ dependencies = [ "alloy-evm", "alloy-op-evm", "alloy-primitives", - "op-alloy-consensus", + "op-alloy-consensus 0.22.3", "op-alloy-rpc-types-engine", "op-revm", "reth-chainspec", @@ -8991,7 +9008,7 @@ dependencies = [ "alloy-rpc-types-eth", "clap", "eyre", - "op-alloy-consensus", + "op-alloy-consensus 0.22.3", "op-alloy-rpc-types-engine", "op-revm", "reth-chainspec", @@ -9039,7 +9056,7 @@ dependencies = [ "alloy-rpc-types-debug", "alloy-rpc-types-engine", "derive_more", - "op-alloy-consensus", + "op-alloy-consensus 0.22.3", "op-alloy-rpc-types-engine", "reth-basic-payload-builder", "reth-chain-state", @@ -9078,7 +9095,7 @@ dependencies = [ "arbitrary", "bytes", "modular-bitfield", - "op-alloy-consensus", + "op-alloy-consensus 0.22.3", "reth-codecs", "reth-primitives-traits", "reth-zstd-compressors", @@ -9109,7 +9126,7 @@ dependencies = [ "jsonrpsee-core 0.26.0", "jsonrpsee-types 0.26.0", "metrics", - "op-alloy-consensus", + "op-alloy-consensus 0.22.3", "op-alloy-network", "op-alloy-rpc-jsonrpsee", "op-alloy-rpc-types", @@ -9173,7 +9190,7 @@ dependencies = [ "derive_more", "futures-util", "metrics", - "op-alloy-consensus", + "op-alloy-consensus 0.22.3", "op-alloy-flz", "op-alloy-rpc-types", "op-revm", @@ -9299,7 +9316,7 @@ dependencies = [ "derive_more", "modular-bitfield", "once_cell", - "op-alloy-consensus", + "op-alloy-consensus 0.22.3", "proptest", "proptest-arbitrary-interop", "rayon", @@ -9618,7 +9635,7 @@ dependencies = [ "auto_impl", "dyn-clone", "jsonrpsee-types 0.26.0", - "op-alloy-consensus", + "op-alloy-consensus 0.22.3", "op-alloy-network", "op-alloy-rpc-types", "op-revm", @@ -11726,13 +11743,13 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tips-core" version = "0.1.0" -source = "git+https://github.com/base/tips?rev=e59327bec565808e0505d3fb3a64749dfc61a41a#e59327bec565808e0505d3fb3a64749dfc61a41a" +source = "git+https://github.com/base/tips?rev=86b275c0fd63226c3fb85ac5512033f99b67d0f5#86b275c0fd63226c3fb85ac5512033f99b67d0f5" dependencies = [ "alloy-consensus", "alloy-primitives", "alloy-provider", "alloy-serde", - "op-alloy-consensus", + "op-alloy-consensus 0.20.0", "op-alloy-flz", "serde", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 7af6ef41..06581433 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,7 +47,7 @@ base-reth-transaction-tracing = { path = "crates/transaction-tracing" } # base/tips # Note: default-features = false avoids version conflicts with reth's alloy/op-alloy dependencies -tips-core = { git = "https://github.com/base/tips", rev = "e59327bec565808e0505d3fb3a64749dfc61a41a", default-features = false } +tips-core = { git = "https://github.com/base/tips", rev = "86b275c0fd63226c3fb85ac5512033f99b67d0f5", default-features = false } # reth reth = { git = "https://github.com/paradigmxyz/reth", tag = "v1.9.3" } @@ -78,6 +78,7 @@ reth-node-core = { git = "https://github.com/paradigmxyz/reth", tag = "v1.9.3" } # revm revm = { version = "31.0.2", default-features = false } revm-bytecode = { version = "7.1.1", default-features = false } +revm-database = { version = "9.0.6", default-features = false } # alloy alloy-primitives = { version = "1.4.1", default-features = false, features = [ diff --git a/crates/flashblocks-rpc/Cargo.toml b/crates/flashblocks-rpc/Cargo.toml index d12a57eb..54c6f5e8 100644 --- a/crates/flashblocks-rpc/Cargo.toml +++ b/crates/flashblocks-rpc/Cargo.toml @@ -29,6 +29,9 @@ reth-primitives.workspace = true reth-primitives-traits.workspace = true reth-exex.workspace = true +# revm +revm-database.workspace = true + # alloy alloy-primitives.workspace = true alloy-eips.workspace = true diff --git a/crates/flashblocks-rpc/src/pending_blocks.rs b/crates/flashblocks-rpc/src/pending_blocks.rs index a0d9ffbc..a440aeec 100644 --- a/crates/flashblocks-rpc/src/pending_blocks.rs +++ b/crates/flashblocks-rpc/src/pending_blocks.rs @@ -13,7 +13,7 @@ use arc_swap::Guard; use eyre::eyre; use op_alloy_network::Optimism; use op_alloy_rpc_types::{OpTransactionReceipt, Transaction}; -use reth::revm::{db::Cache, state::EvmState}; +use reth::revm::{db::{BundleState, Cache}, state::EvmState}; use reth_rpc_convert::RpcTransaction; use reth_rpc_eth_api::{RpcBlock, RpcReceipt}; @@ -33,6 +33,7 @@ pub struct PendingBlocksBuilder { state_overrides: Option, db_cache: Cache, + bundle_state: BundleState, } impl PendingBlocksBuilder { @@ -49,6 +50,7 @@ impl PendingBlocksBuilder { transaction_senders: HashMap::new(), state_overrides: None, db_cache: Cache::default(), + bundle_state: BundleState::default(), } } @@ -116,6 +118,12 @@ impl PendingBlocksBuilder { self } + #[inline] + pub(crate) fn with_bundle_state(&mut self, bundle_state: BundleState) -> &Self { + self.bundle_state = bundle_state; + self + } + pub(crate) fn build(self) -> eyre::Result { if self.headers.is_empty() { return Err(eyre!("missing headers")); @@ -137,6 +145,7 @@ impl PendingBlocksBuilder { transaction_senders: self.transaction_senders, state_overrides: self.state_overrides, db_cache: self.db_cache, + bundle_state: self.bundle_state, }) } } @@ -156,6 +165,7 @@ pub struct PendingBlocks { state_overrides: Option, db_cache: Cache, + bundle_state: BundleState, } impl PendingBlocks { @@ -195,6 +205,10 @@ impl PendingBlocks { self.db_cache.clone() } + pub fn get_bundle_state(&self) -> BundleState { + self.bundle_state.clone() + } + pub fn get_transactions_for_block(&self, block_number: BlockNumber) -> Vec { self.transactions .iter() diff --git a/crates/flashblocks-rpc/src/state.rs b/crates/flashblocks-rpc/src/state.rs index b3335062..bc002731 100644 --- a/crates/flashblocks-rpc/src/state.rs +++ b/crates/flashblocks-rpc/src/state.rs @@ -18,14 +18,19 @@ use eyre::eyre; use op_alloy_consensus::OpTxEnvelope; use op_alloy_network::TransactionResponse; use op_alloy_rpc_types::Transaction; +<<<<<<< HEAD use reth::{ chainspec::{ChainSpecProvider, EthChainSpec}, providers::{BlockReaderIdExt, StateProviderFactory}, revm::{ - DatabaseCommit, State, context::result::ResultAndState, database::StateProviderDatabase, + context::result::ResultAndState, + database::StateProviderDatabase, db::CacheDB, + DatabaseCommit, + State, }, }; +use revm_database::states::bundle_state::BundleRetention; use reth_evm::{ConfigureEvm, Evm}; use reth_optimism_chainspec::OpHardforks; use reth_optimism_evm::{OpEvmConfig, OpNextBlockEnvAttributes}; @@ -375,12 +380,39 @@ where let state_provider = self.client.state_by_block_number_or_tag(BlockNumberOrTag::Number(canonical_block))?; let state_provider_db = StateProviderDatabase::new(state_provider); +<<<<<<< HEAD let state = State::builder().with_database(state_provider_db).with_bundle_update().build(); let mut pending_blocks_builder = PendingBlocksBuilder::new(); let mut db = match &prev_pending_blocks { Some(pending_blocks) => CacheDB { cache: pending_blocks.get_db_cache(), db: state }, None => CacheDB::new(state), +======= + let mut pending_blocks_builder = PendingBlocksBuilder::new(); + + // Cache reads across flashblocks, accumulating caches from previous + // pending blocks if available + let cache_db = match &prev_pending_blocks { + Some(pending_blocks) => CacheDB { + cache: pending_blocks.get_db_cache(), + db: state_provider_db, + }, + None => CacheDB::new(state_provider_db), + }; + + // Track state changes across flashblocks, accumulating bundle state + // from previous pending blocks if available + let mut db = match &prev_pending_blocks { + Some(pending_blocks) => State::builder() + .with_database(cache_db) + .with_bundle_update() + .with_bundle_prestate(pending_blocks.get_bundle_state()) + .build(), + None => State::builder() + .with_database(cache_db) + .with_bundle_update() + .build(), +>>>>>>> 1c80299 (Use pending flashblocks state for bundle metering) }; let mut state_overrides = match &prev_pending_blocks { @@ -620,7 +652,9 @@ where last_block_header = block.header.clone(); } - pending_blocks_builder.with_db_cache(db.cache); + db.merge_transitions(BundleRetention::Reverts); + pending_blocks_builder.with_bundle_state(db.take_bundle()); + pending_blocks_builder.with_db_cache(db.database.cache); pending_blocks_builder.with_state_overrides(state_overrides); Ok(Some(Arc::new(pending_blocks_builder.build()?))) } diff --git a/crates/metering/Cargo.toml b/crates/metering/Cargo.toml index 27e9590e..a7b2d6d4 100644 --- a/crates/metering/Cargo.toml +++ b/crates/metering/Cargo.toml @@ -35,8 +35,12 @@ alloy-eips.workspace = true # op-alloy op-alloy-consensus.workspace = true +# base +base-reth-flashblocks-rpc = { path = "../flashblocks-rpc" } + # revm revm.workspace = true +revm-database.workspace = true # rpc jsonrpsee.workspace = true diff --git a/crates/metering/README.md b/crates/metering/README.md index 1cc88343..eefc12f3 100644 --- a/crates/metering/README.md +++ b/crates/metering/README.md @@ -11,7 +11,7 @@ Simulates a bundle of transactions, providing gas usage and execution time metri The method accepts a Bundle object with the following fields: - `txs`: Array of signed, RLP-encoded transactions (hex strings with 0x prefix) -- `block_number`: Target block number for bundle validity (note: simulation always uses the latest available block state) +- `block_number`: Target block number for bundle validity (note: simulation uses pending flashblocks state when available, otherwise latest canonical block) - `min_timestamp` (optional): Minimum timestamp for bundle validity (also used as simulation timestamp if provided) - `max_timestamp` (optional): Maximum timestamp for bundle validity - `reverting_tx_hashes` (optional): Array of transaction hashes allowed to revert @@ -26,7 +26,7 @@ The method accepts a Bundle object with the following fields: - `coinbaseDiff`: Total gas fees paid - `ethSentToCoinbase`: ETH sent directly to coinbase - `gasFees`: Total gas fees -- `stateBlockNumber`: Block number used for state (always the latest available block) +- `stateBlockNumber`: Block number used for state (latest flashblock if pending flashblocks exist, otherwise latest canonical block) - `totalGasUsed`: Total gas consumed - `totalExecutionTimeUs`: Total execution time (μs) - `results`: Array of per-transaction results: diff --git a/crates/metering/src/lib.rs b/crates/metering/src/lib.rs index 91f7aee0..26681b91 100644 --- a/crates/metering/src/lib.rs +++ b/crates/metering/src/lib.rs @@ -3,6 +3,6 @@ mod rpc; #[cfg(test)] mod tests; -pub use meter::{meter_bundle, MeterBundleOutput}; +pub use meter::{meter_bundle, FlashblocksState, MeterBundleOutput}; pub use rpc::{MeteringApiImpl, MeteringApiServer}; pub use tips_core::types::{Bundle, MeterBundleResponse, TransactionResult}; diff --git a/crates/metering/src/meter.rs b/crates/metering/src/meter.rs index 2361c3ac..0c37f8b4 100644 --- a/crates/metering/src/meter.rs +++ b/crates/metering/src/meter.rs @@ -3,7 +3,8 @@ use std::{sync::Arc, time::Instant}; use alloy_consensus::{BlockHeader, Transaction as _, transaction::SignerRecoverable}; use alloy_primitives::{B256, U256}; use eyre::{Result as EyreResult, eyre}; -use reth::revm::db::State; +use reth::revm::db::{BundleState, Cache, CacheDB, State}; +use revm_database::states::bundle_state::BundleRetention; use reth_evm::{ConfigureEvm, execute::BlockBuilder}; use reth_optimism_chainspec::OpChainSpec; use reth_optimism_evm::{OpEvmConfig, OpNextBlockEnvAttributes}; @@ -12,6 +13,15 @@ use tips_core::types::{BundleExtensions, BundleTxs, ParsedBundle}; use crate::TransactionResult; +/// State from pending flashblocks that is used as a base for metering +#[derive(Debug, Clone)] +pub struct FlashblocksState { + /// The cache of account and storage data + pub cache: Cache, + /// The accumulated bundle of state changes + pub bundle_state: BundleState, +} + const BLOCK_TIME: u64 = 2; // 2 seconds per block /// Output from metering a bundle of transactions @@ -33,8 +43,8 @@ pub struct MeterBundleOutput { /// Simulates and meters a bundle of transactions /// -/// Takes a state provider, chain spec, parsed bundle, and block header, then executes transactions -/// in sequence to measure gas usage and execution time. +/// Takes a state provider, chain spec, parsed bundle, block header, and optional flashblocks state, +/// then executes transactions in sequence to measure gas usage and execution time. /// /// Returns [`MeterBundleOutput`] containing transaction results and aggregated metrics. pub fn meter_bundle( @@ -42,6 +52,7 @@ pub fn meter_bundle( chain_spec: Arc, bundle: ParsedBundle, header: &SealedHeader, + flashblocks_state: Option, ) -> EyreResult where SP: reth_provider::StateProvider, @@ -51,7 +62,29 @@ where // Create state database let state_db = reth::revm::database::StateProviderDatabase::new(state_provider); - let mut db = State::builder().with_database(state_db).with_bundle_update().build(); + // If we have flashblocks state, apply both cache and bundle prestate + let cache_db = if let Some(ref flashblocks) = flashblocks_state { + CacheDB { + cache: flashblocks.cache.clone(), + db: state_db, + } + } else { + CacheDB::new(state_db) + }; + + // Wrap the CacheDB in a State to track bundle changes for state root calculation + let mut db = if let Some(flashblocks) = flashblocks_state.as_ref() { + State::builder() + .with_database(cache_db) + .with_bundle_update() + .with_bundle_prestate(flashblocks.bundle_state.clone()) + .build() + } else { + State::builder() + .with_database(cache_db) + .with_bundle_update() + .build() + }; // Set up next block attributes // Use bundle.min_timestamp if provided, otherwise use header timestamp + BLOCK_TIME @@ -110,9 +143,11 @@ where } } - // Calculate state root and measure its calculation time + // Calculate state root and measure its calculation time. The bundle already includes + // flashblocks state if it was provided via with_bundle_prestate. + db.merge_transitions(BundleRetention::Reverts); let bundle_update = db.take_bundle(); - let state_provider = db.database.as_ref(); + let state_provider = db.database.db.as_ref(); let state_root_start = Instant::now(); let hashed_state = state_provider.hashed_post_state(&bundle_update); let _ = state_provider.state_root_with_updates(hashed_state); @@ -129,4 +164,3 @@ where state_root_time_us, }) } -} diff --git a/crates/metering/src/rpc.rs b/crates/metering/src/rpc.rs index 0faa5bc2..f2faceb2 100644 --- a/crates/metering/src/rpc.rs +++ b/crates/metering/src/rpc.rs @@ -1,13 +1,16 @@ -use alloy_consensus::Header; +use alloy_consensus::{Header, Sealed}; use alloy_eips::BlockNumberOrTag; use alloy_primitives::U256; +use base_reth_flashblocks_rpc::rpc::{FlashblocksAPI, PendingBlocksAPI}; use jsonrpsee::{ core::{RpcResult, async_trait}, proc_macros::rpc, }; use reth::providers::BlockReaderIdExt; use reth_optimism_chainspec::OpChainSpec; +use reth_primitives_traits::SealedHeader; use reth_provider::{ChainSpecProvider, StateProviderFactory}; +use std::sync::Arc; use tips_core::types::{Bundle, MeterBundleResponse, ParsedBundle}; use tracing::{error, info}; @@ -22,25 +25,30 @@ pub trait MeteringApi { } /// Implementation of the metering RPC API -pub struct MeteringApiImpl { +pub struct MeteringApiImpl { provider: Provider, + flashblocks_state: Arc, } -impl MeteringApiImpl +impl MeteringApiImpl where Provider: StateProviderFactory + ChainSpecProvider + BlockReaderIdExt
+ Clone, + FB: FlashblocksAPI, { /// Creates a new instance of MeteringApi - pub fn new(provider: Provider) -> Self { - Self { provider } + pub fn new(provider: Provider, flashblocks_state: Arc) -> Self { + Self { + provider, + flashblocks_state, + } } } #[async_trait] -impl MeteringApiServer for MeteringApiImpl +impl MeteringApiServer for MeteringApiImpl where Provider: StateProviderFactory + ChainSpecProvider @@ -49,6 +57,7 @@ where + Send + Sync + 'static, + FB: FlashblocksAPI + Send + Sync + 'static, { async fn meter_bundle(&self, bundle: Bundle) -> RpcResult { info!( @@ -57,24 +66,54 @@ where "Starting bundle metering" ); - // Get the latest header - let header = self - .provider - .sealed_header_by_number_or_tag(BlockNumberOrTag::Latest) - .map_err(|e| { - jsonrpsee::types::ErrorObjectOwned::owned( - jsonrpsee::types::ErrorCode::InternalError.code(), - format!("Failed to get latest header: {}", e), - None::<()>, - ) - })? - .ok_or_else(|| { - jsonrpsee::types::ErrorObjectOwned::owned( - jsonrpsee::types::ErrorCode::InternalError.code(), - "Latest block not found".to_string(), - None::<()>, - ) - })?; + // Get pending flashblocks state + let pending_blocks = self.flashblocks_state.get_pending_blocks(); + + // Get header and flashblock index from pending blocks + // If no pending blocks exist, fall back to latest canonical block + let (header, flashblock_index, canonical_block_number) = if let Some(pb) = pending_blocks.as_ref() { + let latest_header: Sealed
= pb.latest_header(); + let flashblock_index = pb.latest_flashblock_index(); + let canonical_block_number = pb.canonical_block_number(); + + info!( + latest_block = latest_header.number, + canonical_block = %canonical_block_number, + flashblock_index = flashblock_index, + "Using latest flashblock state for metering" + ); + + // Convert Sealed
to SealedHeader + let sealed_header = SealedHeader::new(latest_header.inner().clone(), latest_header.hash()); + (sealed_header, flashblock_index, canonical_block_number) + } else { + // No pending blocks, use latest canonical block + let canonical_block_number = pending_blocks.get_canonical_block_number(); + let header = self + .provider + .sealed_header_by_number_or_tag(canonical_block_number) + .map_err(|e| { + jsonrpsee::types::ErrorObjectOwned::owned( + jsonrpsee::types::ErrorCode::InternalError.code(), + format!("Failed to get canonical block header: {}", e), + None::<()>, + ) + })? + .ok_or_else(|| { + jsonrpsee::types::ErrorObjectOwned::owned( + jsonrpsee::types::ErrorCode::InternalError.code(), + "Canonical block not found".to_string(), + None::<()>, + ) + })?; + + info!( + canonical_block = header.number, + "No flashblocks available, using canonical block state for metering" + ); + + (header, 0, canonical_block_number) + }; let parsed_bundle = ParsedBundle::try_from(bundle).map_err(|e| { jsonrpsee::types::ErrorObjectOwned::owned( @@ -84,15 +123,27 @@ where ) })?; - // Get state provider for the block - let state_provider = self.provider.state_by_block_hash(header.hash()).map_err(|e| { - error!(error = %e, "Failed to get state provider"); - jsonrpsee::types::ErrorObjectOwned::owned( - jsonrpsee::types::ErrorCode::InternalError.code(), - format!("Failed to get state provider: {}", e), - None::<()>, - ) - })?; + // Get state provider for the canonical block + let state_provider = self + .provider + .state_by_block_number_or_tag(canonical_block_number) + .map_err(|e| { + error!(error = %e, "Failed to get state provider"); + jsonrpsee::types::ErrorObjectOwned::owned( + jsonrpsee::types::ErrorCode::InternalError.code(), + format!("Failed to get state provider: {}", e), + None::<()>, + ) + })?; + + // If we have pending flashblocks, get the state to apply pending changes + let flashblocks_state = pending_blocks.as_ref().map(|pb| crate::FlashblocksState { + cache: pb.get_db_cache(), + bundle_state: pb.get_bundle_state(), + }); + + // Get the flashblock index if we have pending flashblocks + let state_flashblock_index = pending_blocks.as_ref().map(|pb| pb.latest_flashblock_index()); // Meter bundle using utility function let result = meter_bundle( @@ -100,6 +151,7 @@ where self.provider.chain_spec().clone(), parsed_bundle, &header, + flashblocks_state, ) .map_err(|e| { error!(error = %e, "Bundle metering failed"); @@ -123,6 +175,8 @@ where total_gas_used = result.total_gas_used, total_execution_time_us = result.total_execution_time_us, state_root_time_us = result.state_root_time_us, + state_block_number = header.number, + flashblock_index = flashblock_index, "Bundle metering completed successfully" ); @@ -134,7 +188,7 @@ where gas_fees: result.total_gas_fees, results: result.results, state_block_number: header.number, - state_flashblock_index: None, + state_flashblock_index, total_gas_used: result.total_gas_used, total_execution_time_us: result.total_execution_time_us, state_root_time_us: result.state_root_time_us, diff --git a/crates/metering/src/tests/meter.rs b/crates/metering/src/tests/meter.rs index e8bf0160..b96eb739 100644 --- a/crates/metering/src/tests/meter.rs +++ b/crates/metering/src/tests/meter.rs @@ -135,8 +135,13 @@ fn meter_bundle_empty_transactions() -> eyre::Result<()> { let parsed_bundle = create_parsed_bundle(Vec::new())?; - let output = - meter_bundle(state_provider, harness.chain_spec.clone(), parsed_bundle, &harness.header)?; + let output = meter_bundle( + state_provider, + harness.chain_spec.clone(), + parsed_bundle, + &harness.header, + None, + )?; assert!(output.results.is_empty()); assert_eq!(output.total_gas_used, 0); @@ -178,8 +183,13 @@ fn meter_bundle_single_transaction() -> eyre::Result<()> { let parsed_bundle = create_parsed_bundle(vec![envelope.clone()])?; - let output = - meter_bundle(state_provider, harness.chain_spec.clone(), parsed_bundle, &harness.header)?; + let output = meter_bundle( + state_provider, + harness.chain_spec.clone(), + parsed_bundle, + &harness.header, + None, + )?; assert_eq!(output.results.len(), 1); let result = &output.results[0]; @@ -256,8 +266,13 @@ fn meter_bundle_multiple_transactions() -> eyre::Result<()> { let parsed_bundle = create_parsed_bundle(vec![envelope_1.clone(), envelope_2.clone()])?; - let output = - meter_bundle(state_provider, harness.chain_spec.clone(), parsed_bundle, &harness.header)?; + let output = meter_bundle( + state_provider, + harness.chain_spec.clone(), + parsed_bundle, + &harness.header, + None, + )?; assert_eq!(output.results.len(), 2); assert!(output.total_execution_time_us > 0); @@ -325,14 +340,14 @@ fn meter_bundle_state_root_time_invariant() -> eyre::Result<()> { .state_by_block_hash(harness.header.hash()) .context("getting state provider")?; - let bundle_with_metadata = create_bundle_with_metadata(vec![envelope.clone()])?; + let parsed_bundle = create_parsed_bundle(vec![envelope.clone()])?; let output = meter_bundle( state_provider, harness.chain_spec.clone(), - vec![envelope], + parsed_bundle, &harness.header, - &bundle_with_metadata, + None, )?; // Verify invariant: total execution time must include state root time diff --git a/crates/metering/src/tests/rpc.rs b/crates/metering/src/tests/rpc.rs index 5e165ff4..3e1d9beb 100644 --- a/crates/metering/src/tests/rpc.rs +++ b/crates/metering/src/tests/rpc.rs @@ -6,6 +6,7 @@ mod tests { use alloy_genesis::Genesis; use alloy_primitives::{Bytes, U256, address, b256, bytes}; use alloy_rpc_client::RpcClient; + use base_reth_flashblocks_rpc::state::FlashblocksState; use base_reth_test_utils::tracing::init_silenced_tracing; use op_alloy_consensus::OpTxEnvelope; use reth::{ @@ -59,6 +60,7 @@ mod tests { let tasks = TaskManager::current(); let exec = tasks.executor(); const BASE_SEPOLIA_CHAIN_ID: u64 = 84532; + const MAX_PENDING_BLOCKS_DEPTH: u64 = 5; let genesis: Genesis = serde_json::from_str(include_str!("assets/genesis.json")).unwrap(); let chain_spec = Arc::new( @@ -87,7 +89,12 @@ mod tests { .with_components(node.components_builder()) .with_add_ons(node.add_ons()) .extend_rpc_modules(move |ctx| { - let metering_api = MeteringApiImpl::new(ctx.provider().clone()); + // Create a FlashblocksState without starting it (no pending blocks for testing) + let flashblocks_state = Arc::new(FlashblocksState::new( + ctx.provider().clone(), + MAX_PENDING_BLOCKS_DEPTH, + )); + let metering_api = MeteringApiImpl::new(ctx.provider().clone(), flashblocks_state); ctx.modules.merge_configured(metering_api.into_rpc())?; Ok(()) }) diff --git a/crates/node/src/main.rs b/crates/node/src/main.rs index 9162c746..e86d6d3b 100644 --- a/crates/node/src/main.rs +++ b/crates/node/src/main.rs @@ -135,12 +135,6 @@ fn main() { } }) .extend_rpc_modules(move |ctx| { - if metering_enabled { - info!(message = "Starting Metering RPC"); - let metering_api = MeteringApiImpl::new(ctx.provider().clone()); - ctx.modules.merge_configured(metering_api.into_rpc())?; - } - if flashblocks_enabled { info!(message = "Starting Flashblocks"); @@ -166,12 +160,23 @@ fn main() { let api_ext = EthApiExt::new( ctx.registry.eth_api().clone(), ctx.registry.eth_handlers().filter.clone(), - fb, + fb.clone(), ); ctx.modules.replace_configured(api_ext.into_rpc())?; + + if metering_enabled { + info!(message = "Starting Metering RPC with Flashblocks state"); + let metering_api = MeteringApiImpl::new(ctx.provider().clone(), fb); + ctx.modules.merge_configured(metering_api.into_rpc())?; + } } else { info!(message = "flashblocks integration is disabled"); + if metering_enabled { + return Err(eyre::eyre!( + "Metering RPC requires flashblocks to be enabled (--websocket-url)" + )); + } } Ok(()) }) From 89295148a6bd7fcaefe19182686c9c7e33fbec20 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Thu, 30 Oct 2025 02:05:10 -0500 Subject: [PATCH 06/25] Cache flashblock trie nodes to optimize bundle metering Adds a single-entry cache for the latest flashblock's trie nodes, allowing bundle metering operations to reuse the cached trie computation instead of recalculating from scratch each time. Key changes: - Add FlashblockTrieCache: thread-safe single-entry cache for latest flashblock - Add FlashblockTrieData: contains trie updates and hashed state for reuse - Cache keyed by block hash and flashblock index for accuracy - Compute flashblock trie once per flashblock, reuse for all bundle operations - Extract flashblock trie calculation outside timing metrics - Use TrieInput.prepend_cached() to combine flashblock + bundle tries The cache replaces previous entries when a new flashblock is cached, as it's designed only for the current/latest flashblock, not historical ones. --- Cargo.toml | 1 + crates/flashblocks-rpc/src/state.rs | 10 --- crates/metering/Cargo.toml | 2 + crates/metering/src/flashblock_trie_cache.rs | 88 ++++++++++++++++++++ crates/metering/src/lib.rs | 2 + crates/metering/src/meter.rs | 36 +++++++- crates/metering/src/rpc.rs | 32 ++++++- crates/metering/src/tests/meter.rs | 8 ++ 8 files changed, 164 insertions(+), 15 deletions(-) create mode 100644 crates/metering/src/flashblock_trie_cache.rs diff --git a/Cargo.toml b/Cargo.toml index 06581433..18e0fe1a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,6 +74,7 @@ reth-db-common = { git = "https://github.com/paradigmxyz/reth", tag = "v1.9.3" } reth-rpc-layer = { git = "https://github.com/paradigmxyz/reth", tag = "v1.9.3" } reth-ipc = { git = "https://github.com/paradigmxyz/reth", tag = "v1.9.3" } reth-node-core = { git = "https://github.com/paradigmxyz/reth", tag = "v1.9.3" } +reth-trie-common = { git = "https://github.com/paradigmxyz/reth", tag = "v1.9.3" } # revm revm = { version = "31.0.2", default-features = false } diff --git a/crates/flashblocks-rpc/src/state.rs b/crates/flashblocks-rpc/src/state.rs index bc002731..03bbd3eb 100644 --- a/crates/flashblocks-rpc/src/state.rs +++ b/crates/flashblocks-rpc/src/state.rs @@ -18,7 +18,6 @@ use eyre::eyre; use op_alloy_consensus::OpTxEnvelope; use op_alloy_network::TransactionResponse; use op_alloy_rpc_types::Transaction; -<<<<<<< HEAD use reth::{ chainspec::{ChainSpecProvider, EthChainSpec}, providers::{BlockReaderIdExt, StateProviderFactory}, @@ -380,14 +379,6 @@ where let state_provider = self.client.state_by_block_number_or_tag(BlockNumberOrTag::Number(canonical_block))?; let state_provider_db = StateProviderDatabase::new(state_provider); -<<<<<<< HEAD - let state = State::builder().with_database(state_provider_db).with_bundle_update().build(); - let mut pending_blocks_builder = PendingBlocksBuilder::new(); - - let mut db = match &prev_pending_blocks { - Some(pending_blocks) => CacheDB { cache: pending_blocks.get_db_cache(), db: state }, - None => CacheDB::new(state), -======= let mut pending_blocks_builder = PendingBlocksBuilder::new(); // Cache reads across flashblocks, accumulating caches from previous @@ -412,7 +403,6 @@ where .with_database(cache_db) .with_bundle_update() .build(), ->>>>>>> 1c80299 (Use pending flashblocks state for bundle metering) }; let mut state_overrides = match &prev_pending_blocks { diff --git a/crates/metering/Cargo.toml b/crates/metering/Cargo.toml index a7b2d6d4..53230760 100644 --- a/crates/metering/Cargo.toml +++ b/crates/metering/Cargo.toml @@ -26,6 +26,7 @@ reth-optimism-chainspec.workspace = true reth-optimism-primitives.workspace = true reth-transaction-pool.workspace = true reth-optimism-cli.workspace = true # Enables serde & codec traits for OpReceipt/OpTxEnvelope +reth-trie-common.workspace = true # alloy alloy-primitives.workspace = true @@ -49,6 +50,7 @@ jsonrpsee.workspace = true tracing.workspace = true serde.workspace = true eyre.workspace = true +arc-swap.workspace = true [dev-dependencies] alloy-genesis.workspace = true diff --git a/crates/metering/src/flashblock_trie_cache.rs b/crates/metering/src/flashblock_trie_cache.rs new file mode 100644 index 00000000..21aea516 --- /dev/null +++ b/crates/metering/src/flashblock_trie_cache.rs @@ -0,0 +1,88 @@ +use std::sync::Arc; + +use alloy_primitives::B256; +use arc_swap::ArcSwap; +use eyre::Result as EyreResult; +use reth_provider::StateProvider; +use reth_trie_common::{updates::TrieUpdates, HashedPostState}; + +use crate::FlashblocksState; + +/// Trie nodes and hashed state from computing a flashblock state root. +/// +/// These cached nodes can be reused when computing a bundle's state root +/// to avoid recalculating the flashblock portion of the trie. +#[derive(Debug, Clone)] +pub struct FlashblockTrieData { + pub trie_updates: TrieUpdates, + pub hashed_state: HashedPostState, +} + +/// Internal cache entry for a single flashblock. +#[derive(Debug, Clone)] +struct CachedFlashblockTrie { + block_hash: B256, + flashblock_index: u64, + trie_data: FlashblockTrieData, +} + +/// Thread-safe single-entry cache for the latest flashblock's trie nodes. +/// +/// This cache stores the intermediate trie nodes computed when calculating +/// the latest flashblock's state root. Subsequent bundle metering operations +/// on the same flashblock can reuse these cached nodes instead of recalculating +/// them, significantly improving performance. +/// +/// **Important**: This cache holds only ONE flashblock's trie at a time. +/// When a new flashblock is cached, it replaces any previously cached flashblock. +#[derive(Debug, Clone)] +pub struct FlashblockTrieCache { + cache: Arc>>, +} + +impl FlashblockTrieCache { + /// Creates a new empty flashblock trie cache. + pub fn new() -> Self { + Self { cache: Arc::new(ArcSwap::from_pointee(None)) } + } + + /// Ensures the trie for the given flashblock is cached and returns it. + /// + /// If the cache already contains an entry for the provided `block_hash` and + /// `flashblock_index`, the cached data is returned immediately. Otherwise the trie is + /// recomputed, cached (replacing any previous entry), and returned. + pub fn ensure_cached( + &self, + block_hash: B256, + flashblock_index: u64, + flashblocks_state: &FlashblocksState, + canonical_state_provider: &dyn StateProvider, + ) -> EyreResult { + if let Some(ref cached) = *self.cache.load() { + if cached.block_hash == block_hash && cached.flashblock_index == flashblock_index { + return Ok(cached.trie_data.clone()); + } + } + + let hashed_state = + canonical_state_provider.hashed_post_state(&flashblocks_state.bundle_state); + let (_state_root, trie_updates) = + canonical_state_provider.state_root_with_updates(hashed_state.clone())?; + + let trie_data = FlashblockTrieData { trie_updates, hashed_state }; + + self.cache.store(Arc::new(Some(CachedFlashblockTrie { + block_hash, + flashblock_index, + trie_data: trie_data.clone(), + }))); + + Ok(trie_data) + } +} + +impl Default for FlashblockTrieCache { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/metering/src/lib.rs b/crates/metering/src/lib.rs index 26681b91..2c687a22 100644 --- a/crates/metering/src/lib.rs +++ b/crates/metering/src/lib.rs @@ -1,8 +1,10 @@ +mod flashblock_trie_cache; mod meter; mod rpc; #[cfg(test)] mod tests; +pub use flashblock_trie_cache::{FlashblockTrieCache, FlashblockTrieData}; pub use meter::{meter_bundle, FlashblocksState, MeterBundleOutput}; pub use rpc::{MeteringApiImpl, MeteringApiServer}; pub use tips_core::types::{Bundle, MeterBundleResponse, TransactionResult}; diff --git a/crates/metering/src/meter.rs b/crates/metering/src/meter.rs index 0c37f8b4..1dffaa81 100644 --- a/crates/metering/src/meter.rs +++ b/crates/metering/src/meter.rs @@ -4,14 +4,15 @@ use alloy_consensus::{BlockHeader, Transaction as _, transaction::SignerRecovera use alloy_primitives::{B256, U256}; use eyre::{Result as EyreResult, eyre}; use reth::revm::db::{BundleState, Cache, CacheDB, State}; -use revm_database::states::bundle_state::BundleRetention; use reth_evm::{ConfigureEvm, execute::BlockBuilder}; use reth_optimism_chainspec::OpChainSpec; use reth_optimism_evm::{OpEvmConfig, OpNextBlockEnvAttributes}; use reth_primitives_traits::SealedHeader; +use reth_trie_common::TrieInput; +use revm_database::states::bundle_state::BundleRetention; use tips_core::types::{BundleExtensions, BundleTxs, ParsedBundle}; -use crate::TransactionResult; +use crate::{FlashblockTrieData, TransactionResult}; /// State from pending flashblocks that is used as a base for metering #[derive(Debug, Clone)] @@ -53,6 +54,7 @@ pub fn meter_bundle( bundle: ParsedBundle, header: &SealedHeader, flashblocks_state: Option, + cached_flashblock_trie: Option, ) -> EyreResult where SP: reth_provider::StateProvider, @@ -60,6 +62,17 @@ where // Get bundle hash let bundle_hash = bundle.bundle_hash(); + // If we have flashblocks but no cached trie, compute the flashblock trie first + // (before starting any timers, since we only want to time the bundle's execution and state root) + let flashblock_trie = if cached_flashblock_trie.is_none() && flashblocks_state.is_some() { + let fb_state = flashblocks_state.as_ref().unwrap(); + let fb_hashed_state = state_provider.hashed_post_state(&fb_state.bundle_state); + let (_fb_state_root, fb_trie_updates) = state_provider.state_root_with_updates(fb_hashed_state.clone())?; + Some((fb_trie_updates, fb_hashed_state)) + } else { + None + }; + // Create state database let state_db = reth::revm::database::StateProviderDatabase::new(state_provider); // If we have flashblocks state, apply both cache and bundle prestate @@ -148,11 +161,26 @@ where db.merge_transitions(BundleRetention::Reverts); let bundle_update = db.take_bundle(); let state_provider = db.database.db.as_ref(); + let state_root_start = Instant::now(); let hashed_state = state_provider.hashed_post_state(&bundle_update); - let _ = state_provider.state_root_with_updates(hashed_state); - let state_root_time_us = state_root_start.elapsed().as_micros(); + if let Some(cached) = cached_flashblock_trie { + // We have cached flashblock trie nodes, use them + let mut trie_input = TrieInput::from_state(hashed_state); + trie_input.prepend_cached(cached.trie_updates, cached.hashed_state); + let _ = state_provider.state_root_from_nodes_with_updates(trie_input)?; + } else if let Some((fb_trie_updates, fb_hashed_state)) = flashblock_trie { + // We computed the flashblock trie above, now use it + let mut trie_input = TrieInput::from_state(hashed_state); + trie_input.prepend_cached(fb_trie_updates, fb_hashed_state); + let _ = state_provider.state_root_from_nodes_with_updates(trie_input)?; + } else { + // No flashblocks, just calculate bundle state root + let _ = state_provider.state_root_with_updates(hashed_state)?; + } + + let state_root_time_us = state_root_start.elapsed().as_micros(); let total_execution_time_us = execution_start.elapsed().as_micros(); Ok(MeterBundleOutput { diff --git a/crates/metering/src/rpc.rs b/crates/metering/src/rpc.rs index f2faceb2..52dd8b5a 100644 --- a/crates/metering/src/rpc.rs +++ b/crates/metering/src/rpc.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use tips_core::types::{Bundle, MeterBundleResponse, ParsedBundle}; use tracing::{error, info}; -use crate::meter_bundle; +use crate::{meter_bundle, FlashblockTrieCache}; /// RPC API for transaction metering #[rpc(server, namespace = "base")] @@ -28,6 +28,8 @@ pub trait MeteringApi { pub struct MeteringApiImpl { provider: Provider, flashblocks_state: Arc, + /// Single-entry cache for the latest flashblock's trie nodes + trie_cache: FlashblockTrieCache, } impl MeteringApiImpl @@ -43,6 +45,7 @@ where Self { provider, flashblocks_state, + trie_cache: FlashblockTrieCache::new(), } } } @@ -145,6 +148,32 @@ where // Get the flashblock index if we have pending flashblocks let state_flashblock_index = pending_blocks.as_ref().map(|pb| pb.latest_flashblock_index()); + // If we have flashblocks, ensure the trie is cached and get it + let cached_trie = if let Some(ref fb_state) = flashblocks_state { + let fb_index = state_flashblock_index.unwrap(); + + // Ensure the flashblock trie is cached and return it + Some( + self.trie_cache + .ensure_cached( + header.hash(), + fb_index, + fb_state, + &*state_provider, + ) + .map_err(|e| { + error!(error = %e, "Failed to cache flashblock trie"); + jsonrpsee::types::ErrorObjectOwned::owned( + jsonrpsee::types::ErrorCode::InternalError.code(), + format!("Failed to cache flashblock trie: {}", e), + None::<()>, + ) + })?, + ) + } else { + None + }; + // Meter bundle using utility function let result = meter_bundle( state_provider, @@ -152,6 +181,7 @@ where parsed_bundle, &header, flashblocks_state, + cached_trie, ) .map_err(|e| { error!(error = %e, "Bundle metering failed"); diff --git a/crates/metering/src/tests/meter.rs b/crates/metering/src/tests/meter.rs index b96eb739..dbe959ef 100644 --- a/crates/metering/src/tests/meter.rs +++ b/crates/metering/src/tests/meter.rs @@ -141,6 +141,9 @@ fn meter_bundle_empty_transactions() -> eyre::Result<()> { parsed_bundle, &harness.header, None, + None, + None, + None, )?; assert!(output.results.is_empty()); @@ -189,6 +192,9 @@ fn meter_bundle_single_transaction() -> eyre::Result<()> { parsed_bundle, &harness.header, None, + None, + None, + None, )?; assert_eq!(output.results.len(), 1); @@ -272,6 +278,7 @@ fn meter_bundle_multiple_transactions() -> eyre::Result<()> { parsed_bundle, &harness.header, None, + None, )?; assert_eq!(output.results.len(), 2); @@ -348,6 +355,7 @@ fn meter_bundle_state_root_time_invariant() -> eyre::Result<()> { parsed_bundle, &harness.header, None, + None, )?; // Verify invariant: total execution time must include state root time From 2c8699f4a2e31369128ad587dd0d36cace77a974 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Thu, 30 Oct 2025 02:08:23 -0500 Subject: [PATCH 07/25] linter fixes --- crates/flashblocks-rpc/src/pending_blocks.rs | 5 +- crates/flashblocks-rpc/src/state.rs | 8 +- crates/metering/src/flashblock_trie_cache.rs | 1 + crates/metering/src/meter.rs | 14 ++- crates/metering/src/rpc.rs | 99 ++++++++++---------- crates/metering/src/tests/rpc.rs | 5 +- 6 files changed, 69 insertions(+), 63 deletions(-) diff --git a/crates/flashblocks-rpc/src/pending_blocks.rs b/crates/flashblocks-rpc/src/pending_blocks.rs index a440aeec..8279dda8 100644 --- a/crates/flashblocks-rpc/src/pending_blocks.rs +++ b/crates/flashblocks-rpc/src/pending_blocks.rs @@ -13,7 +13,10 @@ use arc_swap::Guard; use eyre::eyre; use op_alloy_network::Optimism; use op_alloy_rpc_types::{OpTransactionReceipt, Transaction}; -use reth::revm::{db::{BundleState, Cache}, state::EvmState}; +use reth::revm::{ + db::{BundleState, Cache}, + state::EvmState, +}; use reth_rpc_convert::RpcTransaction; use reth_rpc_eth_api::{RpcBlock, RpcReceipt}; diff --git a/crates/flashblocks-rpc/src/state.rs b/crates/flashblocks-rpc/src/state.rs index 03bbd3eb..99a573b3 100644 --- a/crates/flashblocks-rpc/src/state.rs +++ b/crates/flashblocks-rpc/src/state.rs @@ -36,12 +36,8 @@ use reth_optimism_evm::{OpEvmConfig, OpNextBlockEnvAttributes}; use reth_optimism_primitives::{DepositReceipt, OpBlock, OpPrimitives}; use reth_optimism_rpc::OpReceiptBuilder; use reth_primitives::RecoveredBlock; -use reth_rpc_convert::transaction::ConvertReceiptInput; -use tokio::sync::{ - Mutex, - broadcast::{self, Sender}, - mpsc::{self, UnboundedReceiver}, -}; +use reth_rpc_convert::{transaction::ConvertReceiptInput, RpcTransaction}; +use reth_rpc_eth_api::{RpcBlock, RpcReceipt}; use tracing::{debug, error, info, warn}; use crate::{ diff --git a/crates/metering/src/flashblock_trie_cache.rs b/crates/metering/src/flashblock_trie_cache.rs index 21aea516..5d1d5cd4 100644 --- a/crates/metering/src/flashblock_trie_cache.rs +++ b/crates/metering/src/flashblock_trie_cache.rs @@ -71,6 +71,7 @@ impl FlashblockTrieCache { let trie_data = FlashblockTrieData { trie_updates, hashed_state }; + // Store the new entry, replacing any previous flashblock's cached trie self.cache.store(Arc::new(Some(CachedFlashblockTrie { block_hash, flashblock_index, diff --git a/crates/metering/src/meter.rs b/crates/metering/src/meter.rs index 1dffaa81..e5a94aee 100644 --- a/crates/metering/src/meter.rs +++ b/crates/metering/src/meter.rs @@ -64,11 +64,15 @@ where // If we have flashblocks but no cached trie, compute the flashblock trie first // (before starting any timers, since we only want to time the bundle's execution and state root) - let flashblock_trie = if cached_flashblock_trie.is_none() && flashblocks_state.is_some() { - let fb_state = flashblocks_state.as_ref().unwrap(); - let fb_hashed_state = state_provider.hashed_post_state(&fb_state.bundle_state); - let (_fb_state_root, fb_trie_updates) = state_provider.state_root_with_updates(fb_hashed_state.clone())?; - Some((fb_trie_updates, fb_hashed_state)) + let flashblock_trie = if cached_flashblock_trie.is_none() { + if let Some(ref fb_state) = flashblocks_state { + let fb_hashed_state = state_provider.hashed_post_state(&fb_state.bundle_state); + let (_fb_state_root, fb_trie_updates) = + state_provider.state_root_with_updates(fb_hashed_state.clone())?; + Some((fb_trie_updates, fb_hashed_state)) + } else { + None + } } else { None }; diff --git a/crates/metering/src/rpc.rs b/crates/metering/src/rpc.rs index 52dd8b5a..ba48e602 100644 --- a/crates/metering/src/rpc.rs +++ b/crates/metering/src/rpc.rs @@ -74,49 +74,51 @@ where // Get header and flashblock index from pending blocks // If no pending blocks exist, fall back to latest canonical block - let (header, flashblock_index, canonical_block_number) = if let Some(pb) = pending_blocks.as_ref() { - let latest_header: Sealed
= pb.latest_header(); - let flashblock_index = pb.latest_flashblock_index(); - let canonical_block_number = pb.canonical_block_number(); - - info!( - latest_block = latest_header.number, - canonical_block = %canonical_block_number, - flashblock_index = flashblock_index, - "Using latest flashblock state for metering" - ); - - // Convert Sealed
to SealedHeader - let sealed_header = SealedHeader::new(latest_header.inner().clone(), latest_header.hash()); - (sealed_header, flashblock_index, canonical_block_number) - } else { - // No pending blocks, use latest canonical block - let canonical_block_number = pending_blocks.get_canonical_block_number(); - let header = self - .provider - .sealed_header_by_number_or_tag(canonical_block_number) - .map_err(|e| { - jsonrpsee::types::ErrorObjectOwned::owned( - jsonrpsee::types::ErrorCode::InternalError.code(), - format!("Failed to get canonical block header: {}", e), - None::<()>, - ) - })? - .ok_or_else(|| { - jsonrpsee::types::ErrorObjectOwned::owned( - jsonrpsee::types::ErrorCode::InternalError.code(), - "Canonical block not found".to_string(), - None::<()>, - ) - })?; - - info!( - canonical_block = header.number, - "No flashblocks available, using canonical block state for metering" - ); - - (header, 0, canonical_block_number) - }; + let (header, flashblock_index, canonical_block_number) = + if let Some(pb) = pending_blocks.as_ref() { + let latest_header: Sealed
= pb.latest_header(); + let flashblock_index = pb.latest_flashblock_index(); + let canonical_block_number = pb.canonical_block_number(); + + info!( + latest_block = latest_header.number, + canonical_block = %canonical_block_number, + flashblock_index = flashblock_index, + "Using latest flashblock state for metering" + ); + + // Convert Sealed
to SealedHeader + let sealed_header = + SealedHeader::new(latest_header.inner().clone(), latest_header.hash()); + (sealed_header, flashblock_index, canonical_block_number) + } else { + // No pending blocks, use latest canonical block + let canonical_block_number = pending_blocks.get_canonical_block_number(); + let header = self + .provider + .sealed_header_by_number_or_tag(canonical_block_number) + .map_err(|e| { + jsonrpsee::types::ErrorObjectOwned::owned( + jsonrpsee::types::ErrorCode::InternalError.code(), + format!("Failed to get canonical block header: {}", e), + None::<()>, + ) + })? + .ok_or_else(|| { + jsonrpsee::types::ErrorObjectOwned::owned( + jsonrpsee::types::ErrorCode::InternalError.code(), + "Canonical block not found".to_string(), + None::<()>, + ) + })?; + + info!( + canonical_block = header.number, + "No flashblocks available, using canonical block state for metering" + ); + + (header, 0, canonical_block_number) + }; let parsed_bundle = ParsedBundle::try_from(bundle).map_err(|e| { jsonrpsee::types::ErrorObjectOwned::owned( @@ -146,7 +148,9 @@ where }); // Get the flashblock index if we have pending flashblocks - let state_flashblock_index = pending_blocks.as_ref().map(|pb| pb.latest_flashblock_index()); + let state_flashblock_index = pending_blocks + .as_ref() + .map(|pb| pb.latest_flashblock_index()); // If we have flashblocks, ensure the trie is cached and get it let cached_trie = if let Some(ref fb_state) = flashblocks_state { @@ -155,12 +159,7 @@ where // Ensure the flashblock trie is cached and return it Some( self.trie_cache - .ensure_cached( - header.hash(), - fb_index, - fb_state, - &*state_provider, - ) + .ensure_cached(header.hash(), fb_index, fb_state, &*state_provider) .map_err(|e| { error!(error = %e, "Failed to cache flashblock trie"); jsonrpsee::types::ErrorObjectOwned::owned( diff --git a/crates/metering/src/tests/rpc.rs b/crates/metering/src/tests/rpc.rs index 3e1d9beb..5ad3b9e8 100644 --- a/crates/metering/src/tests/rpc.rs +++ b/crates/metering/src/tests/rpc.rs @@ -172,7 +172,10 @@ mod tests { assert!(response.total_execution_time_us > 0); // Verify state root time is present and non-zero - assert!(response.state_root_time_us > 0, "state_root_time_us should be greater than zero"); + assert!( + response.state_root_time_us > 0, + "state_root_time_us should be greater than zero" + ); // Verify invariant: total execution time includes state root time assert!( From afe211a56d7e74487170d6b6b892378c723a27ec Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Sun, 9 Nov 2025 14:04:16 -0600 Subject: [PATCH 08/25] Rename total_execution_time to total_time for clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The timer and field names now better reflect that they measure the total elapsed time (including both transaction execution and state root calculation), rather than just execution time. This distinguishes them from the per-transaction execution_time_us metrics. Changes: - Renamed execution_start → total_start timer - Renamed total_execution_time → total_time variable - Renamed total_execution_time_us → total_time_us in MeterBundleOutput - Updated logging and tests to use new naming - Added TODO comment for tips-core field rename --- crates/metering/src/meter.rs | 10 +++++----- crates/metering/src/rpc.rs | 5 +++-- crates/metering/src/tests/meter.rs | 14 +++++++------- crates/metering/src/tests/rpc.rs | 2 +- 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/crates/metering/src/meter.rs b/crates/metering/src/meter.rs index e5a94aee..a2055fcb 100644 --- a/crates/metering/src/meter.rs +++ b/crates/metering/src/meter.rs @@ -36,8 +36,8 @@ pub struct MeterBundleOutput { pub total_gas_fees: U256, /// Bundle hash pub bundle_hash: B256, - /// Total execution time in microseconds (includes state root calculation) - pub total_execution_time_us: u128, + /// Total time in microseconds (includes transaction execution and state root calculation) + pub total_time_us: u128, /// State root calculation time in microseconds pub state_root_time_us: u128, } @@ -120,7 +120,7 @@ where let mut total_gas_used = 0u64; let mut total_gas_fees = U256::ZERO; - let execution_start = Instant::now(); + let total_start = Instant::now(); { let evm_config = OpEvmConfig::optimism(chain_spec); let mut builder = evm_config.builder_for_next_block(&mut db, header, attributes)?; @@ -185,14 +185,14 @@ where } let state_root_time_us = state_root_start.elapsed().as_micros(); - let total_execution_time_us = execution_start.elapsed().as_micros(); + let total_time_us = total_start.elapsed().as_micros(); Ok(MeterBundleOutput { results, total_gas_used, total_gas_fees, bundle_hash, - total_execution_time_us, + total_time_us, state_root_time_us, }) } diff --git a/crates/metering/src/rpc.rs b/crates/metering/src/rpc.rs index ba48e602..81ee6a10 100644 --- a/crates/metering/src/rpc.rs +++ b/crates/metering/src/rpc.rs @@ -202,7 +202,7 @@ where bundle_hash = %result.bundle_hash, num_transactions = result.results.len(), total_gas_used = result.total_gas_used, - total_execution_time_us = result.total_execution_time_us, + total_time_us = result.total_time_us, state_root_time_us = result.state_root_time_us, state_block_number = header.number, flashblock_index = flashblock_index, @@ -219,7 +219,8 @@ where state_block_number: header.number, state_flashblock_index, total_gas_used: result.total_gas_used, - total_execution_time_us: result.total_execution_time_us, + // TODO: Rename to total_time_us in tips-core. + total_execution_time_us: result.total_time_us, state_root_time_us: result.state_root_time_us, }) } diff --git a/crates/metering/src/tests/meter.rs b/crates/metering/src/tests/meter.rs index dbe959ef..13c74761 100644 --- a/crates/metering/src/tests/meter.rs +++ b/crates/metering/src/tests/meter.rs @@ -150,7 +150,7 @@ fn meter_bundle_empty_transactions() -> eyre::Result<()> { assert_eq!(output.total_gas_used, 0); assert_eq!(output.total_gas_fees, U256::ZERO); // Even empty bundles have some EVM setup overhead - assert!(output.total_execution_time_us > 0); + assert!(output.total_time_us > 0); assert!(output.state_root_time_us > 0); assert_eq!(output.bundle_hash, keccak256([])); @@ -199,7 +199,7 @@ fn meter_bundle_single_transaction() -> eyre::Result<()> { assert_eq!(output.results.len(), 1); let result = &output.results[0]; - assert!(output.total_execution_time_us > 0); + assert!(output.total_time_us > 0); assert!(output.state_root_time_us > 0); assert_eq!(result.from_address, harness.address(User::Alice)); @@ -282,7 +282,7 @@ fn meter_bundle_multiple_transactions() -> eyre::Result<()> { )?; assert_eq!(output.results.len(), 2); - assert!(output.total_execution_time_us > 0); + assert!(output.total_time_us > 0); assert!(output.state_root_time_us > 0); // Check first transaction @@ -358,11 +358,11 @@ fn meter_bundle_state_root_time_invariant() -> eyre::Result<()> { None, )?; - // Verify invariant: total execution time must include state root time + // Verify invariant: total time must include state root time assert!( - output.total_execution_time_us >= output.state_root_time_us, - "total_execution_time_us ({}) should be >= state_root_time_us ({})", - output.total_execution_time_us, + output.total_time_us >= output.state_root_time_us, + "total_time_us ({}) should be >= state_root_time_us ({})", + output.total_time_us, output.state_root_time_us ); diff --git a/crates/metering/src/tests/rpc.rs b/crates/metering/src/tests/rpc.rs index 5ad3b9e8..4d10d6ec 100644 --- a/crates/metering/src/tests/rpc.rs +++ b/crates/metering/src/tests/rpc.rs @@ -177,7 +177,7 @@ mod tests { "state_root_time_us should be greater than zero" ); - // Verify invariant: total execution time includes state root time + // Verify invariant: total time includes state root time assert!( response.total_execution_time_us >= response.state_root_time_us, "total_execution_time_us should be >= state_root_time_us" From 76c6c0e257c0ecd184ec5545ee12127c1044d369 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Mon, 10 Nov 2025 13:16:16 -0600 Subject: [PATCH 09/25] Refactor flashblock trie data handling to reduce code duplication Consolidate the logic for using cached vs computed flashblock trie data by introducing a single flashblock_trie_data variable that handles both cases. This eliminates duplicate code paths in the state root calculation section. --- crates/metering/src/meter.rs | 40 +++++++++++++++++------------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/crates/metering/src/meter.rs b/crates/metering/src/meter.rs index a2055fcb..fcaa1d15 100644 --- a/crates/metering/src/meter.rs +++ b/crates/metering/src/meter.rs @@ -62,20 +62,23 @@ where // Get bundle hash let bundle_hash = bundle.bundle_hash(); - // If we have flashblocks but no cached trie, compute the flashblock trie first + // Consolidate flashblock trie data: use cached if available, otherwise compute it // (before starting any timers, since we only want to time the bundle's execution and state root) - let flashblock_trie = if cached_flashblock_trie.is_none() { - if let Some(ref fb_state) = flashblocks_state { - let fb_hashed_state = state_provider.hashed_post_state(&fb_state.bundle_state); - let (_fb_state_root, fb_trie_updates) = - state_provider.state_root_with_updates(fb_hashed_state.clone())?; - Some((fb_trie_updates, fb_hashed_state)) - } else { - None - } - } else { - None - }; + let flashblock_trie_data = cached_flashblock_trie + .map(Ok::<_, eyre::Report>) + .or_else(|| { + flashblocks_state.as_ref().map(|fb_state| { + // Compute the flashblock trie + let fb_hashed_state = state_provider.hashed_post_state(&fb_state.bundle_state); + let (_fb_state_root, fb_trie_updates) = + state_provider.state_root_with_updates(fb_hashed_state.clone())?; + Ok(crate::FlashblockTrieData { + trie_updates: fb_trie_updates, + hashed_state: fb_hashed_state, + }) + }) + }) + .transpose()?; // Create state database let state_db = reth::revm::database::StateProviderDatabase::new(state_provider); @@ -169,15 +172,10 @@ where let state_root_start = Instant::now(); let hashed_state = state_provider.hashed_post_state(&bundle_update); - if let Some(cached) = cached_flashblock_trie { - // We have cached flashblock trie nodes, use them - let mut trie_input = TrieInput::from_state(hashed_state); - trie_input.prepend_cached(cached.trie_updates, cached.hashed_state); - let _ = state_provider.state_root_from_nodes_with_updates(trie_input)?; - } else if let Some((fb_trie_updates, fb_hashed_state)) = flashblock_trie { - // We computed the flashblock trie above, now use it + if let Some(fb_trie_data) = flashblock_trie_data { + // We have flashblock trie data (either cached or computed), use it let mut trie_input = TrieInput::from_state(hashed_state); - trie_input.prepend_cached(fb_trie_updates, fb_hashed_state); + trie_input.prepend_cached(fb_trie_data.trie_updates, fb_trie_data.hashed_state); let _ = state_provider.state_root_from_nodes_with_updates(trie_input)?; } else { // No flashblocks, just calculate bundle state root From 62bd1c303936d7ee40edba30d40c3bd6fca43fa1 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Mon, 10 Nov 2025 18:25:19 -0600 Subject: [PATCH 10/25] Add chain-state metering tests and flashblock harness support --- Cargo.lock | 5 + crates/metering/Cargo.toml | 6 +- crates/metering/src/tests/chain_state.rs | 151 +++++ crates/metering/src/tests/mod.rs | 2 + crates/metering/src/tests/rpc.rs | 771 +++++++++++------------ crates/metering/src/tests/utils.rs | 85 ++- crates/test-utils/README.md | 80 +-- crates/test-utils/src/harness.rs | 117 ++-- crates/test-utils/src/node.rs | 490 +++++--------- 9 files changed, 861 insertions(+), 846 deletions(-) create mode 100644 crates/metering/src/tests/chain_state.rs diff --git a/Cargo.lock b/Cargo.lock index 47803e34..51a7c457 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1586,7 +1586,10 @@ dependencies = [ "alloy-eips", "alloy-genesis", "alloy-primitives", + "alloy-provider", "alloy-rpc-client", + "alloy-rpc-types-engine", + "arc-swap", "base-reth-flashblocks-rpc", "base-reth-test-utils", "eyre", @@ -1609,8 +1612,10 @@ dependencies = [ "reth-testing-utils", "reth-tracing", "reth-transaction-pool", + "reth-trie-common", "revm", "revm-database", + "rollup-boost", "serde", "serde_json", "tips-core", diff --git a/crates/metering/Cargo.toml b/crates/metering/Cargo.toml index 53230760..0014792c 100644 --- a/crates/metering/Cargo.toml +++ b/crates/metering/Cargo.toml @@ -65,5 +65,7 @@ reth-tracing.workspace = true reth-transaction-pool = { workspace = true, features = ["test-utils"] } serde_json.workspace = true tokio.workspace = true -base-reth-test-utils.workspace = true - +base-reth-test-utils = { path = "../test-utils" } +alloy-rpc-types-engine.workspace = true +rollup-boost.workspace = true +alloy-provider.workspace = true diff --git a/crates/metering/src/tests/chain_state.rs b/crates/metering/src/tests/chain_state.rs new file mode 100644 index 00000000..4f378951 --- /dev/null +++ b/crates/metering/src/tests/chain_state.rs @@ -0,0 +1,151 @@ +use alloy_consensus::Receipt; +use alloy_eips::Encodable2718; +use alloy_primitives::{Bytes, B256}; +use alloy_provider::Provider; +use base_reth_flashblocks_rpc::rpc::FlashblocksAPI; +use base_reth_test_utils::harness::TestHarness; +use base_reth_test_utils::node::{default_launcher, BASE_CHAIN_ID}; +use eyre::{eyre, Result}; +use op_alloy_consensus::OpTxEnvelope; +use reth::providers::HeaderProvider; +use reth_optimism_primitives::{OpReceipt, OpTransactionSigned}; +use reth_transaction_pool::test_utils::TransactionBuilder; +use tips_core::types::Bundle; + +use super::utils::{build_single_flashblock, secret_from_hex}; +use crate::rpc::{MeteringApiImpl, MeteringApiServer}; + +#[tokio::test] +async fn meters_bundle_after_advancing_blocks() -> Result<()> { + reth_tracing::init_test_tracing(); + let harness = TestHarness::new(default_launcher).await?; + + let provider = harness.provider(); + let bob = &harness.accounts().bob; + let alice_secret = secret_from_hex(harness.accounts().alice.private_key); + + let tx = TransactionBuilder::default() + .signer(alice_secret) + .chain_id(BASE_CHAIN_ID) + .nonce(0) + .to(bob.address) + .value(1) + .gas_limit(21_000) + .max_fee_per_gas(1_000_000_000) + .max_priority_fee_per_gas(1_000_000_000) + .into_eip1559(); + + let envelope = OpTxEnvelope::from(OpTransactionSigned::Eip1559( + tx.as_eip1559().unwrap().clone(), + )); + let tx_bytes = Bytes::from(envelope.encoded_2718()); + + harness.advance_chain(1).await?; + + let bundle = Bundle { + txs: vec![tx_bytes.clone()], + block_number: provider.get_block_number().await?, + 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![], + }; + + let metering_api = + MeteringApiImpl::new(harness.blockchain_provider(), harness.flashblocks_state()); + let response = MeteringApiServer::meter_bundle(&metering_api, bundle) + .await + .map_err(|err| eyre!("meter_bundle rpc failed: {}", err))?; + + assert_eq!(response.results.len(), 1); + assert_eq!(response.total_gas_used, 21_000); + assert!(response.state_flashblock_index.is_none()); + + Ok(()) +} + +#[tokio::test] +async fn pending_flashblock_updates_state() -> Result<()> { + reth_tracing::init_test_tracing(); + let harness = TestHarness::new(default_launcher).await?; + + let provider = harness.provider(); + let bob = &harness.accounts().bob; + let alice_secret = secret_from_hex(harness.accounts().alice.private_key); + + let tx = TransactionBuilder::default() + .signer(alice_secret) + .chain_id(BASE_CHAIN_ID) + .nonce(0) + .to(bob.address) + .value(1) + .gas_limit(21_000) + .max_fee_per_gas(1_000_000_000) + .max_priority_fee_per_gas(1_000_000_000) + .into_eip1559(); + + let blockchain_provider = harness.blockchain_provider(); + let latest_number = provider.get_block_number().await?; + let latest_header = blockchain_provider + .sealed_header(latest_number)? + .ok_or_else(|| eyre!("missing header for block {}", latest_number))?; + + let pending_block_number = latest_header.number + 1; + let envelope = OpTxEnvelope::from(OpTransactionSigned::Eip1559( + tx.as_eip1559().unwrap().clone(), + )); + let tx_hash = envelope.tx_hash(); + let tx_bytes = Bytes::from(envelope.encoded_2718()); + let receipt = OpReceipt::Eip1559(Receipt { + status: true.into(), + cumulative_gas_used: 21_000, + logs: vec![], + }); + + // Use a zero parent beacon block root to emulate a flashblock that predates Cancun data, + // which should cause metering to surface the missing-root error while still caching state. + let flashblock = build_single_flashblock( + pending_block_number, + latest_header.hash(), + B256::ZERO, + latest_header.timestamp + 2, + latest_header.gas_limit, + vec![(tx_bytes.clone(), Some((tx_hash, receipt.clone())))], + ); + + harness.send_flashblock(flashblock).await?; + + let bundle = Bundle { + txs: vec![tx_bytes.clone()], + block_number: pending_block_number, + 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![], + }; + + let metering_api = + MeteringApiImpl::new(blockchain_provider.clone(), harness.flashblocks_state()); + let result = MeteringApiServer::meter_bundle(&metering_api, bundle).await; + + let err = result.expect_err("pending flashblock metering should surface missing beacon root"); + assert!( + err.message().contains("parent beacon block root missing"), + "unexpected error: {err:?}" + ); + + let pending_blocks = harness.flashblocks_state().get_pending_blocks(); + assert!(pending_blocks.is_some(), "expected flashblock to populate pending state"); + assert_eq!( + pending_blocks.as_ref().unwrap().latest_flashblock_index(), + 0 + ); + + Ok(()) +} diff --git a/crates/metering/src/tests/mod.rs b/crates/metering/src/tests/mod.rs index 80d28813..af2a9605 100644 --- a/crates/metering/src/tests/mod.rs +++ b/crates/metering/src/tests/mod.rs @@ -1,4 +1,6 @@ #[cfg(test)] +mod chain_state; +#[cfg(test)] mod meter; #[cfg(test)] mod rpc; diff --git a/crates/metering/src/tests/rpc.rs b/crates/metering/src/tests/rpc.rs index 4d10d6ec..39a3b04c 100644 --- a/crates/metering/src/tests/rpc.rs +++ b/crates/metering/src/tests/rpc.rs @@ -1,445 +1,400 @@ -#[cfg(test)] -mod tests { - use std::{any::Any, net::SocketAddr, sync::Arc}; - - use alloy_eips::Encodable2718; - use alloy_genesis::Genesis; - use alloy_primitives::{Bytes, U256, address, b256, bytes}; - use alloy_rpc_client::RpcClient; - use base_reth_flashblocks_rpc::state::FlashblocksState; - use base_reth_test_utils::tracing::init_silenced_tracing; - use op_alloy_consensus::OpTxEnvelope; - use reth::{ - args::{DiscoveryArgs, NetworkArgs, RpcServerArgs}, - builder::{Node, NodeBuilder, NodeConfig, NodeHandle}, - chainspec::Chain, - core::exit::NodeExitFuture, - tasks::TaskManager, - }; - use reth_optimism_chainspec::OpChainSpecBuilder; - use reth_optimism_node::{OpNode, args::RollupArgs}; - use reth_optimism_primitives::OpTransactionSigned; - use reth_provider::providers::BlockchainProvider; - use reth_transaction_pool::test_utils::TransactionBuilder; - use serde_json; - use tips_core::types::Bundle; - - use crate::rpc::{MeteringApiImpl, MeteringApiServer}; - - pub struct NodeContext { - http_api_addr: SocketAddr, - _node_exit_future: NodeExitFuture, - _node: Box, +use crate::rpc::{MeteringApiImpl, MeteringApiServer}; +use alloy_consensus::Receipt; +use alloy_eips::Encodable2718; +use alloy_primitives::{address, Bytes, B256, U256}; +use alloy_provider::Provider; +use base_reth_flashblocks_rpc::rpc::FlashblocksAPI; +use base_reth_test_utils::harness::TestHarness; +use base_reth_test_utils::node::{ + default_launcher, LocalFlashblocksState, LocalNodeProvider, BASE_CHAIN_ID, +}; +use eyre::{eyre, Result}; +use op_alloy_consensus::OpTxEnvelope; +use reth_optimism_primitives::{OpReceipt, OpTransactionSigned}; +use reth_provider::HeaderProvider; +use reth_transaction_pool::test_utils::TransactionBuilder; +use tips_core::types::Bundle; + +use super::utils::{build_single_flashblock, secret_from_hex}; + +struct RpcTestContext { + harness: TestHarness, + api: MeteringApiImpl, +} + +impl RpcTestContext { + async fn new() -> Result { + let harness = TestHarness::new(default_launcher).await?; + let provider = harness.blockchain_provider(); + let flashblocks_state = harness.flashblocks_state(); + let api = MeteringApiImpl::new(provider, flashblocks_state); + + Ok(Self { harness, api }) } - // 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![], - } + fn accounts(&self) -> &base_reth_test_utils::accounts::TestAccounts { + self.harness.accounts() } - impl NodeContext { - pub async fn rpc_client(&self) -> eyre::Result { - let url = format!("http://{}", self.http_api_addr); - let client = RpcClient::new_http(url.parse()?); - Ok(client) - } + fn harness(&self) -> &TestHarness { + &self.harness } - async fn setup_node() -> eyre::Result { - init_silenced_tracing(); - let tasks = TaskManager::current(); - let exec = tasks.executor(); - const BASE_SEPOLIA_CHAIN_ID: u64 = 84532; - const MAX_PENDING_BLOCKS_DEPTH: u64 = 5; - - let genesis: Genesis = serde_json::from_str(include_str!("assets/genesis.json")).unwrap(); - let chain_spec = Arc::new( - OpChainSpecBuilder::base_mainnet() - .genesis(genesis) - .ecotone_activated() - .chain(Chain::from(BASE_SEPOLIA_CHAIN_ID)) - .build(), - ); - - let network_config = NetworkArgs { - discovery: DiscoveryArgs { disable_discovery: true, ..DiscoveryArgs::default() }, - ..NetworkArgs::default() - }; - - let node_config = NodeConfig::new(chain_spec.clone()) - .with_network(network_config.clone()) - .with_rpc(RpcServerArgs::default().with_unused_ports().with_http()) - .with_unused_ports(); - - let node = OpNode::new(RollupArgs::default()); - - let NodeHandle { node, node_exit_future } = NodeBuilder::new(node_config.clone()) - .testing_node(exec.clone()) - .with_types_and_provider::>() - .with_components(node.components_builder()) - .with_add_ons(node.add_ons()) - .extend_rpc_modules(move |ctx| { - // Create a FlashblocksState without starting it (no pending blocks for testing) - let flashblocks_state = Arc::new(FlashblocksState::new( - ctx.provider().clone(), - MAX_PENDING_BLOCKS_DEPTH, - )); - let metering_api = MeteringApiImpl::new(ctx.provider().clone(), flashblocks_state); - ctx.modules.merge_configured(metering_api.into_rpc())?; - Ok(()) - }) - .launch() - .await?; - - let http_api_addr = node - .rpc_server_handle() - .http_local_addr() - .ok_or_else(|| eyre::eyre!("Failed to get http api address"))?; - - Ok(NodeContext { - http_api_addr, - _node_exit_future: node_exit_future, - _node: Box::new(node), - }) + async fn meter_bundle(&self, bundle: Bundle) -> Result { + MeteringApiServer::meter_bundle(&self.api, bundle) + .await + .map_err(|err| eyre!("meter_bundle rpc failed: {}", err)) } - #[tokio::test] - async fn test_meter_bundle_empty() -> eyre::Result<()> { - let node = setup_node().await?; - let client = node.rpc_client().await?; + async fn meter_bundle_raw( + &self, + bundle: Bundle, + ) -> jsonrpsee::core::RpcResult { + MeteringApiServer::meter_bundle(&self.api, bundle).await + } +} - let bundle = create_bundle(vec![], 0, None); +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![], + } +} - let response: crate::MeterBundleResponse = - client.request("base_meterBundle", (bundle,)).await?; +#[tokio::test] +async fn test_meter_bundle_empty() -> Result<()> { + reth_tracing::init_test_tracing(); + let ctx = RpcTestContext::new().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); + let bundle = create_bundle(vec![], 0, None); + let response = ctx.meter_bundle(bundle).await?; - Ok(()) - } + assert_eq!(response.results.len(), 0); + assert_eq!(response.total_gas_used, 0); + assert_eq!(response.gas_fees, "0"); - #[tokio::test] - async fn test_meter_bundle_single_transaction() -> eyre::Result<()> { - let node = setup_node().await?; - let client = node.rpc_client().await?; - - // Use a funded account from genesis.json - // Account: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 - // Private key from common test accounts (Hardhat account #0) - let sender_address = address!("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"); - let sender_secret = - b256!("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"); - - // Build a transaction - let tx = TransactionBuilder::default() - .signer(sender_secret) - .chain_id(84532) - .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: crate::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); - - // Verify state root time is present and non-zero - assert!( - response.state_root_time_us > 0, - "state_root_time_us should be greater than zero" - ); - - // Verify invariant: total time includes state root time - assert!( - response.total_execution_time_us >= response.state_root_time_us, - "total_execution_time_us should be >= state_root_time_us" - ); - - 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(()) - } + let latest_block = ctx.harness().provider().get_block_number().await?; + assert_eq!(response.state_block_number, latest_block); - #[tokio::test] - async fn test_meter_bundle_multiple_transactions() -> eyre::Result<()> { - let node = setup_node().await?; - let client = node.rpc_client().await?; - - // Use funded accounts from genesis.json - // Hardhat account #0 and #1 - let address1 = address!("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"); - let secret1 = b256!("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"); - - let tx1_inner = TransactionBuilder::default() - .signer(secret1) - .chain_id(84532) - .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 = address!("0x70997970C51812dc3A010C7d01b50e0d17dc79C8"); - let secret2 = b256!("0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"); - - let tx2_inner = TransactionBuilder::default() - .signer(secret2) - .chain_id(84532) - .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: crate::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(()) - } + Ok(()) +} - #[tokio::test] - async fn test_meter_bundle_invalid_transaction() -> eyre::Result<()> { - let node = setup_node().await?; - let client = node.rpc_client().await?; +#[tokio::test] +async fn test_meter_bundle_single_transaction() -> Result<()> { + reth_tracing::init_test_tracing(); + let ctx = RpcTestContext::new().await?; + + let sender_account = &ctx.accounts().alice; + let sender_address = sender_account.address; + let sender_secret = secret_from_hex(sender_account.private_key); + + let tx = TransactionBuilder::default() + .signer(sender_secret) + .chain_id(BASE_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 signed_tx = OpTransactionSigned::Eip1559(tx.as_eip1559().unwrap().clone()); + let envelope: OpTxEnvelope = signed_tx.into(); + let tx_bytes = Bytes::from(envelope.encoded_2718()); + + let bundle = create_bundle(vec![tx_bytes], 0, None); + let response = ctx.meter_bundle(bundle).await?; + + assert_eq!(response.results.len(), 1); + assert_eq!(response.total_gas_used, 21_000); + assert!(response.total_execution_time_us > 0); + assert!( + response.state_root_time_us > 0, + "state_root_time_us should be greater than zero" + ); + + 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, "1000000000"); + assert!(result.execution_time_us > 0); + + Ok(()) +} - let bundle = create_bundle( - vec![bytes!("0xdeadbeef")], // Invalid transaction data - 0, - None, - ); +#[tokio::test] +async fn test_meter_bundle_multiple_transactions() -> Result<()> { + reth_tracing::init_test_tracing(); + let ctx = RpcTestContext::new().await?; + + let secret1 = secret_from_hex(ctx.accounts().alice.private_key); + let secret2 = secret_from_hex(ctx.accounts().bob.private_key); + + let tx1 = TransactionBuilder::default() + .signer(secret1) + .chain_id(BASE_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_bytes = Bytes::from( + OpTxEnvelope::from(OpTransactionSigned::Eip1559( + tx1.as_eip1559().unwrap().clone(), + )) + .encoded_2718(), + ); + + let tx2 = TransactionBuilder::default() + .signer(secret2) + .chain_id(BASE_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_bytes = Bytes::from( + OpTxEnvelope::from(OpTransactionSigned::Eip1559( + tx2.as_eip1559().unwrap().clone(), + )) + .encoded_2718(), + ); + + let bundle = create_bundle(vec![tx1_bytes, tx2_bytes], 0, None); + let response = ctx.meter_bundle(bundle).await?; + + assert_eq!(response.results.len(), 2); + assert_eq!(response.total_gas_used, 42_000); + + let result1 = &response.results[0]; + assert_eq!(result1.from_address, ctx.accounts().alice.address); + assert_eq!(result1.gas_price, "1000000000"); + + let result2 = &response.results[1]; + assert_eq!(result2.from_address, ctx.accounts().bob.address); + assert_eq!(result2.gas_price, "2000000000"); + + Ok(()) +} - let result: Result = - client.request("base_meterBundle", (bundle,)).await; +#[tokio::test] +async fn test_meter_bundle_invalid_transaction() -> Result<()> { + reth_tracing::init_test_tracing(); + let ctx = RpcTestContext::new().await?; - assert!(result.is_err()); + let bundle = create_bundle(vec![Bytes::from_static(b"\xde\xad\xbe\xef")], 0, None); + let result = ctx.meter_bundle_raw(bundle).await; - Ok(()) - } + assert!(result.is_err(), "expected invalid transaction to fail"); + Ok(()) +} - #[tokio::test] - async fn test_meter_bundle_uses_latest_block() -> eyre::Result<()> { - let node = setup_node().await?; - let client = node.rpc_client().await?; +#[tokio::test] +async fn test_meter_bundle_uses_latest_block() -> Result<()> { + reth_tracing::init_test_tracing(); + let ctx = RpcTestContext::new().await?; - // Metering always uses the latest block state, regardless of bundle.block_number - let bundle = create_bundle(vec![], 0, None); + ctx.harness().advance_chain(2).await?; - let response: crate::MeterBundleResponse = - client.request("base_meterBundle", (bundle,)).await?; + let bundle = create_bundle(vec![], 0, None); + let response = ctx.meter_bundle(bundle).await?; - // Should return the latest block number (genesis block 0) - assert_eq!(response.state_block_number, 0); + let latest_block = ctx.harness().provider().get_block_number().await?; + assert_eq!(response.state_block_number, latest_block); - Ok(()) - } + Ok(()) +} - #[tokio::test] - async fn test_meter_bundle_ignores_bundle_block_number() -> eyre::Result<()> { - let node = setup_node().await?; - let client = node.rpc_client().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: crate::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: crate::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_ignores_bundle_block_number() -> Result<()> { + reth_tracing::init_test_tracing(); + let ctx = RpcTestContext::new().await?; - #[tokio::test] - async fn test_meter_bundle_custom_timestamp() -> eyre::Result<()> { - let node = setup_node().await?; - let client = node.rpc_client().await?; + let bundle1 = create_bundle(vec![], 0, None); + let response1 = ctx.meter_bundle(bundle1).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 bundle2 = create_bundle(vec![], 999, None); + let response2 = ctx.meter_bundle(bundle2).await?; - let response: crate::MeterBundleResponse = - client.request("base_meterBundle", (bundle,)).await?; + assert_eq!(response1.state_block_number, response2.state_block_number); + let latest_block = ctx.harness().provider().get_block_number().await?; + assert_eq!(response1.state_block_number, latest_block); - // Verify the request succeeded with custom timestamp - assert_eq!(response.results.len(), 0); - assert_eq!(response.total_gas_used, 0); + Ok(()) +} - Ok(()) - } +#[tokio::test] +async fn test_meter_bundle_custom_timestamp() -> Result<()> { + reth_tracing::init_test_tracing(); + let ctx = RpcTestContext::new().await?; + + let custom_timestamp = 1_234_567_890; + let bundle = create_bundle(vec![], 0, Some(custom_timestamp)); + let response = ctx.meter_bundle(bundle).await?; - #[tokio::test] - async fn test_meter_bundle_arbitrary_block_number() -> eyre::Result<()> { - let node = setup_node().await?; - let client = node.rpc_client().await?; + assert_eq!(response.results.len(), 0); + assert_eq!(response.total_gas_used, 0); - // 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); + Ok(()) +} - let response: crate::MeterBundleResponse = - client.request("base_meterBundle", (bundle,)).await?; +#[tokio::test] +async fn test_meter_bundle_arbitrary_block_number() -> Result<()> { + reth_tracing::init_test_tracing(); + let ctx = RpcTestContext::new().await?; - // Should succeed and use the latest block (genesis block 0) - assert_eq!(response.state_block_number, 0); + let bundle = create_bundle(vec![], 999_999, None); + let response = ctx.meter_bundle(bundle).await?; - Ok(()) - } + let latest_block = ctx.harness().provider().get_block_number().await?; + assert_eq!(response.state_block_number, latest_block); - #[tokio::test] - async fn test_meter_bundle_gas_calculations() -> eyre::Result<()> { - let node = setup_node().await?; - let client = node.rpc_client().await?; - - // Use two funded accounts from genesis.json with different gas prices - let secret1 = b256!("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"); - let secret2 = b256!("0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"); - - // First transaction with 3 gwei gas price - let tx1_inner = TransactionBuilder::default() - .signer(secret1) - .chain_id(84532) - .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(84532) - .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: crate::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(()) - } + Ok(()) +} + +#[tokio::test] +async fn test_meter_bundle_gas_calculations() -> Result<()> { + reth_tracing::init_test_tracing(); + let ctx = RpcTestContext::new().await?; + + let secret1 = secret_from_hex(ctx.accounts().alice.private_key); + let secret2 = secret_from_hex(ctx.accounts().bob.private_key); + + let tx1 = TransactionBuilder::default() + .signer(secret1) + .chain_id(BASE_CHAIN_ID) + .nonce(0) + .to(address!("0x1111111111111111111111111111111111111111")) + .value(1000) + .gas_limit(21_000) + .max_fee_per_gas(3_000_000_000) + .max_priority_fee_per_gas(3_000_000_000) + .into_eip1559(); + let tx1_bytes = Bytes::from( + OpTxEnvelope::from(OpTransactionSigned::Eip1559( + tx1.as_eip1559().unwrap().clone(), + )) + .encoded_2718(), + ); + + let tx2 = TransactionBuilder::default() + .signer(secret2) + .chain_id(BASE_CHAIN_ID) + .nonce(0) + .to(address!("0x2222222222222222222222222222222222222222")) + .value(2000) + .gas_limit(21_000) + .max_fee_per_gas(7_000_000_000) + .max_priority_fee_per_gas(7_000_000_000) + .into_eip1559(); + let tx2_bytes = Bytes::from( + OpTxEnvelope::from(OpTransactionSigned::Eip1559( + tx2.as_eip1559().unwrap().clone(), + )) + .encoded_2718(), + ); + + let bundle = create_bundle(vec![tx1_bytes, tx2_bytes], 0, None); + let response = ctx.meter_bundle(bundle).await?; + + assert_eq!(response.results.len(), 2); + + let expected_fees_1 = U256::from(21_000) * U256::from(3_000_000_000u64); + let expected_fees_2 = U256::from(21_000) * U256::from(7_000_000_000u64); + + assert_eq!(response.results[0].gas_fees, expected_fees_1.to_string()); + assert_eq!(response.results[0].gas_price, "3000000000"); + assert_eq!(response.results[1].gas_fees, expected_fees_2.to_string()); + assert_eq!(response.results[1].gas_price, "7000000000"); + + let total_fees = expected_fees_1 + expected_fees_2; + assert_eq!(response.gas_fees, total_fees.to_string()); + assert_eq!(response.coinbase_diff, total_fees.to_string()); + assert_eq!(response.total_gas_used, 42_000); + assert_eq!(response.bundle_gas_price, "5000000000"); + + Ok(()) +} + +#[tokio::test] +async fn flashblock_without_beacon_root_errors() -> Result<()> { + reth_tracing::init_test_tracing(); + let ctx = RpcTestContext::new().await?; + + let provider = ctx.harness().provider(); + let latest_block = provider.get_block_number().await?; + let blockchain_provider = ctx.harness().blockchain_provider(); + let latest_header = blockchain_provider + .sealed_header(latest_block)? + .ok_or_else(|| eyre!("missing header for block {}", latest_block))?; + + let alice_secret = secret_from_hex(ctx.accounts().alice.private_key); + let tx = TransactionBuilder::default() + .signer(alice_secret) + .chain_id(BASE_CHAIN_ID) + .nonce(0) + .to(ctx.accounts().bob.address) + .value(1) + .gas_limit(21_000) + .max_fee_per_gas(1_000_000_000) + .max_priority_fee_per_gas(1_000_000_000) + .into_eip1559(); + + let envelope = OpTxEnvelope::from(OpTransactionSigned::Eip1559( + tx.as_eip1559().unwrap().clone(), + )); + let tx_hash = envelope.tx_hash(); + let tx_bytes = Bytes::from(envelope.encoded_2718()); + let receipt = OpReceipt::Eip1559(Receipt { + status: true.into(), + cumulative_gas_used: 21_000, + logs: vec![], + }); + + // Zero-out the parent beacon block root to emulate a flashblock that lacks Cancun data. + let flashblock = build_single_flashblock( + latest_header.number + 1, + latest_header.hash(), + B256::ZERO, + latest_header.timestamp + 2, + latest_header.gas_limit, + vec![(tx_bytes.clone(), Some((tx_hash, receipt.clone())))], + ); + + ctx.harness().send_flashblock(flashblock).await?; + + let bundle = create_bundle(vec![tx_bytes], latest_header.number + 1, None); + let err = ctx + .meter_bundle_raw(bundle) + .await + .expect_err("pending flashblock metering should fail without beacon root"); + assert!(err.message().contains("parent beacon block root missing")); + + let pending_blocks = ctx.harness().flashblocks_state().get_pending_blocks(); + assert!( + pending_blocks.is_some(), + "flashblock should populate pending state" + ); + assert_eq!( + pending_blocks.as_ref().unwrap().latest_flashblock_index(), + 0 + ); + + Ok(()) } diff --git a/crates/metering/src/tests/utils.rs b/crates/metering/src/tests/utils.rs index 7bd29fef..0f6f8ffd 100644 --- a/crates/metering/src/tests/utils.rs +++ b/crates/metering/src/tests/utils.rs @@ -1,12 +1,31 @@ use std::sync::Arc; +use alloy_consensus::Receipt; +use alloy_primitives::{b256, bytes, hex::FromHex, map::HashMap, Address, Bytes, B256, U256}; +use alloy_rpc_types_engine::PayloadId; +use base_reth_flashblocks_rpc::subscription::{Flashblock, Metadata}; +use op_alloy_consensus::OpDepositReceipt; use reth::api::{NodeTypes, NodeTypesWithDBAdapter}; use reth_db::{ - ClientVersion, DatabaseEnv, init_db, - mdbx::{DatabaseArguments, KILOBYTE, MEGABYTE, MaxReadTransactionDuration}, - test_utils::{ERROR_DB_CREATION, TempDatabase, create_test_static_files_dir, tempdir_path}, + init_db, + mdbx::{DatabaseArguments, MaxReadTransactionDuration, KILOBYTE, MEGABYTE}, + test_utils::{create_test_static_files_dir, tempdir_path, TempDatabase, ERROR_DB_CREATION}, + ClientVersion, DatabaseEnv, }; -use reth_provider::{ProviderFactory, providers::StaticFileProvider}; +use reth_optimism_primitives::OpReceipt; +use reth_provider::{providers::StaticFileProvider, ProviderFactory}; +use rollup_boost::{ExecutionPayloadBaseV1, ExecutionPayloadFlashblockDeltaV1}; + +const FLASHBLOCK_PAYLOAD_ID: [u8; 8] = [0; 8]; +// Pre-captured deposit transaction and hash used by flashblocks tests for the L1 block info deposit. +// Values match `BLOCK_INFO_TXN` and `BLOCK_INFO_TXN_HASH` from `crates/flashblocks-rpc/src/tests/mod.rs`. +const BLOCK_INFO_DEPOSIT_TX: Bytes = bytes!("0x7ef90104a06c0c775b6b492bab9d7e81abdf27f77cafb698551226455a82f559e0f93fea3794deaddeaddeaddeaddeaddeaddeaddeaddead00019442000000000000000000000000000000000000158080830f424080b8b0098999be000008dd00101c1200000000000000020000000068869d6300000000015f277f000000000000000000000000000000000000000000000000000000000d42ac290000000000000000000000000000000000000000000000000000000000000001abf52777e63959936b1bf633a2a643f0da38d63deffe49452fed1bf8a44975d50000000000000000000000005050f69a9786f081509234f1a7f4684b5e5b76c9000000000000000000000000"); +const BLOCK_INFO_DEPOSIT_TX_HASH: B256 = + b256!("0xba56c8b0deb460ff070f8fca8e2ee01e51a3db27841cc862fdd94cc1a47662b6"); + +pub fn secret_from_hex(hex_key: &str) -> B256 { + B256::from_hex(hex_key).expect("32-byte private key") +} pub fn create_provider_factory( chain_spec: Arc, @@ -35,3 +54,61 @@ fn create_test_db() -> Arc> { Arc::new(TempDatabase::new(db, path)) } + +pub fn build_single_flashblock( + block_number: u64, + parent_hash: B256, + parent_beacon_block_root: B256, + timestamp: u64, + gas_limit: u64, + transactions: Vec<(Bytes, Option<(B256, OpReceipt)>)>, +) -> Flashblock { + let base = ExecutionPayloadBaseV1 { + parent_beacon_block_root, + parent_hash, + fee_recipient: Address::ZERO, + prev_randao: B256::ZERO, + block_number, + gas_limit, + timestamp, + extra_data: Bytes::new(), + base_fee_per_gas: U256::from(1), + }; + + let mut flashblock_txs = vec![BLOCK_INFO_DEPOSIT_TX.clone()]; + let mut receipts = HashMap::default(); + receipts.insert( + BLOCK_INFO_DEPOSIT_TX_HASH, + OpReceipt::Deposit(OpDepositReceipt { + inner: Receipt { + status: true.into(), + cumulative_gas_used: 10_000, + logs: vec![], + }, + deposit_nonce: Some(4_012_991u64), + deposit_receipt_version: None, + }), + ); + + for (tx_bytes, maybe_receipt) in transactions { + if let Some((hash, receipt)) = maybe_receipt { + receipts.insert(hash, receipt); + } + flashblock_txs.push(tx_bytes); + } + + Flashblock { + payload_id: PayloadId::new(FLASHBLOCK_PAYLOAD_ID), + index: 0, + base: Some(base), + diff: ExecutionPayloadFlashblockDeltaV1 { + transactions: flashblock_txs, + ..Default::default() + }, + metadata: Metadata { + receipts, + new_account_balances: Default::default(), + block_number, + }, + } +} diff --git a/crates/test-utils/README.md b/crates/test-utils/README.md index 70f7f37e..1f1cc7fd 100644 --- a/crates/test-utils/README.md +++ b/crates/test-utils/README.md @@ -15,7 +15,7 @@ This crate provides reusable testing utilities for integration tests across the ## Quick Start ```rust -use base_reth_test_utils::harness::TestHarness; +use base_reth_test_utils::TestHarness; #[tokio::test] async fn test_example() -> eyre::Result<()> { @@ -44,7 +44,6 @@ The framework follows a three-layer architecture: │ - Coordinates node + engine │ │ - Builds blocks from transactions │ │ - Manages test accounts │ -│ - Manages flashblocks │ └─────────────────────────────────────┘ │ │ ┌──────┘ └──────┐ @@ -65,10 +64,10 @@ The framework follows a three-layer architecture: ### 1. TestHarness -The main entry point for integration tests that only need canonical chain control. Combines node, engine, and accounts into a single interface. +The main entry point for integration tests. Combines node, engine, and accounts into a single interface. ```rust -use base_reth_test_utils::harness::TestHarness; +use base_reth_test_utils::TestHarness; use alloy_primitives::Bytes; #[tokio::test] @@ -90,18 +89,24 @@ async fn test_harness() -> eyre::Result<()> { let txs: Vec = vec![/* signed transaction bytes */]; harness.build_block_from_transactions(txs).await?; + // Build block from flashblocks + harness.build_block_from_flashblocks(&flashblocks).await?; + + // Send flashblocks for pending state testing + harness.send_flashblock(flashblock).await?; + Ok(()) } ``` -> Need pending-state testing? Use `FlashblocksHarness` (see Flashblocks section below) to gain `send_flashblock` helpers. - **Key Methods:** - `new()` - Create new harness with node, engine, and accounts - `provider()` - Get Alloy RootProvider for RPC calls - `accounts()` - Access test accounts - `advance_chain(n)` - Build N empty blocks -- `build_block_from_transactions(txs)` - Build block with specific transactions (auto-prepends the L1 block info deposit) +- `build_block_from_transactions(txs)` - Build block with specific transactions +- `send_flashblock(fb)` - Send a single flashblock to the node for pending state processing +- `send_flashblocks(iter)` - Convenience helper that sends multiple flashblocks sequentially **Block Building Process:** 1. Fetches latest block header from provider (no local state tracking) @@ -117,35 +122,32 @@ async fn test_harness() -> eyre::Result<()> { In-process Optimism node with Base Sepolia configuration. ```rust -use base_reth_test_utils::node::LocalNode; +use base_reth_test_utils::LocalNode; #[tokio::test] async fn test_node() -> eyre::Result<()> { - let node = LocalNode::new(default_launcher).await?; + let node = LocalNode::new().await?; + // Get provider let provider = node.provider()?; + + // Get Engine API let engine = node.engine_api()?; + // Send flashblocks + node.send_flashblock(flashblock).await?; + Ok(()) } ``` -**Features (base):** +**Features:** - Base Sepolia chain configuration - Disabled P2P discovery (isolated testing) - Random unused ports (parallel test safety) - HTTP RPC server at `node.http_api_addr` - Engine API IPC at `node.engine_ipc_path` - -For flashblocks-enabled nodes, use `FlashblocksLocalNode`: - -```rust -use base_reth_test_utils::node::FlashblocksLocalNode; - -let node = FlashblocksLocalNode::new().await?; -let pending_state = node.flashblocks_state(); -node.send_flashblock(flashblock).await?; -``` +- Flashblocks-canon ExEx integration **Note:** Most tests should use `TestHarness` instead of `LocalNode` directly. @@ -154,7 +156,7 @@ node.send_flashblock(flashblock).await?; Type-safe Engine API client wrapping raw CL operations. ```rust -use base_reth_test_utils::engine::EngineApi; +use base_reth_test_utils::EngineApi; use alloy_primitives::B256; use op_alloy_rpc_types_engine::OpPayloadAttributes; @@ -179,8 +181,7 @@ let status = engine.new_payload(payload, vec![], parent_root, requests).await?; Hardcoded test accounts with deterministic addresses (Anvil-compatible). ```rust -use base_reth_test_utils::accounts::TestAccounts; -use base_reth_test_utils::harness::TestHarness; +use base_reth_test_utils::TestAccounts; let accounts = TestAccounts::new(); @@ -208,27 +209,33 @@ Each account includes: ### 5. Flashblocks Support -Use `FlashblocksHarness` when you need `send_flashblock` and access to the in-memory pending state. +Test flashblocks delivery without WebSocket connections. ```rust -use base_reth_test_utils::flashblocks_harness::FlashblocksHarness; +use base_reth_test_utils::{FlashblocksContext, FlashblockBuilder}; #[tokio::test] async fn test_flashblocks() -> eyre::Result<()> { - let harness = FlashblocksHarness::new().await?; + let (fb_ctx, receiver) = FlashblocksContext::new(); - harness.send_flashblock(flashblock).await?; + // Create base flashblock + let flashblock = FlashblockBuilder::new(1, 0) + .as_base(B256::ZERO, 1000) + .with_transaction(tx_bytes, tx_hash, 21000) + .with_balance(address, U256::from(1000)) + .build(); - let pending = harness.flashblocks_state(); - // assertions... + fb_ctx.send_flashblock(flashblock).await?; Ok(()) } ``` -`FlashblocksHarness` derefs to the base `TestHarness`, so you can keep using methods like `provider()`, `build_block_from_transactions`, etc. - -Test flashblocks delivery without WebSocket connections by constructing payloads and sending them through `FlashblocksHarness` (or the lower-level `FlashblocksLocalNode`). +**Via TestHarness:** +```rust +let harness = TestHarness::new().await?; +harness.send_flashblock(flashblock).await?; +``` ## Configuration Constants @@ -250,8 +257,8 @@ test-utils/ │ ├── accounts.rs # Test account definitions │ ├── node.rs # LocalNode (EL wrapper) │ ├── engine.rs # EngineApi (CL wrapper) -│ ├── harness.rs # TestHarness (orchestration) -│ └── flashblocks_harness.rs # FlashblocksHarness + helpers +│ ├── harness.rs # TestHarness (orchestration) +│ └── flashblocks.rs # Flashblocks support ├── assets/ │ └── genesis.json # Base Sepolia genesis └── Cargo.toml @@ -269,10 +276,12 @@ base-reth-test-utils.workspace = true Import in tests: ```rust -use base_reth_test_utils::harness::TestHarness; +use base_reth_test_utils::TestHarness; #[tokio::test] async fn my_test() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + let harness = TestHarness::new().await?; // Your test logic @@ -309,7 +318,6 @@ cargo test -p base-reth-test-utils test_harness_setup - Snapshot/restore functionality - Multi-node network simulation - Performance benchmarking utilities -- Helper builder for Flashblocks ## References diff --git a/crates/test-utils/src/harness.rs b/crates/test-utils/src/harness.rs index be656c82..aade0c2b 100644 --- a/crates/test-utils/src/harness.rs +++ b/crates/test-utils/src/harness.rs @@ -1,41 +1,29 @@ -//! Unified test harness combining node and engine helpers, plus optional flashblocks adapter. +//! Unified test harness combining node, engine API, and flashblocks functionality -use std::time::Duration; - -use alloy_eips::{BlockHashOrNumber, eip7685::Requests}; -use alloy_primitives::{B64, B256, Bytes, bytes}; +use crate::accounts::TestAccounts; +use crate::engine::{EngineApi, IpcEngine}; +use crate::node::{LocalFlashblocksState, LocalNode, LocalNodeProvider, OpAddOns, OpBuilder}; +use alloy_eips::eip7685::Requests; +use alloy_primitives::{Bytes, B256}; use alloy_provider::{Provider, RootProvider}; use alloy_rpc_types::BlockNumberOrTag; use alloy_rpc_types_engine::PayloadAttributes; -use eyre::{Result, eyre}; +use base_reth_flashblocks_rpc::subscription::Flashblock; +use eyre::{eyre, Result}; use futures_util::Future; use op_alloy_network::Optimism; use op_alloy_rpc_types_engine::OpPayloadAttributes; -use reth::{ - builder::NodeHandle, - providers::{BlockNumReader, BlockReader, ChainSpecProvider}, -}; +use reth::builder::NodeHandle; use reth_e2e_test_utils::Adapter; use reth_optimism_node::OpNode; -use reth_optimism_primitives::OpBlock; -use reth_primitives_traits::{Block as BlockT, RecoveredBlock}; +use std::sync::Arc; +use std::time::Duration; use tokio::time::sleep; -use crate::{ - accounts::TestAccounts, - engine::{EngineApi, IpcEngine}, - node::{LocalNode, LocalNodeProvider, OpAddOns, OpBuilder, default_launcher}, - tracing::init_silenced_tracing, -}; - const BLOCK_TIME_SECONDS: u64 = 2; const GAS_LIMIT: u64 = 200_000_000; const NODE_STARTUP_DELAY_MS: u64 = 500; const BLOCK_BUILD_DELAY_MS: u64 = 100; -// Pre-captured L1 block info deposit transaction required by OP Stack. -const L1_BLOCK_INFO_DEPOSIT_TX: Bytes = bytes!( - "0x7ef90104a06c0c775b6b492bab9d7e81abdf27f77cafb698551226455a82f559e0f93fea3794deaddeaddeaddeaddeaddeaddeaddeaddead00019442000000000000000000000000000000000000158080830f424080b8b0098999be000008dd00101c1200000000000000020000000068869d6300000000015f277f000000000000000000000000000000000000000000000000000000000d42ac290000000000000000000000000000000000000000000000000000000000000001abf52777e63959936b1bf633a2a643f0da38d63deffe49452fed1bf8a44975d50000000000000000000000005050f69a9786f081509234f1a7f4684b5e5b76c9000000000000000000000000" -); pub struct TestHarness { node: LocalNode, @@ -44,31 +32,28 @@ pub struct TestHarness { } impl TestHarness { - pub async fn new() -> Result { - Self::with_launcher(default_launcher).await - } - - pub async fn with_launcher(launcher: L) -> Result + pub async fn new(launcher: L) -> Result where L: FnOnce(OpBuilder) -> LRet, LRet: Future, OpAddOns>>>, { - init_silenced_tracing(); let node = LocalNode::new(launcher).await?; - Self::from_node(node).await - } - - pub(crate) async fn from_node(node: LocalNode) -> Result { let engine = node.engine_api()?; let accounts = TestAccounts::new(); sleep(Duration::from_millis(NODE_STARTUP_DELAY_MS)).await; - Ok(Self { node, engine, accounts }) + Ok(Self { + node, + engine, + accounts, + }) } pub fn provider(&self) -> RootProvider { - self.node.provider().expect("provider should always be available after node initialization") + self.node + .provider() + .expect("provider should always be available after node initialization") } pub fn accounts(&self) -> &TestAccounts { @@ -79,16 +64,15 @@ impl TestHarness { self.node.blockchain_provider() } + pub fn flashblocks_state(&self) -> Arc { + self.node.flashblocks_state() + } + pub fn rpc_url(&self) -> String { format!("http://{}", self.node.http_api_addr) } - pub async fn build_block_from_transactions(&self, mut transactions: Vec) -> Result<()> { - // Ensure the block always starts with the required L1 block info deposit. - if !transactions.first().is_some_and(|tx| tx == &L1_BLOCK_INFO_DEPOSIT_TX) { - transactions.insert(0, L1_BLOCK_INFO_DEPOSIT_TX.clone()); - } - + pub async fn build_block_from_transactions(&self, transactions: Vec) -> Result<()> { let latest_block = self .provider() .get_block_by_number(BlockNumberOrTag::Latest) @@ -96,28 +80,18 @@ impl TestHarness { .ok_or_else(|| eyre!("No genesis block found"))?; let parent_hash = latest_block.header.hash; - let parent_beacon_block_root = - latest_block.header.parent_beacon_block_root.unwrap_or(B256::ZERO); let next_timestamp = latest_block.header.timestamp + BLOCK_TIME_SECONDS; - let min_base_fee = latest_block.header.base_fee_per_gas.unwrap_or_default(); - let chain_spec = self.node.blockchain_provider().chain_spec(); - let base_fee_params = chain_spec.base_fee_params_at_timestamp(next_timestamp); - let eip_1559_params = ((base_fee_params.max_change_denominator as u64) << 32) - | (base_fee_params.elasticity_multiplier as u64); - let payload_attributes = OpPayloadAttributes { payload_attributes: PayloadAttributes { timestamp: next_timestamp, - parent_beacon_block_root: Some(parent_beacon_block_root), + parent_beacon_block_root: Some(B256::ZERO), withdrawals: Some(vec![]), ..Default::default() }, transactions: Some(transactions), gas_limit: Some(GAS_LIMIT), no_tx_pool: Some(true), - min_base_fee: Some(min_base_fee), - eip_1559_params: Some(B64::from(eip_1559_params)), ..Default::default() }; @@ -158,38 +132,47 @@ impl TestHarness { .latest_valid_hash .ok_or_else(|| eyre!("Payload status missing latest_valid_hash"))?; - self.engine.update_forkchoice(parent_hash, new_block_hash, None).await?; + self.engine + .update_forkchoice(parent_hash, new_block_hash, None) + .await?; Ok(()) } + pub async fn send_flashblock(&self, flashblock: Flashblock) -> Result<()> { + self.node.send_flashblock(flashblock).await + } + + pub async fn send_flashblocks(&self, flashblocks: I) -> Result<()> + where + I: IntoIterator, + { + for flashblock in flashblocks { + self.send_flashblock(flashblock).await?; + } + Ok(()) + } + pub async fn advance_chain(&self, n: u64) -> Result<()> { for _ in 0..n { self.build_block_from_transactions(vec![]).await?; } Ok(()) } - - pub fn latest_block(&self) -> RecoveredBlock { - let provider = self.blockchain_provider(); - let best_number = provider.best_block_number().expect("able to read best block number"); - let block = provider - .block(BlockHashOrNumber::Number(best_number)) - .expect("able to load canonical block") - .expect("canonical block exists"); - BlockT::try_into_recovered(block).expect("able to recover canonical block") - } } #[cfg(test)] mod tests { + use crate::node::default_launcher; + + use super::*; use alloy_primitives::U256; use alloy_provider::Provider; - use super::*; #[tokio::test] async fn test_harness_setup() -> Result<()> { - let harness = TestHarness::new().await?; + reth_tracing::init_test_tracing(); + let harness = TestHarness::new(default_launcher).await?; assert_eq!(harness.accounts().alice.name, "Alice"); assert_eq!(harness.accounts().bob.name, "Bob"); @@ -198,7 +181,9 @@ mod tests { let chain_id = provider.get_chain_id().await?; assert_eq!(chain_id, crate::node::BASE_CHAIN_ID); - let alice_balance = provider.get_balance(harness.accounts().alice.address).await?; + let alice_balance = provider + .get_balance(harness.accounts().alice.address) + .await?; assert!(alice_balance > U256::ZERO); let block_number = provider.get_block_number().await?; diff --git a/crates/test-utils/src/node.rs b/crates/test-utils/src/node.rs index 5a731bfd..cd713b8c 100644 --- a/crates/test-utils/src/node.rs +++ b/crates/test-utils/src/node.rs @@ -1,51 +1,35 @@ //! Local node setup with Base Sepolia chainspec -use std::{ - any::Any, - net::SocketAddr, - sync::{Arc, Mutex}, -}; - +use crate::engine::EngineApi; use alloy_genesis::Genesis; use alloy_provider::RootProvider; use alloy_rpc_client::RpcClient; -use base_reth_flashblocks_rpc::{ - rpc::{EthApiExt, EthApiOverrideServer}, - state::FlashblocksState, - subscription::{Flashblock, FlashblocksReceiver}, -}; +use base_reth_flashblocks_rpc::rpc::{EthApiExt, EthApiOverrideServer}; +use base_reth_flashblocks_rpc::state::FlashblocksState; +use base_reth_flashblocks_rpc::subscription::{Flashblock, FlashblocksReceiver}; use eyre::Result; use futures_util::Future; use once_cell::sync::OnceCell; use op_alloy_network::Optimism; -use reth::{ - api::{FullNodeTypesAdapter, NodeTypesWithDBAdapter}, - args::{DiscoveryArgs, NetworkArgs, RpcServerArgs}, - builder::{ - Node, NodeBuilder, NodeBuilderWithComponents, NodeConfig, NodeHandle, WithLaunchContext, - }, - core::exit::NodeExitFuture, - tasks::TaskManager, -}; -use reth_db::{ - ClientVersion, DatabaseEnv, init_db, - mdbx::DatabaseArguments, - test_utils::{ERROR_DB_CREATION, TempDatabase, tempdir_path}, +use reth::api::{FullNodeTypesAdapter, NodeTypesWithDBAdapter}; +use reth::args::{DiscoveryArgs, NetworkArgs, RpcServerArgs}; +use reth::builder::{ + Node, NodeBuilder, NodeBuilderWithComponents, NodeConfig, NodeHandle, WithLaunchContext, }; +use reth::core::exit::NodeExitFuture; +use reth::tasks::TaskManager; use reth_e2e_test_utils::{Adapter, TmpDB}; use reth_exex::ExExEvent; -use reth_node_core::{ - args::DatadirArgs, - dirs::{DataDirPath, MaybePlatformPath}, -}; use reth_optimism_chainspec::OpChainSpec; -use reth_optimism_node::{OpNode, args::RollupArgs}; -use reth_provider::{CanonStateSubscriptions, providers::BlockchainProvider}; +use reth_optimism_node::args::RollupArgs; +use reth_optimism_node::OpNode; +use reth_provider::providers::BlockchainProvider; +use std::any::Any; +use std::net::SocketAddr; +use std::sync::Arc; use tokio::sync::{mpsc, oneshot}; use tokio_stream::StreamExt; -use crate::engine::EngineApi; - pub const BASE_CHAIN_ID: u64 = 84532; pub type LocalNodeProvider = BlockchainProvider>; @@ -54,145 +38,14 @@ pub type LocalFlashblocksState = FlashblocksState; pub struct LocalNode { pub(crate) http_api_addr: SocketAddr, engine_ipc_path: String, + flashblock_sender: mpsc::Sender<(Flashblock, oneshot::Sender<()>)>, + flashblocks_state: Arc, provider: LocalNodeProvider, _node_exit_future: NodeExitFuture, _node: Box, _task_manager: TaskManager, } -#[derive(Clone)] -pub struct FlashblocksParts { - sender: mpsc::Sender<(Flashblock, oneshot::Sender<()>)>, - state: Arc, -} - -impl FlashblocksParts { - pub fn state(&self) -> Arc { - self.state.clone() - } - - pub async fn send(&self, flashblock: Flashblock) -> Result<()> { - let (tx, rx) = oneshot::channel(); - self.sender.send((flashblock, tx)).await.map_err(|err| eyre::eyre!(err))?; - rx.await.map_err(|err| eyre::eyre!(err))?; - Ok(()) - } -} - -#[derive(Clone)] -struct FlashblocksNodeExtensions { - inner: Arc, -} - -struct FlashblocksNodeExtensionsInner { - sender: mpsc::Sender<(Flashblock, oneshot::Sender<()>)>, - receiver: Arc)>>>>, - fb_cell: Arc>>, - process_canonical: bool, -} - -impl FlashblocksNodeExtensions { - fn new(process_canonical: bool) -> Self { - let (sender, receiver) = mpsc::channel::<(Flashblock, oneshot::Sender<()>)>(100); - let inner = FlashblocksNodeExtensionsInner { - sender, - receiver: Arc::new(Mutex::new(Some(receiver))), - fb_cell: Arc::new(OnceCell::new()), - process_canonical, - }; - Self { inner: Arc::new(inner) } - } - - fn apply(&self, builder: OpBuilder) -> OpBuilder { - let fb_cell = self.inner.fb_cell.clone(); - let receiver = self.inner.receiver.clone(); - let process_canonical = self.inner.process_canonical; - - let fb_cell_for_exex = fb_cell.clone(); - - builder - .install_exex("flashblocks-canon", move |mut ctx| { - let fb_cell = fb_cell_for_exex.clone(); - let process_canonical = process_canonical; - async move { - let provider = ctx.provider().clone(); - let fb = init_flashblocks_state(&fb_cell, &provider); - Ok(async move { - while let Some(note) = ctx.notifications.try_next().await? { - if let Some(committed) = note.committed_chain() { - if process_canonical { - // Many suites drive canonical updates manually to reproduce race conditions, so - // allowing this to be disabled keeps canonical replay deterministic. - for block in committed.blocks_iter() { - fb.on_canonical_block_received(block); - } - } - let _ = ctx - .events - .send(ExExEvent::FinishedHeight(committed.tip().num_hash())); - } - } - Ok(()) - }) - } - }) - .extend_rpc_modules(move |ctx| { - let fb_cell = fb_cell.clone(); - let provider = ctx.provider().clone(); - let fb = init_flashblocks_state(&fb_cell, &provider); - - let mut canon_stream = tokio_stream::wrappers::BroadcastStream::new( - ctx.provider().subscribe_to_canonical_state(), - ); - tokio::spawn(async move { - use tokio_stream::StreamExt; - while let Some(Ok(notification)) = canon_stream.next().await { - provider.canonical_in_memory_state().notify_canon_state(notification); - } - }); - let api_ext = EthApiExt::new( - ctx.registry.eth_api().clone(), - ctx.registry.eth_handlers().filter.clone(), - fb.clone(), - ); - ctx.modules.replace_configured(api_ext.into_rpc())?; - - let fb_for_task = fb.clone(); - let mut receiver = receiver - .lock() - .expect("flashblock receiver mutex poisoned") - .take() - .expect("flashblock receiver should only be initialized once"); - tokio::spawn(async move { - while let Some((payload, tx)) = receiver.recv().await { - fb_for_task.on_flashblock_received(payload); - let _ = tx.send(()); - } - }); - - Ok(()) - }) - } - - fn wrap_launcher(&self, launcher: L) -> impl FnOnce(OpBuilder) -> LRet - where - L: FnOnce(OpBuilder) -> LRet, - { - let extensions = self.clone(); - move |builder| { - let builder = extensions.apply(builder); - launcher(builder) - } - } - - fn parts(&self) -> Result { - let state = self.inner.fb_cell.get().ok_or_else(|| { - eyre::eyre!("FlashblocksState should be initialized during node launch") - })?; - Ok(FlashblocksParts { sender: self.inner.sender.clone(), state: state.clone() }) - } -} - pub type OpTypes = FullNodeTypesAdapter>>; pub type OpComponentsBuilder = >::ComponentsBuilder; @@ -213,33 +66,148 @@ impl LocalNode { L: FnOnce(OpBuilder) -> LRet, LRet: Future, OpAddOns>>>, { - build_node(launcher).await - } + let tasks = TaskManager::current(); + let exec = tasks.executor(); + + let genesis: Genesis = serde_json::from_str(include_str!("../assets/genesis.json"))?; + let chain_spec = Arc::new(OpChainSpec::from_genesis(genesis)); + + let network_config = NetworkArgs { + discovery: DiscoveryArgs { + disable_discovery: true, + ..DiscoveryArgs::default() + }, + ..NetworkArgs::default() + }; - /// Creates a test database with a smaller map size to reduce memory usage. - /// - /// Unlike `NodeBuilder::testing_node()` which hardcodes an 8 TB map size, - /// this method configures the database with a 100 MB map size. This prevents - /// `ENOMEM` errors when running parallel tests with `cargo test`, as the - /// default 8 TB size can cause memory exhaustion when multiple test processes - /// run concurrently. - fn create_test_database() -> Result>> { - let default_size = 100 * 1024 * 1024; // 100 MB - Self::create_test_database_with_size(default_size) + // Generate unique IPC path for this test instance to avoid conflicts + // Use timestamp + thread ID + process ID for uniqueness + let unique_ipc_path = format!( + "/tmp/reth_engine_api_{}_{}_{:?}.ipc", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + std::process::id(), + std::thread::current().id() + ); + + let mut rpc_args = RpcServerArgs::default() + .with_unused_ports() + .with_http() + .with_auth_ipc(); + rpc_args.auth_ipc_path = unique_ipc_path; + + let node_config = NodeConfig::new(chain_spec.clone()) + .with_network(network_config) + .with_rpc(rpc_args) + .with_unused_ports(); + + let node = OpNode::new(RollupArgs::default()); + + let (sender, receiver) = mpsc::channel::<(Flashblock, oneshot::Sender<()>)>(100); + let fb_cell: Arc>>> = Arc::new(OnceCell::new()); + let provider_cell: Arc> = Arc::new(OnceCell::new()); + + let NodeHandle { + node: node_handle, + node_exit_future, + } = NodeBuilder::new(node_config.clone()) + .testing_node(exec.clone()) + .with_types_and_provider::>() + .with_components(node.components_builder()) + .with_add_ons(node.add_ons()) + .install_exex("flashblocks-canon", { + let fb_cell = fb_cell.clone(); + let provider_cell = provider_cell.clone(); + move |mut ctx| async move { + let provider = provider_cell.get_or_init(|| ctx.provider().clone()).clone(); + let fb = fb_cell + .get_or_init(|| Arc::new(FlashblocksState::new(provider.clone()))) + .clone(); + Ok(async move { + while let Some(note) = ctx.notifications.try_next().await? { + if let Some(committed) = note.committed_chain() { + for block in committed.blocks_iter() { + fb.on_canonical_block_received(block); + } + let _ = ctx + .events + .send(ExExEvent::FinishedHeight(committed.tip().num_hash())); + } + } + Ok(()) + }) + } + }) + .extend_rpc_modules({ + let fb_cell = fb_cell.clone(); + let provider_cell = provider_cell.clone(); + let mut receiver = Some(receiver); + move |ctx| { + let provider = provider_cell.get_or_init(|| ctx.provider().clone()).clone(); + let fb = fb_cell + .get_or_init(|| Arc::new(FlashblocksState::new(provider.clone()))) + .clone(); + fb.start(); + let api_ext = EthApiExt::new( + ctx.registry.eth_api().clone(), + ctx.registry.eth_handlers().filter.clone(), + fb.clone(), + ); + ctx.modules.replace_configured(api_ext.into_rpc())?; + // Spawn task to receive flashblocks from the test context + let fb_for_task = fb.clone(); + let mut receiver = receiver + .take() + .expect("flashblock receiver should only be initialized once"); + tokio::spawn(async move { + while let Some((payload, tx)) = receiver.recv().await { + fb_for_task.on_flashblock_received(payload); + let _ = tx.send(()); + } + }); + Ok(()) + } + }) + .launch_with_fn(launcher) + .await?; + + let http_api_addr = node_handle + .rpc_server_handle() + .http_local_addr() + .ok_or_else(|| eyre::eyre!("HTTP RPC server failed to bind to address"))?; + + let engine_ipc_path = node_config.rpc.auth_ipc_path; + let flashblocks_state = fb_cell + .get() + .expect("FlashblocksState should be initialized during node launch") + .clone(); + let provider = provider_cell + .get() + .expect("Provider should be initialized during node launch") + .clone(); + + Ok(Self { + http_api_addr, + engine_ipc_path, + flashblock_sender: sender, + flashblocks_state, + provider, + _node_exit_future: node_exit_future, + _node: Box::new(node_handle), + _task_manager: tasks, + }) } - /// Creates a test database with a configurable map size to reduce memory usage. - /// - /// # Arguments - /// - /// * `max_size` - Maximum map size in bytes. - fn create_test_database_with_size(max_size: usize) -> Result>> { - let path = tempdir_path(); - let emsg = format!("{ERROR_DB_CREATION}: {path:?}"); - let args = - DatabaseArguments::new(ClientVersion::default()).with_geometry_max_size(Some(max_size)); - let db = init_db(&path, args).expect(&emsg); - Ok(Arc::new(TempDatabase::new(db, path))) + pub async fn send_flashblock(&self, flashblock: Flashblock) -> Result<()> { + let (tx, rx) = oneshot::channel(); + self.flashblock_sender + .send((flashblock, tx)) + .await + .map_err(|err| eyre::eyre!(err))?; + rx.await.map_err(|err| eyre::eyre!(err))?; + Ok(()) } pub fn provider(&self) -> Result> { @@ -252,149 +220,11 @@ impl LocalNode { EngineApi::::new(self.engine_ipc_path.clone()) } - pub fn blockchain_provider(&self) -> LocalNodeProvider { - self.provider.clone() - } -} - -async fn build_node(launcher: L) -> Result -where - L: FnOnce(OpBuilder) -> LRet, - LRet: Future, OpAddOns>>>, -{ - let tasks = TaskManager::current(); - let exec = tasks.executor(); - - let genesis: Genesis = serde_json::from_str(include_str!("../assets/genesis.json"))?; - let chain_spec = Arc::new(OpChainSpec::from_genesis(genesis)); - - let network_config = NetworkArgs { - discovery: DiscoveryArgs { disable_discovery: true, ..DiscoveryArgs::default() }, - ..NetworkArgs::default() - }; - - let unique_ipc_path = format!( - "/tmp/reth_engine_api_{}_{}_{:?}.ipc", - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(), - std::process::id(), - std::thread::current().id() - ); - - let mut rpc_args = RpcServerArgs::default().with_unused_ports().with_http().with_auth_ipc(); - rpc_args.auth_ipc_path = unique_ipc_path; - - let node = OpNode::new(RollupArgs::default()); - - let temp_db = LocalNode::create_test_database()?; - let db_path = temp_db.path().to_path_buf(); - - let mut node_config = NodeConfig::new(chain_spec.clone()) - .with_network(network_config) - .with_rpc(rpc_args) - .with_unused_ports(); - - let datadir_path = MaybePlatformPath::::from(db_path.clone()); - node_config = - node_config.with_datadir_args(DatadirArgs { datadir: datadir_path, ..Default::default() }); - - let builder = NodeBuilder::new(node_config.clone()) - .with_database(temp_db) - .with_launch_context(exec.clone()) - .with_types_and_provider::>() - .with_components(node.components_builder()) - .with_add_ons(node.add_ons()); - - let NodeHandle { node: node_handle, node_exit_future } = - builder.launch_with_fn(launcher).await?; - - let http_api_addr = node_handle - .rpc_server_handle() - .http_local_addr() - .ok_or_else(|| eyre::eyre!("HTTP RPC server failed to bind to address"))?; - - let engine_ipc_path = node_config.rpc.auth_ipc_path; - let provider = node_handle.provider().clone(); - - Ok(LocalNode { - http_api_addr, - engine_ipc_path, - provider, - _node_exit_future: node_exit_future, - _node: Box::new(node_handle), - _task_manager: tasks, - }) -} - -fn init_flashblocks_state( - cell: &Arc>>, - provider: &LocalNodeProvider, -) -> Arc { - cell.get_or_init(|| { - let fb = Arc::new(FlashblocksState::new(provider.clone(), 5)); - fb.start(); - fb - }) - .clone() -} - -pub struct FlashblocksLocalNode { - node: LocalNode, - parts: FlashblocksParts, -} - -impl FlashblocksLocalNode { - pub async fn new() -> Result { - Self::with_launcher(default_launcher).await - } - - /// Builds a flashblocks-enabled node with canonical block streaming disabled so tests can call - /// `FlashblocksState::on_canonical_block_received` at precise points. - pub async fn manual_canonical() -> Result { - Self::with_manual_canonical_launcher(default_launcher).await - } - - pub async fn with_launcher(launcher: L) -> Result - where - L: FnOnce(OpBuilder) -> LRet, - LRet: Future, OpAddOns>>>, - { - Self::with_launcher_inner(launcher, true).await - } - - pub async fn with_manual_canonical_launcher(launcher: L) -> Result - where - L: FnOnce(OpBuilder) -> LRet, - LRet: Future, OpAddOns>>>, - { - Self::with_launcher_inner(launcher, false).await - } - - async fn with_launcher_inner(launcher: L, process_canonical: bool) -> Result - where - L: FnOnce(OpBuilder) -> LRet, - LRet: Future, OpAddOns>>>, - { - let extensions = FlashblocksNodeExtensions::new(process_canonical); - let wrapped_launcher = extensions.wrap_launcher(launcher); - let node = LocalNode::new(wrapped_launcher).await?; - - let parts = extensions.parts()?; - Ok(Self { node, parts }) - } - pub fn flashblocks_state(&self) -> Arc { - self.parts.state() - } - - pub async fn send_flashblock(&self, flashblock: Flashblock) -> Result<()> { - self.parts.send(flashblock).await + self.flashblocks_state.clone() } - pub fn into_parts(self) -> (LocalNode, FlashblocksParts) { - (self.node, self.parts) - } - - pub fn as_node(&self) -> &LocalNode { - &self.node + pub fn blockchain_provider(&self) -> LocalNodeProvider { + self.provider.clone() } } From 874998900a1cbdae6b7db01887b9964d98bd9dd7 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Tue, 11 Nov 2025 16:44:25 -0600 Subject: [PATCH 11/25] Fix chain-state metering tests with L1 deposit --- Cargo.toml | 1 + crates/metering/Cargo.toml | 4 + crates/metering/src/tests/chain_state.rs | 298 ++++++++++++++++++++--- crates/metering/src/tests/utils.rs | 4 +- crates/test-utils/README.md | 2 +- crates/test-utils/src/harness.rs | 14 +- 6 files changed, 281 insertions(+), 42 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 18e0fe1a..e31f5c7c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,6 +91,7 @@ alloy-rpc-types = { version = "1.0.41", default-features = false } alloy-rpc-types-engine = { version = "1.0.41", default-features = false } alloy-rpc-types-eth = { version = "1.0.41" } alloy-consensus = { version = "1.0.41" } +alloy-sol-types = { version = "1.4.1" } alloy-trie = { version = "0.9.1", default-features = false } alloy-provider = { version = "1.0.41" } alloy-hardforks = "0.4.4" diff --git a/crates/metering/Cargo.toml b/crates/metering/Cargo.toml index 0014792c..d1d10b08 100644 --- a/crates/metering/Cargo.toml +++ b/crates/metering/Cargo.toml @@ -32,9 +32,12 @@ reth-trie-common.workspace = true alloy-primitives.workspace = true alloy-consensus.workspace = true alloy-eips.workspace = true +alloy-sol-types.workspace = true +alloy-rpc-types-eth.workspace = true # op-alloy op-alloy-consensus.workspace = true +op-alloy-rpc-types.workspace = true # base base-reth-flashblocks-rpc = { path = "../flashblocks-rpc" } @@ -69,3 +72,4 @@ base-reth-test-utils = { path = "../test-utils" } alloy-rpc-types-engine.workspace = true rollup-boost.workspace = true alloy-provider.workspace = true +hex-literal = "0.4" diff --git a/crates/metering/src/tests/chain_state.rs b/crates/metering/src/tests/chain_state.rs index 4f378951..23c5903c 100644 --- a/crates/metering/src/tests/chain_state.rs +++ b/crates/metering/src/tests/chain_state.rs @@ -1,49 +1,93 @@ use alloy_consensus::Receipt; -use alloy_eips::Encodable2718; -use alloy_primitives::{Bytes, B256}; +use alloy_eips::{BlockNumberOrTag, Encodable2718}; +use alloy_primitives::{Bytes, B256, U256}; use alloy_provider::Provider; +use alloy_rpc_types_eth::TransactionInput; +use alloy_sol_types::{sol, SolCall}; use base_reth_flashblocks_rpc::rpc::FlashblocksAPI; use base_reth_test_utils::harness::TestHarness; use base_reth_test_utils::node::{default_launcher, BASE_CHAIN_ID}; use eyre::{eyre, Result}; +use hex_literal::hex; use op_alloy_consensus::OpTxEnvelope; use reth::providers::HeaderProvider; use reth_optimism_primitives::{OpReceipt, OpTransactionSigned}; +use reth_primitives::TransactionSigned; use reth_transaction_pool::test_utils::TransactionBuilder; use tips_core::types::Bundle; use super::utils::{build_single_flashblock, secret_from_hex}; use crate::rpc::{MeteringApiImpl, MeteringApiServer}; +use op_alloy_rpc_types::OpTransactionRequest; #[tokio::test] -async fn meters_bundle_after_advancing_blocks() -> Result<()> { +async fn metering_succeeds_after_storage_change() -> Result<()> { reth_tracing::init_test_tracing(); let harness = TestHarness::new(default_launcher).await?; let provider = harness.provider(); - let bob = &harness.accounts().bob; - let alice_secret = secret_from_hex(harness.accounts().alice.private_key); + let alice = &harness.accounts().alice; + let alice_secret = secret_from_hex(alice.private_key); - let tx = TransactionBuilder::default() + // Deploy the Counter contract (nonce 0) + let deploy_signed = TransactionBuilder::default() .signer(alice_secret) .chain_id(BASE_CHAIN_ID) .nonce(0) - .to(bob.address) - .value(1) - .gas_limit(21_000) - .max_fee_per_gas(1_000_000_000) - .max_priority_fee_per_gas(1_000_000_000) + .gas_limit(DEPLOY_GAS_LIMIT) + .max_fee_per_gas(GWEI) + .max_priority_fee_per_gas(GWEI) + .input(COUNTER_CREATION_BYTECODE.to_vec()) .into_eip1559(); + let (deploy_envelope, deploy_bytes) = envelope_from_signed(deploy_signed); + harness + .build_block_from_transactions(vec![deploy_bytes]) + .await?; - let envelope = OpTxEnvelope::from(OpTransactionSigned::Eip1559( - tx.as_eip1559().unwrap().clone(), - )); - let tx_bytes = Bytes::from(envelope.encoded_2718()); + let deploy_receipt = provider + .get_transaction_receipt(deploy_envelope.tx_hash()) + .await? + .ok_or_else(|| eyre!("deployment transaction missing receipt"))?; + let contract_address = deploy_receipt + .inner + .contract_address + .ok_or_else(|| eyre!("deployment receipt missing contract address"))?; - harness.advance_chain(1).await?; + // Mutate storage on-chain via setNumber (nonce 1) + let set_call = Counter::setNumberCall { + newNumber: U256::from(42u64), + }; + let set_signed = TransactionBuilder::default() + .signer(alice_secret) + .chain_id(BASE_CHAIN_ID) + .nonce(1) + .gas_limit(CALL_GAS_LIMIT) + .max_fee_per_gas(GWEI) + .max_priority_fee_per_gas(GWEI) + .to(contract_address) + .input(Bytes::from(set_call.abi_encode())) + .into_eip1559(); + let (_set_envelope, set_bytes) = envelope_from_signed(set_signed); + harness + .build_block_from_transactions(vec![set_bytes]) + .await?; + + // Meter an increment call (nonce 2) after the storage change + let increment_call = Counter::incrementCall {}; + let increment_signed = TransactionBuilder::default() + .signer(alice_secret) + .chain_id(BASE_CHAIN_ID) + .nonce(2) + .gas_limit(CALL_GAS_LIMIT) + .max_fee_per_gas(GWEI) + .max_priority_fee_per_gas(GWEI) + .to(contract_address) + .input(Bytes::from(increment_call.abi_encode())) + .into_eip1559(); + let (_increment_envelope, increment_bytes) = envelope_from_signed(increment_signed.clone()); let bundle = Bundle { - txs: vec![tx_bytes.clone()], + txs: vec![increment_bytes.clone()], block_number: provider.get_block_number().await?, flashblock_number_min: None, flashblock_number_max: None, @@ -61,9 +105,41 @@ async fn meters_bundle_after_advancing_blocks() -> Result<()> { .map_err(|err| eyre!("meter_bundle rpc failed: {}", err))?; assert_eq!(response.results.len(), 1); - assert_eq!(response.total_gas_used, 21_000); + let result = &response.results[0]; + assert_eq!(result.to_address, Some(contract_address)); + assert!(result.gas_used > 0); assert!(response.state_flashblock_index.is_none()); + // Confirm canonical storage remains at 42 (increment transaction only simulated) + let number_call = Counter::numberCall {}; + let call_request = OpTransactionRequest::default() + .from(alice.address) + .to(contract_address) + .input(TransactionInput::new(Bytes::from(number_call.abi_encode()))); + let raw_number = provider + .call(call_request) + .block(BlockNumberOrTag::Latest.into()) + .await?; + let decoded: U256 = Counter::numberCall::abi_decode_returns(raw_number.as_ref())?; + assert_eq!(decoded, U256::from(42u64)); + + // Execute the increment on-chain to confirm the transaction is valid when mined + harness + .build_block_from_transactions(vec![increment_bytes]) + .await?; + let number_after_increment = provider + .call( + OpTransactionRequest::default() + .from(alice.address) + .to(contract_address) + .input(TransactionInput::new(Bytes::from(number_call.abi_encode()))), + ) + .block(BlockNumberOrTag::Latest.into()) + .await?; + let decoded_after_increment: U256 = + Counter::numberCall::abi_decode_returns(number_after_increment.as_ref())?; + assert_eq!(decoded_after_increment, U256::from(43u64)); + Ok(()) } @@ -73,19 +149,30 @@ async fn pending_flashblock_updates_state() -> Result<()> { let harness = TestHarness::new(default_launcher).await?; let provider = harness.provider(); - let bob = &harness.accounts().bob; - let alice_secret = secret_from_hex(harness.accounts().alice.private_key); + let alice = &harness.accounts().alice; + let alice_secret = secret_from_hex(alice.private_key); - let tx = TransactionBuilder::default() + // Deploy the contract so the pending flashblock can interact with storage + let deploy_signed = TransactionBuilder::default() .signer(alice_secret) .chain_id(BASE_CHAIN_ID) .nonce(0) - .to(bob.address) - .value(1) - .gas_limit(21_000) - .max_fee_per_gas(1_000_000_000) - .max_priority_fee_per_gas(1_000_000_000) + .gas_limit(DEPLOY_GAS_LIMIT) + .max_fee_per_gas(GWEI) + .max_priority_fee_per_gas(GWEI) + .input(COUNTER_CREATION_BYTECODE.to_vec()) .into_eip1559(); + let (deploy_envelope, deploy_bytes) = envelope_from_signed(deploy_signed); + harness + .build_block_from_transactions(vec![deploy_bytes]) + .await?; + let contract_address = provider + .get_transaction_receipt(deploy_envelope.tx_hash()) + .await? + .ok_or_else(|| eyre!("deployment transaction missing receipt"))? + .inner + .contract_address + .ok_or_else(|| eyre!("deployment receipt missing contract address"))?; let blockchain_provider = harness.blockchain_provider(); let latest_number = provider.get_block_number().await?; @@ -94,32 +181,43 @@ async fn pending_flashblock_updates_state() -> Result<()> { .ok_or_else(|| eyre!("missing header for block {}", latest_number))?; let pending_block_number = latest_header.number + 1; - let envelope = OpTxEnvelope::from(OpTransactionSigned::Eip1559( - tx.as_eip1559().unwrap().clone(), - )); - let tx_hash = envelope.tx_hash(); - let tx_bytes = Bytes::from(envelope.encoded_2718()); + let flash_call = Counter::setNumberCall { + newNumber: U256::from(99u64), + }; + let flash_signed = TransactionBuilder::default() + .signer(alice_secret) + .chain_id(BASE_CHAIN_ID) + .nonce(1) + .gas_limit(CALL_GAS_LIMIT) + .max_fee_per_gas(GWEI) + .max_priority_fee_per_gas(GWEI) + .to(contract_address) + .input(Bytes::from(flash_call.abi_encode())) + .into_eip1559(); + let (flash_envelope, flash_bytes) = envelope_from_signed(flash_signed); let receipt = OpReceipt::Eip1559(Receipt { status: true.into(), - cumulative_gas_used: 21_000, + cumulative_gas_used: 80_000, logs: vec![], }); - // Use a zero parent beacon block root to emulate a flashblock that predates Cancun data, - // which should cause metering to surface the missing-root error while still caching state. + // Pending flashblock omits the beacon root, so metering will report the validation error. let flashblock = build_single_flashblock( pending_block_number, latest_header.hash(), B256::ZERO, latest_header.timestamp + 2, latest_header.gas_limit, - vec![(tx_bytes.clone(), Some((tx_hash, receipt.clone())))], + vec![( + flash_bytes.clone(), + Some((flash_envelope.tx_hash(), receipt.clone())), + )], ); harness.send_flashblock(flashblock).await?; let bundle = Bundle { - txs: vec![tx_bytes.clone()], + txs: vec![flash_bytes.clone()], block_number: pending_block_number, flashblock_number_min: None, flashblock_number_max: None, @@ -133,7 +231,6 @@ async fn pending_flashblock_updates_state() -> Result<()> { let metering_api = MeteringApiImpl::new(blockchain_provider.clone(), harness.flashblocks_state()); let result = MeteringApiServer::meter_bundle(&metering_api, bundle).await; - let err = result.expect_err("pending flashblock metering should surface missing beacon root"); assert!( err.message().contains("parent beacon block root missing"), @@ -147,5 +244,132 @@ async fn pending_flashblock_updates_state() -> Result<()> { 0 ); + // Pending state should reflect the storage change even though the simulation failed. + let number_call = Counter::numberCall {}; + let pending_value = provider + .call( + OpTransactionRequest::default() + .from(alice.address) + .to(contract_address) + .input(TransactionInput::new(Bytes::from(number_call.abi_encode()))), + ) + .block(BlockNumberOrTag::Pending.into()) + .await?; + let decoded_pending: U256 = Counter::numberCall::abi_decode_returns(pending_value.as_ref())?; + assert_eq!(decoded_pending, U256::from(99u64)); + + Ok(()) +} + +sol! { + contract Counter { + function setNumber(uint256 newNumber); + function increment(); + function number() view returns (uint256); + } +} + +const COUNTER_CREATION_BYTECODE: &[u8] = &hex!("6080604052348015600e575f5ffd5b506101e18061001c5f395ff3fe608060405234801561000f575f5ffd5b506004361061003f575f3560e01c80633fb5c1cb146100435780638381f58a1461005f578063d09de08a1461007d575b5f5ffd5b61005d600480360381019061005891906100e4565b610087565b005b610067610090565b604051610074919061011e565b60405180910390f35b610085610095565b005b805f8190555050565b5f5481565b5f5f8154809291906100a690610164565b9190505550565b5f5ffd5b5f819050919050565b6100c3816100b1565b81146100cd575f5ffd5b50565b5f813590506100de816100ba565b92915050565b5f602082840312156100f9576100f86100ad565b5b5f610106848285016100d0565b91505092915050565b610118816100b1565b82525050565b5f6020820190506101315f83018461010f565b92915050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f61016e826100b1565b91507fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff82036101a05761019f610137565b5b60018201905091905056fea26469706673582212204b710430bf5e9541dd320fc4eece1bf270f8b7d4835bba28f79ff7bd29904a2964736f6c634300081e0033"); +const GWEI: u128 = 1_000_000_000; +const DEPLOY_GAS_LIMIT: u64 = 1_000_000; +const CALL_GAS_LIMIT: u64 = 150_000; + +fn envelope_from_signed(tx: TransactionSigned) -> (OpTxEnvelope, Bytes) { + let op_signed = OpTransactionSigned::Eip1559( + tx.as_eip1559() + .expect("transaction should be EIP-1559") + .clone(), + ); + let envelope = OpTxEnvelope::from(op_signed); + let bytes = Bytes::from(envelope.encoded_2718()); + (envelope, bytes) +} + +#[tokio::test] +async fn counter_storage_changes_persist_across_blocks() -> Result<()> { + reth_tracing::init_test_tracing(); + let harness = TestHarness::new(default_launcher).await?; + let alice = &harness.accounts().alice; + let alice_secret = secret_from_hex(alice.private_key); + let mut nonce = 0u64; + + let deploy_signed = TransactionBuilder::default() + .signer(alice_secret) + .chain_id(BASE_CHAIN_ID) + .nonce(nonce) + .gas_limit(DEPLOY_GAS_LIMIT) + .max_fee_per_gas(GWEI) + .max_priority_fee_per_gas(GWEI) + .input(COUNTER_CREATION_BYTECODE.to_vec()) + .into_eip1559(); + let (deploy_envelope, deploy_bytes) = envelope_from_signed(deploy_signed); + + harness + .build_block_from_transactions(vec![deploy_bytes]) + .await?; + nonce += 1; + + let provider = harness.provider(); + let deploy_receipt = provider + .get_transaction_receipt(deploy_envelope.tx_hash()) + .await? + .ok_or_else(|| eyre!("deployment transaction missing receipt"))?; + let contract_address = deploy_receipt + .inner + .contract_address + .ok_or_else(|| eyre!("deployment receipt missing contract address"))?; + + let set_call = Counter::setNumberCall { + newNumber: U256::from(42u64), + }; + let set_signed = TransactionBuilder::default() + .signer(alice_secret) + .chain_id(BASE_CHAIN_ID) + .nonce(nonce) + .gas_limit(CALL_GAS_LIMIT) + .max_fee_per_gas(GWEI) + .max_priority_fee_per_gas(GWEI) + .to(contract_address) + .input(Bytes::from(set_call.abi_encode())) + .into_eip1559(); + let (_set_envelope, set_bytes) = envelope_from_signed(set_signed); + harness + .build_block_from_transactions(vec![set_bytes]) + .await?; + nonce += 1; + + let increment_call = Counter::incrementCall {}; + let increment_signed = TransactionBuilder::default() + .signer(alice_secret) + .chain_id(BASE_CHAIN_ID) + .nonce(nonce) + .gas_limit(CALL_GAS_LIMIT) + .max_fee_per_gas(GWEI) + .max_priority_fee_per_gas(GWEI) + .to(contract_address) + .input(Bytes::from(increment_call.abi_encode())) + .into_eip1559(); + let (_increment_envelope, increment_bytes) = envelope_from_signed(increment_signed); + harness + .build_block_from_transactions(vec![increment_bytes]) + .await?; + + let storage_value = provider + .get_storage_at(contract_address, U256::ZERO) + .await?; + assert_eq!(storage_value, U256::from(43u64)); + + let number_call = Counter::numberCall {}; + let call_request = OpTransactionRequest::default() + .from(alice.address) + .to(contract_address) + .input(TransactionInput::new(Bytes::from(number_call.abi_encode()))); + let raw_number = provider + .call(call_request) + .block(BlockNumberOrTag::Latest.into()) + .await?; + let decoded: U256 = Counter::numberCall::abi_decode_returns(raw_number.as_ref())?; + assert_eq!(decoded, U256::from(43u64)); + Ok(()) } diff --git a/crates/metering/src/tests/utils.rs b/crates/metering/src/tests/utils.rs index 0f6f8ffd..b7464cf6 100644 --- a/crates/metering/src/tests/utils.rs +++ b/crates/metering/src/tests/utils.rs @@ -19,8 +19,8 @@ use rollup_boost::{ExecutionPayloadBaseV1, ExecutionPayloadFlashblockDeltaV1}; const FLASHBLOCK_PAYLOAD_ID: [u8; 8] = [0; 8]; // Pre-captured deposit transaction and hash used by flashblocks tests for the L1 block info deposit. // Values match `BLOCK_INFO_TXN` and `BLOCK_INFO_TXN_HASH` from `crates/flashblocks-rpc/src/tests/mod.rs`. -const BLOCK_INFO_DEPOSIT_TX: Bytes = bytes!("0x7ef90104a06c0c775b6b492bab9d7e81abdf27f77cafb698551226455a82f559e0f93fea3794deaddeaddeaddeaddeaddeaddeaddeaddead00019442000000000000000000000000000000000000158080830f424080b8b0098999be000008dd00101c1200000000000000020000000068869d6300000000015f277f000000000000000000000000000000000000000000000000000000000d42ac290000000000000000000000000000000000000000000000000000000000000001abf52777e63959936b1bf633a2a643f0da38d63deffe49452fed1bf8a44975d50000000000000000000000005050f69a9786f081509234f1a7f4684b5e5b76c9000000000000000000000000"); -const BLOCK_INFO_DEPOSIT_TX_HASH: B256 = +pub const BLOCK_INFO_DEPOSIT_TX: Bytes = bytes!("0x7ef90104a06c0c775b6b492bab9d7e81abdf27f77cafb698551226455a82f559e0f93fea3794deaddeaddeaddeaddeaddeaddeaddeaddead00019442000000000000000000000000000000000000158080830f424080b8b0098999be000008dd00101c1200000000000000020000000068869d6300000000015f277f000000000000000000000000000000000000000000000000000000000d42ac290000000000000000000000000000000000000000000000000000000000000001abf52777e63959936b1bf633a2a643f0da38d63deffe49452fed1bf8a44975d50000000000000000000000005050f69a9786f081509234f1a7f4684b5e5b76c9000000000000000000000000"); +pub const BLOCK_INFO_DEPOSIT_TX_HASH: B256 = b256!("0xba56c8b0deb460ff070f8fca8e2ee01e51a3db27841cc862fdd94cc1a47662b6"); pub fn secret_from_hex(hex_key: &str) -> B256 { diff --git a/crates/test-utils/README.md b/crates/test-utils/README.md index 1f1cc7fd..6d020e9b 100644 --- a/crates/test-utils/README.md +++ b/crates/test-utils/README.md @@ -104,7 +104,7 @@ async fn test_harness() -> eyre::Result<()> { - `provider()` - Get Alloy RootProvider for RPC calls - `accounts()` - Access test accounts - `advance_chain(n)` - Build N empty blocks -- `build_block_from_transactions(txs)` - Build block with specific transactions +- `build_block_from_transactions(txs)` - Build block with specific transactions (auto-prepends the L1 block info deposit) - `send_flashblock(fb)` - Send a single flashblock to the node for pending state processing - `send_flashblocks(iter)` - Convenience helper that sends multiple flashblocks sequentially diff --git a/crates/test-utils/src/harness.rs b/crates/test-utils/src/harness.rs index aade0c2b..8056283f 100644 --- a/crates/test-utils/src/harness.rs +++ b/crates/test-utils/src/harness.rs @@ -4,7 +4,7 @@ use crate::accounts::TestAccounts; use crate::engine::{EngineApi, IpcEngine}; use crate::node::{LocalFlashblocksState, LocalNode, LocalNodeProvider, OpAddOns, OpBuilder}; use alloy_eips::eip7685::Requests; -use alloy_primitives::{Bytes, B256}; +use alloy_primitives::{bytes, Bytes, B256}; use alloy_provider::{Provider, RootProvider}; use alloy_rpc_types::BlockNumberOrTag; use alloy_rpc_types_engine::PayloadAttributes; @@ -24,6 +24,8 @@ const BLOCK_TIME_SECONDS: u64 = 2; const GAS_LIMIT: u64 = 200_000_000; const NODE_STARTUP_DELAY_MS: u64 = 500; const BLOCK_BUILD_DELAY_MS: u64 = 100; +// Pre-captured L1 block info deposit transaction required by the Optimism EVM. +const L1_BLOCK_INFO_DEPOSIT_TX: Bytes = bytes!("0x7ef90104a06c0c775b6b492bab9d7e81abdf27f77cafb698551226455a82f559e0f93fea3794deaddeaddeaddeaddeaddeaddeaddeaddead00019442000000000000000000000000000000000000158080830f424080b8b0098999be000008dd00101c1200000000000000020000000068869d6300000000015f277f000000000000000000000000000000000000000000000000000000000d42ac290000000000000000000000000000000000000000000000000000000000000001abf52777e63959936b1bf633a2a643f0da38d63deffe49452fed1bf8a44975d50000000000000000000000005050f69a9786f081509234f1a7f4684b5e5b76c9000000000000000000000000"); pub struct TestHarness { node: LocalNode, @@ -72,7 +74,15 @@ impl TestHarness { format!("http://{}", self.node.http_api_addr) } - pub async fn build_block_from_transactions(&self, transactions: Vec) -> Result<()> { + pub async fn build_block_from_transactions(&self, mut transactions: Vec) -> Result<()> { + // Ensure the block always starts with the required L1 block info deposit. + if !transactions + .first() + .is_some_and(|tx| tx == &L1_BLOCK_INFO_DEPOSIT_TX) + { + transactions.insert(0, L1_BLOCK_INFO_DEPOSIT_TX.clone()); + } + let latest_block = self .provider() .get_block_by_number(BlockNumberOrTag::Latest) From 5aacb8a1d395816761bcaf3178be36e69211a47a Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Tue, 11 Nov 2025 17:09:43 -0600 Subject: [PATCH 12/25] Restore beacon root for successful metering tests --- crates/test-utils/src/harness.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/test-utils/src/harness.rs b/crates/test-utils/src/harness.rs index 8056283f..d70a7b9a 100644 --- a/crates/test-utils/src/harness.rs +++ b/crates/test-utils/src/harness.rs @@ -90,12 +90,16 @@ impl TestHarness { .ok_or_else(|| eyre!("No genesis block found"))?; let parent_hash = latest_block.header.hash; + let parent_beacon_block_root = latest_block + .header + .parent_beacon_block_root + .unwrap_or(B256::ZERO); let next_timestamp = latest_block.header.timestamp + BLOCK_TIME_SECONDS; let payload_attributes = OpPayloadAttributes { payload_attributes: PayloadAttributes { timestamp: next_timestamp, - parent_beacon_block_root: Some(B256::ZERO), + parent_beacon_block_root: Some(parent_beacon_block_root), withdrawals: Some(vec![]), ..Default::default() }, From e55c43aef329baf990f300f084b6a2c5761d4761 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Tue, 11 Nov 2025 17:13:37 -0600 Subject: [PATCH 13/25] Have storage persistence test meter the next block --- crates/metering/src/tests/chain_state.rs | 53 ++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/crates/metering/src/tests/chain_state.rs b/crates/metering/src/tests/chain_state.rs index 23c5903c..fbd11041 100644 --- a/crates/metering/src/tests/chain_state.rs +++ b/crates/metering/src/tests/chain_state.rs @@ -354,6 +354,8 @@ async fn counter_storage_changes_persist_across_blocks() -> Result<()> { .build_block_from_transactions(vec![increment_bytes]) .await?; + nonce += 1; + let storage_value = provider .get_storage_at(contract_address, U256::ZERO) .await?; @@ -371,5 +373,56 @@ async fn counter_storage_changes_persist_across_blocks() -> Result<()> { let decoded: U256 = Counter::numberCall::abi_decode_returns(raw_number.as_ref())?; assert_eq!(decoded, U256::from(43u64)); + // Meter another increment (nonce 3) to ensure meter_bundle sees the persisted state. + let meter_increment_call = Counter::incrementCall {}; + let meter_increment_signed = TransactionBuilder::default() + .signer(alice_secret) + .chain_id(BASE_CHAIN_ID) + .nonce(nonce) + .gas_limit(CALL_GAS_LIMIT) + .max_fee_per_gas(GWEI) + .max_priority_fee_per_gas(GWEI) + .to(contract_address) + .input(Bytes::from(meter_increment_call.abi_encode())) + .into_eip1559(); + let (_meter_increment_envelope, meter_increment_bytes) = + envelope_from_signed(meter_increment_signed.clone()); + + let bundle = Bundle { + txs: vec![meter_increment_bytes.clone()], + block_number: provider.get_block_number().await?, + 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![], + }; + let metering_api = + MeteringApiImpl::new(harness.blockchain_provider(), harness.flashblocks_state()); + let response = MeteringApiServer::meter_bundle(&metering_api, bundle) + .await + .map_err(|err| eyre!("meter_bundle rpc failed: {}", err))?; + + assert_eq!(response.results.len(), 1); + let metering_result = &response.results[0]; + assert_eq!(metering_result.to_address, Some(contract_address)); + assert!(metering_result.gas_used > 0); + + // Canonical state remains unchanged by the simulation. + let raw_number_after_sim = provider + .call( + OpTransactionRequest::default() + .from(alice.address) + .to(contract_address) + .input(TransactionInput::new(Bytes::from(number_call.abi_encode()))), + ) + .block(BlockNumberOrTag::Latest.into()) + .await?; + let decoded_after_sim: U256 = + Counter::numberCall::abi_decode_returns(raw_number_after_sim.as_ref())?; + assert_eq!(decoded_after_sim, U256::from(43u64)); + Ok(()) } From a41a0017cd386706eda5a08427365b2a25acf444 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Tue, 11 Nov 2025 17:18:55 -0600 Subject: [PATCH 14/25] Rename metering tests for clarity --- crates/metering/src/tests/chain_state.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/metering/src/tests/chain_state.rs b/crates/metering/src/tests/chain_state.rs index fbd11041..d4e26486 100644 --- a/crates/metering/src/tests/chain_state.rs +++ b/crates/metering/src/tests/chain_state.rs @@ -21,7 +21,7 @@ use crate::rpc::{MeteringApiImpl, MeteringApiServer}; use op_alloy_rpc_types::OpTransactionRequest; #[tokio::test] -async fn metering_succeeds_after_storage_change() -> Result<()> { +async fn meter_bundle_simulation_reflects_pending_state() -> Result<()> { reth_tracing::init_test_tracing(); let harness = TestHarness::new(default_launcher).await?; @@ -144,7 +144,7 @@ async fn metering_succeeds_after_storage_change() -> Result<()> { } #[tokio::test] -async fn pending_flashblock_updates_state() -> Result<()> { +async fn meter_bundle_errors_when_beacon_root_missing() -> Result<()> { reth_tracing::init_test_tracing(); let harness = TestHarness::new(default_launcher).await?; @@ -286,7 +286,7 @@ fn envelope_from_signed(tx: TransactionSigned) -> (OpTxEnvelope, Bytes) { } #[tokio::test] -async fn counter_storage_changes_persist_across_blocks() -> Result<()> { +async fn meter_bundle_reads_canonical_storage_without_mutation() -> Result<()> { reth_tracing::init_test_tracing(); let harness = TestHarness::new(default_launcher).await?; let alice = &harness.accounts().alice; From 3572284f57892e3459e04a1a7bd02f1c68021e2d Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Tue, 11 Nov 2025 17:24:27 -0600 Subject: [PATCH 15/25] Drop redundant beacon-root RPC test --- crates/metering/src/tests/rpc.rs | 76 ++------------------------------ 1 file changed, 3 insertions(+), 73 deletions(-) diff --git a/crates/metering/src/tests/rpc.rs b/crates/metering/src/tests/rpc.rs index 39a3b04c..22dddcbd 100644 --- a/crates/metering/src/tests/rpc.rs +++ b/crates/metering/src/tests/rpc.rs @@ -1,21 +1,18 @@ use crate::rpc::{MeteringApiImpl, MeteringApiServer}; -use alloy_consensus::Receipt; use alloy_eips::Encodable2718; -use alloy_primitives::{address, Bytes, B256, U256}; +use alloy_primitives::{address, Bytes, U256}; use alloy_provider::Provider; -use base_reth_flashblocks_rpc::rpc::FlashblocksAPI; use base_reth_test_utils::harness::TestHarness; use base_reth_test_utils::node::{ default_launcher, LocalFlashblocksState, LocalNodeProvider, BASE_CHAIN_ID, }; use eyre::{eyre, Result}; use op_alloy_consensus::OpTxEnvelope; -use reth_optimism_primitives::{OpReceipt, OpTransactionSigned}; -use reth_provider::HeaderProvider; +use reth_optimism_primitives::OpTransactionSigned; use reth_transaction_pool::test_utils::TransactionBuilder; use tips_core::types::Bundle; -use super::utils::{build_single_flashblock, secret_from_hex}; +use super::utils::secret_from_hex; struct RpcTestContext { harness: TestHarness, @@ -331,70 +328,3 @@ async fn test_meter_bundle_gas_calculations() -> Result<()> { Ok(()) } - -#[tokio::test] -async fn flashblock_without_beacon_root_errors() -> Result<()> { - reth_tracing::init_test_tracing(); - let ctx = RpcTestContext::new().await?; - - let provider = ctx.harness().provider(); - let latest_block = provider.get_block_number().await?; - let blockchain_provider = ctx.harness().blockchain_provider(); - let latest_header = blockchain_provider - .sealed_header(latest_block)? - .ok_or_else(|| eyre!("missing header for block {}", latest_block))?; - - let alice_secret = secret_from_hex(ctx.accounts().alice.private_key); - let tx = TransactionBuilder::default() - .signer(alice_secret) - .chain_id(BASE_CHAIN_ID) - .nonce(0) - .to(ctx.accounts().bob.address) - .value(1) - .gas_limit(21_000) - .max_fee_per_gas(1_000_000_000) - .max_priority_fee_per_gas(1_000_000_000) - .into_eip1559(); - - let envelope = OpTxEnvelope::from(OpTransactionSigned::Eip1559( - tx.as_eip1559().unwrap().clone(), - )); - let tx_hash = envelope.tx_hash(); - let tx_bytes = Bytes::from(envelope.encoded_2718()); - let receipt = OpReceipt::Eip1559(Receipt { - status: true.into(), - cumulative_gas_used: 21_000, - logs: vec![], - }); - - // Zero-out the parent beacon block root to emulate a flashblock that lacks Cancun data. - let flashblock = build_single_flashblock( - latest_header.number + 1, - latest_header.hash(), - B256::ZERO, - latest_header.timestamp + 2, - latest_header.gas_limit, - vec![(tx_bytes.clone(), Some((tx_hash, receipt.clone())))], - ); - - ctx.harness().send_flashblock(flashblock).await?; - - let bundle = create_bundle(vec![tx_bytes], latest_header.number + 1, None); - let err = ctx - .meter_bundle_raw(bundle) - .await - .expect_err("pending flashblock metering should fail without beacon root"); - assert!(err.message().contains("parent beacon block root missing")); - - let pending_blocks = ctx.harness().flashblocks_state().get_pending_blocks(); - assert!( - pending_blocks.is_some(), - "flashblock should populate pending state" - ); - assert_eq!( - pending_blocks.as_ref().unwrap().latest_flashblock_index(), - 0 - ); - - Ok(()) -} From eb37396f6846def235032c5d548fef42a8ef1ccf Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Mon, 17 Nov 2025 11:35:01 -0600 Subject: [PATCH 16/25] Document why flashblock trie caching isolates bundle I/O Add comments explaining that the trie cache ensures each bundle's state root calculation measures only the bundle's incremental I/O, not the accumulated I/O from previous flashblocks. --- crates/metering/src/flashblock_trie_cache.rs | 6 ++++-- crates/metering/src/meter.rs | 8 ++++---- crates/metering/src/rpc.rs | 7 +++---- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/crates/metering/src/flashblock_trie_cache.rs b/crates/metering/src/flashblock_trie_cache.rs index 5d1d5cd4..4e11c72a 100644 --- a/crates/metering/src/flashblock_trie_cache.rs +++ b/crates/metering/src/flashblock_trie_cache.rs @@ -10,8 +10,10 @@ use crate::FlashblocksState; /// Trie nodes and hashed state from computing a flashblock state root. /// -/// These cached nodes can be reused when computing a bundle's state root -/// to avoid recalculating the flashblock portion of the trie. +/// When metering bundles, we want each state root calculation to measure only +/// the bundle's incremental I/O, not I/O from previous flashblocks. By caching +/// the flashblock trie once and reusing it for all bundle simulations, we ensure +/// each bundle's state root time reflects only its own I/O cost. #[derive(Debug, Clone)] pub struct FlashblockTrieData { pub trie_updates: TrieUpdates, diff --git a/crates/metering/src/meter.rs b/crates/metering/src/meter.rs index fcaa1d15..7dc147e9 100644 --- a/crates/metering/src/meter.rs +++ b/crates/metering/src/meter.rs @@ -62,13 +62,12 @@ where // Get bundle hash let bundle_hash = bundle.bundle_hash(); - // Consolidate flashblock trie data: use cached if available, otherwise compute it - // (before starting any timers, since we only want to time the bundle's execution and state root) + // Get flashblock trie data before starting timers. This ensures we only measure + // the bundle's incremental I/O cost, not I/O from previous flashblocks. let flashblock_trie_data = cached_flashblock_trie .map(Ok::<_, eyre::Report>) .or_else(|| { flashblocks_state.as_ref().map(|fb_state| { - // Compute the flashblock trie let fb_hashed_state = state_provider.hashed_post_state(&fb_state.bundle_state); let (_fb_state_root, fb_trie_updates) = state_provider.state_root_with_updates(fb_hashed_state.clone())?; @@ -173,7 +172,8 @@ where let hashed_state = state_provider.hashed_post_state(&bundle_update); if let Some(fb_trie_data) = flashblock_trie_data { - // We have flashblock trie data (either cached or computed), use it + // Prepend cached flashblock trie so state root calculation only performs I/O + // for this bundle's changes, not for previous flashblocks. let mut trie_input = TrieInput::from_state(hashed_state); trie_input.prepend_cached(fb_trie_data.trie_updates, fb_trie_data.hashed_state); let _ = state_provider.state_root_from_nodes_with_updates(trie_input)?; diff --git a/crates/metering/src/rpc.rs b/crates/metering/src/rpc.rs index 81ee6a10..f7196007 100644 --- a/crates/metering/src/rpc.rs +++ b/crates/metering/src/rpc.rs @@ -28,7 +28,8 @@ pub trait MeteringApi { pub struct MeteringApiImpl { provider: Provider, flashblocks_state: Arc, - /// Single-entry cache for the latest flashblock's trie nodes + /// Cache for the latest flashblock's trie, ensuring each bundle's state root + /// calculation only measures the bundle's incremental I/O. trie_cache: FlashblockTrieCache, } @@ -152,11 +153,9 @@ where .as_ref() .map(|pb| pb.latest_flashblock_index()); - // If we have flashblocks, ensure the trie is cached and get it + // Ensure the flashblock trie is cached for reuse across bundle simulations let cached_trie = if let Some(ref fb_state) = flashblocks_state { let fb_index = state_flashblock_index.unwrap(); - - // Ensure the flashblock trie is cached and return it Some( self.trie_cache .ensure_cached(header.hash(), fb_index, fb_state, &*state_provider) From 82b03cbd179b06e2a49cbea9e8096d255bda61c8 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Mon, 17 Nov 2025 11:59:25 -0600 Subject: [PATCH 17/25] Move build_single_flashblock from metering to test-utils --- Cargo.lock | 14 +++- crates/metering/src/tests/chain_state.rs | 4 +- crates/metering/src/tests/utils.rs | 73 +------------------ crates/test-utils/src/flashblocks.rs | 93 ++++++++++++++++++++++++ crates/test-utils/src/lib.rs | 1 + 5 files changed, 110 insertions(+), 75 deletions(-) create mode 100644 crates/test-utils/src/flashblocks.rs diff --git a/Cargo.lock b/Cargo.lock index 51a7c457..a59045b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1589,12 +1589,16 @@ dependencies = [ "alloy-provider", "alloy-rpc-client", "alloy-rpc-types-engine", + "alloy-rpc-types-eth", + "alloy-sol-types", "arc-swap", "base-reth-flashblocks-rpc", "base-reth-test-utils", "eyre", + "hex-literal", "jsonrpsee 0.26.0", "op-alloy-consensus 0.22.3", + "op-alloy-rpc-types", "rand 0.9.2", "reth", "reth-db", @@ -4111,6 +4115,12 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + [[package]] name = "hickory-proto" version = "0.25.2" @@ -4369,7 +4379,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -12764,7 +12774,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/metering/src/tests/chain_state.rs b/crates/metering/src/tests/chain_state.rs index d4e26486..88e5ab7e 100644 --- a/crates/metering/src/tests/chain_state.rs +++ b/crates/metering/src/tests/chain_state.rs @@ -16,7 +16,9 @@ use reth_primitives::TransactionSigned; use reth_transaction_pool::test_utils::TransactionBuilder; use tips_core::types::Bundle; -use super::utils::{build_single_flashblock, secret_from_hex}; +use base_reth_test_utils::flashblocks::build_single_flashblock; + +use super::utils::secret_from_hex; use crate::rpc::{MeteringApiImpl, MeteringApiServer}; use op_alloy_rpc_types::OpTransactionRequest; diff --git a/crates/metering/src/tests/utils.rs b/crates/metering/src/tests/utils.rs index b7464cf6..b4fc3a9d 100644 --- a/crates/metering/src/tests/utils.rs +++ b/crates/metering/src/tests/utils.rs @@ -1,10 +1,6 @@ use std::sync::Arc; -use alloy_consensus::Receipt; -use alloy_primitives::{b256, bytes, hex::FromHex, map::HashMap, Address, Bytes, B256, U256}; -use alloy_rpc_types_engine::PayloadId; -use base_reth_flashblocks_rpc::subscription::{Flashblock, Metadata}; -use op_alloy_consensus::OpDepositReceipt; +use alloy_primitives::{hex::FromHex, B256}; use reth::api::{NodeTypes, NodeTypesWithDBAdapter}; use reth_db::{ init_db, @@ -12,16 +8,7 @@ use reth_db::{ test_utils::{create_test_static_files_dir, tempdir_path, TempDatabase, ERROR_DB_CREATION}, ClientVersion, DatabaseEnv, }; -use reth_optimism_primitives::OpReceipt; use reth_provider::{providers::StaticFileProvider, ProviderFactory}; -use rollup_boost::{ExecutionPayloadBaseV1, ExecutionPayloadFlashblockDeltaV1}; - -const FLASHBLOCK_PAYLOAD_ID: [u8; 8] = [0; 8]; -// Pre-captured deposit transaction and hash used by flashblocks tests for the L1 block info deposit. -// Values match `BLOCK_INFO_TXN` and `BLOCK_INFO_TXN_HASH` from `crates/flashblocks-rpc/src/tests/mod.rs`. -pub const BLOCK_INFO_DEPOSIT_TX: Bytes = bytes!("0x7ef90104a06c0c775b6b492bab9d7e81abdf27f77cafb698551226455a82f559e0f93fea3794deaddeaddeaddeaddeaddeaddeaddeaddead00019442000000000000000000000000000000000000158080830f424080b8b0098999be000008dd00101c1200000000000000020000000068869d6300000000015f277f000000000000000000000000000000000000000000000000000000000d42ac290000000000000000000000000000000000000000000000000000000000000001abf52777e63959936b1bf633a2a643f0da38d63deffe49452fed1bf8a44975d50000000000000000000000005050f69a9786f081509234f1a7f4684b5e5b76c9000000000000000000000000"); -pub const BLOCK_INFO_DEPOSIT_TX_HASH: B256 = - b256!("0xba56c8b0deb460ff070f8fca8e2ee01e51a3db27841cc862fdd94cc1a47662b6"); pub fn secret_from_hex(hex_key: &str) -> B256 { B256::from_hex(hex_key).expect("32-byte private key") @@ -54,61 +41,3 @@ fn create_test_db() -> Arc> { Arc::new(TempDatabase::new(db, path)) } - -pub fn build_single_flashblock( - block_number: u64, - parent_hash: B256, - parent_beacon_block_root: B256, - timestamp: u64, - gas_limit: u64, - transactions: Vec<(Bytes, Option<(B256, OpReceipt)>)>, -) -> Flashblock { - let base = ExecutionPayloadBaseV1 { - parent_beacon_block_root, - parent_hash, - fee_recipient: Address::ZERO, - prev_randao: B256::ZERO, - block_number, - gas_limit, - timestamp, - extra_data: Bytes::new(), - base_fee_per_gas: U256::from(1), - }; - - let mut flashblock_txs = vec![BLOCK_INFO_DEPOSIT_TX.clone()]; - let mut receipts = HashMap::default(); - receipts.insert( - BLOCK_INFO_DEPOSIT_TX_HASH, - OpReceipt::Deposit(OpDepositReceipt { - inner: Receipt { - status: true.into(), - cumulative_gas_used: 10_000, - logs: vec![], - }, - deposit_nonce: Some(4_012_991u64), - deposit_receipt_version: None, - }), - ); - - for (tx_bytes, maybe_receipt) in transactions { - if let Some((hash, receipt)) = maybe_receipt { - receipts.insert(hash, receipt); - } - flashblock_txs.push(tx_bytes); - } - - Flashblock { - payload_id: PayloadId::new(FLASHBLOCK_PAYLOAD_ID), - index: 0, - base: Some(base), - diff: ExecutionPayloadFlashblockDeltaV1 { - transactions: flashblock_txs, - ..Default::default() - }, - metadata: Metadata { - receipts, - new_account_balances: Default::default(), - block_number, - }, - } -} diff --git a/crates/test-utils/src/flashblocks.rs b/crates/test-utils/src/flashblocks.rs new file mode 100644 index 00000000..199a0022 --- /dev/null +++ b/crates/test-utils/src/flashblocks.rs @@ -0,0 +1,93 @@ +//! Flashblock testing utilities + +use alloy_consensus::Receipt; +use alloy_primitives::{b256, bytes, Address, Bytes, B256, U256}; +use alloy_rpc_types_engine::PayloadId; +use base_reth_flashblocks_rpc::subscription::{Flashblock, Metadata}; +use op_alloy_consensus::OpDepositReceipt; +use reth_optimism_primitives::OpReceipt; +use rollup_boost::{ExecutionPayloadBaseV1, ExecutionPayloadFlashblockDeltaV1}; +use std::collections::HashMap; + +const FLASHBLOCK_PAYLOAD_ID: [u8; 8] = [0; 8]; + +// Pre-captured deposit transaction and hash used by flashblocks tests for the L1 block info deposit. +// Values match `BLOCK_INFO_TXN` and `BLOCK_INFO_TXN_HASH` from `crates/flashblocks-rpc/src/tests/mod.rs`. +const BLOCK_INFO_DEPOSIT_TX: Bytes = bytes!("0x7ef90104a06c0c775b6b492bab9d7e81abdf27f77cafb698551226455a82f559e0f93fea3794deaddeaddeaddeaddeaddeaddeaddeaddead00019442000000000000000000000000000000000000158080830f424080b8b0098999be000008dd00101c1200000000000000020000000068869d6300000000015f277f000000000000000000000000000000000000000000000000000000000d42ac290000000000000000000000000000000000000000000000000000000000000001abf52777e63959936b1bf633a2a643f0da38d63deffe49452fed1bf8a44975d50000000000000000000000005050f69a9786f081509234f1a7f4684b5e5b76c9000000000000000000000000"); +const BLOCK_INFO_DEPOSIT_TX_HASH: B256 = + b256!("0xba56c8b0deb460ff070f8fca8e2ee01e51a3db27841cc862fdd94cc1a47662b6"); + +/// Builds a single flashblock for testing purposes. +/// +/// This utility creates a base flashblock (index 0) with the required L1 block info deposit +/// transaction and any additional transactions provided. +/// +/// # Arguments +/// +/// * `block_number` - The block number for this flashblock +/// * `parent_hash` - Hash of the parent block +/// * `parent_beacon_block_root` - Parent beacon block root +/// * `timestamp` - Block timestamp +/// * `gas_limit` - Gas limit for the block +/// * `transactions` - Vector of (transaction bytes, optional (hash, receipt)) tuples +/// +/// # Returns +/// +/// A `Flashblock` configured for testing with the provided parameters +pub fn build_single_flashblock( + block_number: u64, + parent_hash: B256, + parent_beacon_block_root: B256, + timestamp: u64, + gas_limit: u64, + transactions: Vec<(Bytes, Option<(B256, OpReceipt)>)>, +) -> Flashblock { + let base = ExecutionPayloadBaseV1 { + parent_beacon_block_root, + parent_hash, + fee_recipient: Address::ZERO, + prev_randao: B256::ZERO, + block_number, + gas_limit, + timestamp, + extra_data: Bytes::new(), + base_fee_per_gas: U256::from(1), + }; + + let mut flashblock_txs = vec![BLOCK_INFO_DEPOSIT_TX.clone()]; + let mut receipts = HashMap::default(); + receipts.insert( + BLOCK_INFO_DEPOSIT_TX_HASH, + OpReceipt::Deposit(OpDepositReceipt { + inner: Receipt { + status: true.into(), + cumulative_gas_used: 10_000, + logs: vec![], + }, + deposit_nonce: Some(4_012_991u64), + deposit_receipt_version: None, + }), + ); + + for (tx_bytes, maybe_receipt) in transactions { + if let Some((hash, receipt)) = maybe_receipt { + receipts.insert(hash, receipt); + } + flashblock_txs.push(tx_bytes); + } + + Flashblock { + payload_id: PayloadId::new(FLASHBLOCK_PAYLOAD_ID), + index: 0, + base: Some(base), + diff: ExecutionPayloadFlashblockDeltaV1 { + transactions: flashblock_txs, + ..Default::default() + }, + metadata: Metadata { + receipts, + new_account_balances: Default::default(), + block_number, + }, + } +} diff --git a/crates/test-utils/src/lib.rs b/crates/test-utils/src/lib.rs index 2c6b290d..4c3155cf 100644 --- a/crates/test-utils/src/lib.rs +++ b/crates/test-utils/src/lib.rs @@ -1,5 +1,6 @@ pub mod accounts; pub mod engine; +pub mod flashblocks; pub mod flashblocks_harness; pub mod harness; pub mod node; From 4211c106ade8a3c2d7484a9b1bb062897333ea70 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Mon, 17 Nov 2025 12:08:25 -0600 Subject: [PATCH 18/25] tweak comments --- crates/metering/src/meter.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/metering/src/meter.rs b/crates/metering/src/meter.rs index 7dc147e9..624f99bf 100644 --- a/crates/metering/src/meter.rs +++ b/crates/metering/src/meter.rs @@ -81,7 +81,8 @@ where // Create state database let state_db = reth::revm::database::StateProviderDatabase::new(state_provider); - // If we have flashblocks state, apply both cache and bundle prestate + + // Apply flashblocks read cache if available let cache_db = if let Some(ref flashblocks) = flashblocks_state { CacheDB { cache: flashblocks.cache.clone(), @@ -91,7 +92,7 @@ where CacheDB::new(state_db) }; - // Wrap the CacheDB in a State to track bundle changes for state root calculation + // Track bundle state changes. If metering using flashblocks state, include its bundle prestate. let mut db = if let Some(flashblocks) = flashblocks_state.as_ref() { State::builder() .with_database(cache_db) From 5b05bb2d1fc77ba91ef0f50ad87b6fb9906274e8 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Tue, 25 Nov 2025 16:31:16 -0600 Subject: [PATCH 19/25] Update tips-core and fix metering harness/tests --- Cargo.lock | 68 +-- Cargo.toml | 2 +- crates/flashblocks-rpc/src/state.rs | 27 +- crates/metering/src/flashblock_trie_cache.rs | 5 +- crates/metering/src/lib.rs | 2 +- crates/metering/src/meter.rs | 10 +- crates/metering/src/rpc.rs | 25 +- crates/metering/src/tests/chain_state.rs | 93 ++-- crates/metering/src/tests/meter.rs | 9 +- crates/metering/src/tests/rpc.rs | 73 ++- crates/metering/src/tests/utils.rs | 11 +- crates/test-utils/README.md | 84 ++-- crates/test-utils/src/flashblocks.rs | 93 ---- crates/test-utils/src/flashblocks_harness.rs | 72 ++- crates/test-utils/src/harness.rs | 119 ++--- crates/test-utils/src/lib.rs | 1 - crates/test-utils/src/node.rs | 490 +++++++++++++------ 17 files changed, 620 insertions(+), 564 deletions(-) delete mode 100644 crates/test-utils/src/flashblocks.rs diff --git a/Cargo.lock b/Cargo.lock index a59045b2..0c23815a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -256,7 +256,7 @@ dependencies = [ "alloy-sol-types", "auto_impl", "derive_more", - "op-alloy-consensus 0.22.3", + "op-alloy-consensus", "op-alloy-rpc-types-engine", "op-revm", "revm", @@ -370,7 +370,7 @@ dependencies = [ "alloy-op-hardforks", "alloy-primitives", "auto_impl", - "op-alloy-consensus 0.22.3", + "op-alloy-consensus", "op-revm", "revm", "thiserror 2.0.17", @@ -1543,7 +1543,7 @@ dependencies = [ "metrics", "metrics-derive", "once_cell", - "op-alloy-consensus 0.22.3", + "op-alloy-consensus", "op-alloy-network", "op-alloy-rpc-types", "rand 0.9.2", @@ -1597,7 +1597,7 @@ dependencies = [ "eyre", "hex-literal", "jsonrpsee 0.26.0", - "op-alloy-consensus 0.22.3", + "op-alloy-consensus", "op-alloy-rpc-types", "rand 0.9.2", "reth", @@ -1653,7 +1653,7 @@ dependencies = [ "metrics", "metrics-derive", "once_cell", - "op-alloy-consensus 0.22.3", + "op-alloy-consensus", "op-alloy-network", "op-alloy-rpc-jsonrpsee", "op-alloy-rpc-types", @@ -1709,7 +1709,7 @@ dependencies = [ "futures-util", "jsonrpsee 0.26.0", "once_cell", - "op-alloy-consensus 0.22.3", + "op-alloy-consensus", "op-alloy-network", "op-alloy-rpc-types", "op-alloy-rpc-types-engine", @@ -4359,7 +4359,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.1", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -4379,7 +4379,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.57.0", ] [[package]] @@ -5893,20 +5893,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "op-alloy-consensus" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a501241474c3118833d6195312ae7eb7cc90bbb0d5f524cbb0b06619e49ff67" -dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-primitives", - "alloy-rlp", - "derive_more", - "thiserror 2.0.17", -] - [[package]] name = "op-alloy-consensus" version = "0.22.3" @@ -5945,7 +5931,7 @@ dependencies = [ "alloy-provider", "alloy-rpc-types-eth", "alloy-signer", - "op-alloy-consensus 0.22.3", + "op-alloy-consensus", "op-alloy-rpc-types", ] @@ -5972,7 +5958,7 @@ dependencies = [ "alloy-rpc-types-eth", "alloy-serde", "derive_more", - "op-alloy-consensus 0.22.3", + "op-alloy-consensus", "serde", "serde_json", "thiserror 2.0.17", @@ -5993,7 +5979,7 @@ dependencies = [ "derive_more", "ethereum_ssz", "ethereum_ssz_derive", - "op-alloy-consensus 0.22.3", + "op-alloy-consensus", "serde", "snap", "thiserror 2.0.17", @@ -6793,7 +6779,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2 0.6.1", + "socket2 0.5.10", "thiserror 2.0.17", "tokio", "tracing", @@ -6830,7 +6816,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.1", + "socket2 0.5.10", "tracing", "windows-sys 0.60.2", ] @@ -7398,7 +7384,7 @@ dependencies = [ "arbitrary", "bytes", "modular-bitfield", - "op-alloy-consensus 0.22.3", + "op-alloy-consensus", "reth-codecs-derive", "reth-zstd-compressors", "serde", @@ -8844,7 +8830,7 @@ dependencies = [ "alloy-primitives", "derive_more", "miniz_oxide", - "op-alloy-consensus 0.22.3", + "op-alloy-consensus", "op-alloy-rpc-types", "paste", "reth-chainspec", @@ -8872,7 +8858,7 @@ dependencies = [ "derive_more", "eyre", "futures-util", - "op-alloy-consensus 0.22.3", + "op-alloy-consensus", "reth-chainspec", "reth-cli", "reth-cli-commands", @@ -8944,7 +8930,7 @@ dependencies = [ "alloy-evm", "alloy-op-evm", "alloy-primitives", - "op-alloy-consensus 0.22.3", + "op-alloy-consensus", "op-alloy-rpc-types-engine", "op-revm", "reth-chainspec", @@ -9023,7 +9009,7 @@ dependencies = [ "alloy-rpc-types-eth", "clap", "eyre", - "op-alloy-consensus 0.22.3", + "op-alloy-consensus", "op-alloy-rpc-types-engine", "op-revm", "reth-chainspec", @@ -9071,7 +9057,7 @@ dependencies = [ "alloy-rpc-types-debug", "alloy-rpc-types-engine", "derive_more", - "op-alloy-consensus 0.22.3", + "op-alloy-consensus", "op-alloy-rpc-types-engine", "reth-basic-payload-builder", "reth-chain-state", @@ -9110,7 +9096,7 @@ dependencies = [ "arbitrary", "bytes", "modular-bitfield", - "op-alloy-consensus 0.22.3", + "op-alloy-consensus", "reth-codecs", "reth-primitives-traits", "reth-zstd-compressors", @@ -9141,7 +9127,7 @@ dependencies = [ "jsonrpsee-core 0.26.0", "jsonrpsee-types 0.26.0", "metrics", - "op-alloy-consensus 0.22.3", + "op-alloy-consensus", "op-alloy-network", "op-alloy-rpc-jsonrpsee", "op-alloy-rpc-types", @@ -9205,7 +9191,7 @@ dependencies = [ "derive_more", "futures-util", "metrics", - "op-alloy-consensus 0.22.3", + "op-alloy-consensus", "op-alloy-flz", "op-alloy-rpc-types", "op-revm", @@ -9331,7 +9317,7 @@ dependencies = [ "derive_more", "modular-bitfield", "once_cell", - "op-alloy-consensus 0.22.3", + "op-alloy-consensus", "proptest", "proptest-arbitrary-interop", "rayon", @@ -9650,7 +9636,7 @@ dependencies = [ "auto_impl", "dyn-clone", "jsonrpsee-types 0.26.0", - "op-alloy-consensus 0.22.3", + "op-alloy-consensus", "op-alloy-network", "op-alloy-rpc-types", "op-revm", @@ -11758,13 +11744,13 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tips-core" version = "0.1.0" -source = "git+https://github.com/base/tips?rev=86b275c0fd63226c3fb85ac5512033f99b67d0f5#86b275c0fd63226c3fb85ac5512033f99b67d0f5" +source = "git+https://github.com/base/tips?rev=c08eaa4fe10c26de8911609b41ddab4918698325#c08eaa4fe10c26de8911609b41ddab4918698325" dependencies = [ "alloy-consensus", "alloy-primitives", "alloy-provider", "alloy-serde", - "op-alloy-consensus 0.20.0", + "op-alloy-consensus", "op-alloy-flz", "serde", "tracing", @@ -12774,7 +12760,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index e31f5c7c..1b76b115 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,7 +47,7 @@ base-reth-transaction-tracing = { path = "crates/transaction-tracing" } # base/tips # Note: default-features = false avoids version conflicts with reth's alloy/op-alloy dependencies -tips-core = { git = "https://github.com/base/tips", rev = "86b275c0fd63226c3fb85ac5512033f99b67d0f5", default-features = false } +tips-core = { git = "https://github.com/base/tips", rev = "c08eaa4fe10c26de8911609b41ddab4918698325", default-features = false } # reth reth = { git = "https://github.com/paradigmxyz/reth", tag = "v1.9.3" } diff --git a/crates/flashblocks-rpc/src/state.rs b/crates/flashblocks-rpc/src/state.rs index 99a573b3..1383ac79 100644 --- a/crates/flashblocks-rpc/src/state.rs +++ b/crates/flashblocks-rpc/src/state.rs @@ -22,22 +22,23 @@ use reth::{ chainspec::{ChainSpecProvider, EthChainSpec}, providers::{BlockReaderIdExt, StateProviderFactory}, revm::{ - context::result::ResultAndState, - database::StateProviderDatabase, + DatabaseCommit, State, context::result::ResultAndState, database::StateProviderDatabase, db::CacheDB, - DatabaseCommit, - State, }, }; -use revm_database::states::bundle_state::BundleRetention; use reth_evm::{ConfigureEvm, Evm}; use reth_optimism_chainspec::OpHardforks; use reth_optimism_evm::{OpEvmConfig, OpNextBlockEnvAttributes}; use reth_optimism_primitives::{DepositReceipt, OpBlock, OpPrimitives}; use reth_optimism_rpc::OpReceiptBuilder; use reth_primitives::RecoveredBlock; -use reth_rpc_convert::{transaction::ConvertReceiptInput, RpcTransaction}; -use reth_rpc_eth_api::{RpcBlock, RpcReceipt}; +use reth_rpc_convert::transaction::ConvertReceiptInput; +use revm_database::states::bundle_state::BundleRetention; +use tokio::sync::{ + Mutex, + broadcast::{self, Sender}, + mpsc::{self, UnboundedReceiver}, +}; use tracing::{debug, error, info, warn}; use crate::{ @@ -380,10 +381,9 @@ where // Cache reads across flashblocks, accumulating caches from previous // pending blocks if available let cache_db = match &prev_pending_blocks { - Some(pending_blocks) => CacheDB { - cache: pending_blocks.get_db_cache(), - db: state_provider_db, - }, + Some(pending_blocks) => { + CacheDB { cache: pending_blocks.get_db_cache(), db: state_provider_db } + } None => CacheDB::new(state_provider_db), }; @@ -395,10 +395,7 @@ where .with_bundle_update() .with_bundle_prestate(pending_blocks.get_bundle_state()) .build(), - None => State::builder() - .with_database(cache_db) - .with_bundle_update() - .build(), + None => State::builder().with_database(cache_db).with_bundle_update().build(), }; let mut state_overrides = match &prev_pending_blocks { diff --git a/crates/metering/src/flashblock_trie_cache.rs b/crates/metering/src/flashblock_trie_cache.rs index 4e11c72a..f1322b40 100644 --- a/crates/metering/src/flashblock_trie_cache.rs +++ b/crates/metering/src/flashblock_trie_cache.rs @@ -4,7 +4,7 @@ use alloy_primitives::B256; use arc_swap::ArcSwap; use eyre::Result as EyreResult; use reth_provider::StateProvider; -use reth_trie_common::{updates::TrieUpdates, HashedPostState}; +use reth_trie_common::{HashedPostState, updates::TrieUpdates}; use crate::FlashblocksState; @@ -60,7 +60,8 @@ impl FlashblockTrieCache { flashblocks_state: &FlashblocksState, canonical_state_provider: &dyn StateProvider, ) -> EyreResult { - if let Some(ref cached) = *self.cache.load() { + let cached_entry = self.cache.load(); + if let Some(cached) = cached_entry.as_ref() { if cached.block_hash == block_hash && cached.flashblock_index == flashblock_index { return Ok(cached.trie_data.clone()); } diff --git a/crates/metering/src/lib.rs b/crates/metering/src/lib.rs index 2c687a22..8d1d65bd 100644 --- a/crates/metering/src/lib.rs +++ b/crates/metering/src/lib.rs @@ -5,6 +5,6 @@ mod rpc; mod tests; pub use flashblock_trie_cache::{FlashblockTrieCache, FlashblockTrieData}; -pub use meter::{meter_bundle, FlashblocksState, MeterBundleOutput}; +pub use meter::{FlashblocksState, MeterBundleOutput, meter_bundle}; pub use rpc::{MeteringApiImpl, MeteringApiServer}; pub use tips_core::types::{Bundle, MeterBundleResponse, TransactionResult}; diff --git a/crates/metering/src/meter.rs b/crates/metering/src/meter.rs index 624f99bf..33c68047 100644 --- a/crates/metering/src/meter.rs +++ b/crates/metering/src/meter.rs @@ -84,10 +84,7 @@ where // Apply flashblocks read cache if available let cache_db = if let Some(ref flashblocks) = flashblocks_state { - CacheDB { - cache: flashblocks.cache.clone(), - db: state_db, - } + CacheDB { cache: flashblocks.cache.clone(), db: state_db } } else { CacheDB::new(state_db) }; @@ -100,10 +97,7 @@ where .with_bundle_prestate(flashblocks.bundle_state.clone()) .build() } else { - State::builder() - .with_database(cache_db) - .with_bundle_update() - .build() + State::builder().with_database(cache_db).with_bundle_update().build() }; // Set up next block attributes diff --git a/crates/metering/src/rpc.rs b/crates/metering/src/rpc.rs index f7196007..f55c2227 100644 --- a/crates/metering/src/rpc.rs +++ b/crates/metering/src/rpc.rs @@ -1,5 +1,6 @@ +use std::sync::Arc; + use alloy_consensus::{Header, Sealed}; -use alloy_eips::BlockNumberOrTag; use alloy_primitives::U256; use base_reth_flashblocks_rpc::rpc::{FlashblocksAPI, PendingBlocksAPI}; use jsonrpsee::{ @@ -10,11 +11,10 @@ use reth::providers::BlockReaderIdExt; use reth_optimism_chainspec::OpChainSpec; use reth_primitives_traits::SealedHeader; use reth_provider::{ChainSpecProvider, StateProviderFactory}; -use std::sync::Arc; use tips_core::types::{Bundle, MeterBundleResponse, ParsedBundle}; use tracing::{error, info}; -use crate::{meter_bundle, FlashblockTrieCache}; +use crate::{FlashblockTrieCache, meter_bundle}; /// RPC API for transaction metering #[rpc(server, namespace = "base")] @@ -43,11 +43,7 @@ where { /// Creates a new instance of MeteringApi pub fn new(provider: Provider, flashblocks_state: Arc) -> Self { - Self { - provider, - flashblocks_state, - trie_cache: FlashblockTrieCache::new(), - } + Self { provider, flashblocks_state, trie_cache: FlashblockTrieCache::new() } } } @@ -130,10 +126,8 @@ where })?; // Get state provider for the canonical block - let state_provider = self - .provider - .state_by_block_number_or_tag(canonical_block_number) - .map_err(|e| { + let state_provider = + self.provider.state_by_block_number_or_tag(canonical_block_number).map_err(|e| { error!(error = %e, "Failed to get state provider"); jsonrpsee::types::ErrorObjectOwned::owned( jsonrpsee::types::ErrorCode::InternalError.code(), @@ -149,9 +143,7 @@ where }); // Get the flashblock index if we have pending flashblocks - let state_flashblock_index = pending_blocks - .as_ref() - .map(|pb| pb.latest_flashblock_index()); + let state_flashblock_index = pending_blocks.as_ref().map(|pb| pb.latest_flashblock_index()); // Ensure the flashblock trie is cached for reuse across bundle simulations let cached_trie = if let Some(ref fb_state) = flashblocks_state { @@ -202,7 +194,6 @@ where num_transactions = result.results.len(), total_gas_used = result.total_gas_used, total_time_us = result.total_time_us, - state_root_time_us = result.state_root_time_us, state_block_number = header.number, flashblock_index = flashblock_index, "Bundle metering completed successfully" @@ -218,9 +209,7 @@ where state_block_number: header.number, state_flashblock_index, total_gas_used: result.total_gas_used, - // TODO: Rename to total_time_us in tips-core. total_execution_time_us: result.total_time_us, - state_root_time_us: result.state_root_time_us, }) } } diff --git a/crates/metering/src/tests/chain_state.rs b/crates/metering/src/tests/chain_state.rs index 88e5ab7e..baa592d0 100644 --- a/crates/metering/src/tests/chain_state.rs +++ b/crates/metering/src/tests/chain_state.rs @@ -1,31 +1,28 @@ use alloy_consensus::Receipt; use alloy_eips::{BlockNumberOrTag, Encodable2718}; -use alloy_primitives::{Bytes, B256, U256}; +use alloy_primitives::{B256, Bytes, U256}; use alloy_provider::Provider; use alloy_rpc_types_eth::TransactionInput; -use alloy_sol_types::{sol, SolCall}; +use alloy_sol_types::{SolCall, sol}; use base_reth_flashblocks_rpc::rpc::FlashblocksAPI; -use base_reth_test_utils::harness::TestHarness; -use base_reth_test_utils::node::{default_launcher, BASE_CHAIN_ID}; -use eyre::{eyre, Result}; +use base_reth_test_utils::{flashblocks_harness::FlashblocksHarness, node::BASE_CHAIN_ID}; +use eyre::{Result, eyre}; use hex_literal::hex; use op_alloy_consensus::OpTxEnvelope; +use op_alloy_rpc_types::OpTransactionRequest; use reth::providers::HeaderProvider; use reth_optimism_primitives::{OpReceipt, OpTransactionSigned}; use reth_primitives::TransactionSigned; use reth_transaction_pool::test_utils::TransactionBuilder; use tips_core::types::Bundle; -use base_reth_test_utils::flashblocks::build_single_flashblock; - use super::utils::secret_from_hex; use crate::rpc::{MeteringApiImpl, MeteringApiServer}; -use op_alloy_rpc_types::OpTransactionRequest; #[tokio::test] async fn meter_bundle_simulation_reflects_pending_state() -> Result<()> { reth_tracing::init_test_tracing(); - let harness = TestHarness::new(default_launcher).await?; + let harness = FlashblocksHarness::new().await?; let provider = harness.provider(); let alice = &harness.accounts().alice; @@ -42,9 +39,7 @@ async fn meter_bundle_simulation_reflects_pending_state() -> Result<()> { .input(COUNTER_CREATION_BYTECODE.to_vec()) .into_eip1559(); let (deploy_envelope, deploy_bytes) = envelope_from_signed(deploy_signed); - harness - .build_block_from_transactions(vec![deploy_bytes]) - .await?; + harness.build_block_from_transactions(vec![deploy_bytes]).await?; let deploy_receipt = provider .get_transaction_receipt(deploy_envelope.tx_hash()) @@ -56,9 +51,7 @@ async fn meter_bundle_simulation_reflects_pending_state() -> Result<()> { .ok_or_else(|| eyre!("deployment receipt missing contract address"))?; // Mutate storage on-chain via setNumber (nonce 1) - let set_call = Counter::setNumberCall { - newNumber: U256::from(42u64), - }; + let set_call = Counter::setNumberCall { newNumber: U256::from(42u64) }; let set_signed = TransactionBuilder::default() .signer(alice_secret) .chain_id(BASE_CHAIN_ID) @@ -70,9 +63,7 @@ async fn meter_bundle_simulation_reflects_pending_state() -> Result<()> { .input(Bytes::from(set_call.abi_encode())) .into_eip1559(); let (_set_envelope, set_bytes) = envelope_from_signed(set_signed); - harness - .build_block_from_transactions(vec![set_bytes]) - .await?; + harness.build_block_from_transactions(vec![set_bytes]).await?; // Meter an increment call (nonce 2) after the storage change let increment_call = Counter::incrementCall {}; @@ -118,17 +109,12 @@ async fn meter_bundle_simulation_reflects_pending_state() -> Result<()> { .from(alice.address) .to(contract_address) .input(TransactionInput::new(Bytes::from(number_call.abi_encode()))); - let raw_number = provider - .call(call_request) - .block(BlockNumberOrTag::Latest.into()) - .await?; + let raw_number = provider.call(call_request).block(BlockNumberOrTag::Latest.into()).await?; let decoded: U256 = Counter::numberCall::abi_decode_returns(raw_number.as_ref())?; assert_eq!(decoded, U256::from(42u64)); // Execute the increment on-chain to confirm the transaction is valid when mined - harness - .build_block_from_transactions(vec![increment_bytes]) - .await?; + harness.build_block_from_transactions(vec![increment_bytes]).await?; let number_after_increment = provider .call( OpTransactionRequest::default() @@ -148,7 +134,7 @@ async fn meter_bundle_simulation_reflects_pending_state() -> Result<()> { #[tokio::test] async fn meter_bundle_errors_when_beacon_root_missing() -> Result<()> { reth_tracing::init_test_tracing(); - let harness = TestHarness::new(default_launcher).await?; + let harness = FlashblocksHarness::new().await?; let provider = harness.provider(); let alice = &harness.accounts().alice; @@ -165,9 +151,7 @@ async fn meter_bundle_errors_when_beacon_root_missing() -> Result<()> { .input(COUNTER_CREATION_BYTECODE.to_vec()) .into_eip1559(); let (deploy_envelope, deploy_bytes) = envelope_from_signed(deploy_signed); - harness - .build_block_from_transactions(vec![deploy_bytes]) - .await?; + harness.build_block_from_transactions(vec![deploy_bytes]).await?; let contract_address = provider .get_transaction_receipt(deploy_envelope.tx_hash()) .await? @@ -183,9 +167,7 @@ async fn meter_bundle_errors_when_beacon_root_missing() -> Result<()> { .ok_or_else(|| eyre!("missing header for block {}", latest_number))?; let pending_block_number = latest_header.number + 1; - let flash_call = Counter::setNumberCall { - newNumber: U256::from(99u64), - }; + let flash_call = Counter::setNumberCall { newNumber: U256::from(99u64) }; let flash_signed = TransactionBuilder::default() .signer(alice_secret) .chain_id(BASE_CHAIN_ID) @@ -204,16 +186,13 @@ async fn meter_bundle_errors_when_beacon_root_missing() -> Result<()> { }); // Pending flashblock omits the beacon root, so metering will report the validation error. - let flashblock = build_single_flashblock( + let flashblock = harness.build_flashblock( pending_block_number, latest_header.hash(), B256::ZERO, latest_header.timestamp + 2, latest_header.gas_limit, - vec![( - flash_bytes.clone(), - Some((flash_envelope.tx_hash(), receipt.clone())), - )], + vec![(flash_bytes.clone(), Some((flash_envelope.tx_hash(), receipt.clone())))], ); harness.send_flashblock(flashblock).await?; @@ -241,10 +220,7 @@ async fn meter_bundle_errors_when_beacon_root_missing() -> Result<()> { let pending_blocks = harness.flashblocks_state().get_pending_blocks(); assert!(pending_blocks.is_some(), "expected flashblock to populate pending state"); - assert_eq!( - pending_blocks.as_ref().unwrap().latest_flashblock_index(), - 0 - ); + assert_eq!(pending_blocks.as_ref().unwrap().latest_flashblock_index(), 0); // Pending state should reflect the storage change even though the simulation failed. let number_call = Counter::numberCall {}; @@ -271,16 +247,16 @@ sol! { } } -const COUNTER_CREATION_BYTECODE: &[u8] = &hex!("6080604052348015600e575f5ffd5b506101e18061001c5f395ff3fe608060405234801561000f575f5ffd5b506004361061003f575f3560e01c80633fb5c1cb146100435780638381f58a1461005f578063d09de08a1461007d575b5f5ffd5b61005d600480360381019061005891906100e4565b610087565b005b610067610090565b604051610074919061011e565b60405180910390f35b610085610095565b005b805f8190555050565b5f5481565b5f5f8154809291906100a690610164565b9190505550565b5f5ffd5b5f819050919050565b6100c3816100b1565b81146100cd575f5ffd5b50565b5f813590506100de816100ba565b92915050565b5f602082840312156100f9576100f86100ad565b5b5f610106848285016100d0565b91505092915050565b610118816100b1565b82525050565b5f6020820190506101315f83018461010f565b92915050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f61016e826100b1565b91507fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff82036101a05761019f610137565b5b60018201905091905056fea26469706673582212204b710430bf5e9541dd320fc4eece1bf270f8b7d4835bba28f79ff7bd29904a2964736f6c634300081e0033"); +const COUNTER_CREATION_BYTECODE: &[u8] = &hex!( + "6080604052348015600e575f5ffd5b506101e18061001c5f395ff3fe608060405234801561000f575f5ffd5b506004361061003f575f3560e01c80633fb5c1cb146100435780638381f58a1461005f578063d09de08a1461007d575b5f5ffd5b61005d600480360381019061005891906100e4565b610087565b005b610067610090565b604051610074919061011e565b60405180910390f35b610085610095565b005b805f8190555050565b5f5481565b5f5f8154809291906100a690610164565b9190505550565b5f5ffd5b5f819050919050565b6100c3816100b1565b81146100cd575f5ffd5b50565b5f813590506100de816100ba565b92915050565b5f602082840312156100f9576100f86100ad565b5b5f610106848285016100d0565b91505092915050565b610118816100b1565b82525050565b5f6020820190506101315f83018461010f565b92915050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f61016e826100b1565b91507fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff82036101a05761019f610137565b5b60018201905091905056fea26469706673582212204b710430bf5e9541dd320fc4eece1bf270f8b7d4835bba28f79ff7bd29904a2964736f6c634300081e0033" +); const GWEI: u128 = 1_000_000_000; const DEPLOY_GAS_LIMIT: u64 = 1_000_000; const CALL_GAS_LIMIT: u64 = 150_000; fn envelope_from_signed(tx: TransactionSigned) -> (OpTxEnvelope, Bytes) { let op_signed = OpTransactionSigned::Eip1559( - tx.as_eip1559() - .expect("transaction should be EIP-1559") - .clone(), + tx.as_eip1559().expect("transaction should be EIP-1559").clone(), ); let envelope = OpTxEnvelope::from(op_signed); let bytes = Bytes::from(envelope.encoded_2718()); @@ -290,7 +266,7 @@ fn envelope_from_signed(tx: TransactionSigned) -> (OpTxEnvelope, Bytes) { #[tokio::test] async fn meter_bundle_reads_canonical_storage_without_mutation() -> Result<()> { reth_tracing::init_test_tracing(); - let harness = TestHarness::new(default_launcher).await?; + let harness = FlashblocksHarness::new().await?; let alice = &harness.accounts().alice; let alice_secret = secret_from_hex(alice.private_key); let mut nonce = 0u64; @@ -306,9 +282,7 @@ async fn meter_bundle_reads_canonical_storage_without_mutation() -> Result<()> { .into_eip1559(); let (deploy_envelope, deploy_bytes) = envelope_from_signed(deploy_signed); - harness - .build_block_from_transactions(vec![deploy_bytes]) - .await?; + harness.build_block_from_transactions(vec![deploy_bytes]).await?; nonce += 1; let provider = harness.provider(); @@ -321,9 +295,7 @@ async fn meter_bundle_reads_canonical_storage_without_mutation() -> Result<()> { .contract_address .ok_or_else(|| eyre!("deployment receipt missing contract address"))?; - let set_call = Counter::setNumberCall { - newNumber: U256::from(42u64), - }; + let set_call = Counter::setNumberCall { newNumber: U256::from(42u64) }; let set_signed = TransactionBuilder::default() .signer(alice_secret) .chain_id(BASE_CHAIN_ID) @@ -335,9 +307,7 @@ async fn meter_bundle_reads_canonical_storage_without_mutation() -> Result<()> { .input(Bytes::from(set_call.abi_encode())) .into_eip1559(); let (_set_envelope, set_bytes) = envelope_from_signed(set_signed); - harness - .build_block_from_transactions(vec![set_bytes]) - .await?; + harness.build_block_from_transactions(vec![set_bytes]).await?; nonce += 1; let increment_call = Counter::incrementCall {}; @@ -352,15 +322,11 @@ async fn meter_bundle_reads_canonical_storage_without_mutation() -> Result<()> { .input(Bytes::from(increment_call.abi_encode())) .into_eip1559(); let (_increment_envelope, increment_bytes) = envelope_from_signed(increment_signed); - harness - .build_block_from_transactions(vec![increment_bytes]) - .await?; + harness.build_block_from_transactions(vec![increment_bytes]).await?; nonce += 1; - let storage_value = provider - .get_storage_at(contract_address, U256::ZERO) - .await?; + let storage_value = provider.get_storage_at(contract_address, U256::ZERO).await?; assert_eq!(storage_value, U256::from(43u64)); let number_call = Counter::numberCall {}; @@ -368,10 +334,7 @@ async fn meter_bundle_reads_canonical_storage_without_mutation() -> Result<()> { .from(alice.address) .to(contract_address) .input(TransactionInput::new(Bytes::from(number_call.abi_encode()))); - let raw_number = provider - .call(call_request) - .block(BlockNumberOrTag::Latest.into()) - .await?; + let raw_number = provider.call(call_request).block(BlockNumberOrTag::Latest.into()).await?; let decoded: U256 = Counter::numberCall::abi_decode_returns(raw_number.as_ref())?; assert_eq!(decoded, U256::from(43u64)); diff --git a/crates/metering/src/tests/meter.rs b/crates/metering/src/tests/meter.rs index 13c74761..3512c0aa 100644 --- a/crates/metering/src/tests/meter.rs +++ b/crates/metering/src/tests/meter.rs @@ -142,8 +142,6 @@ fn meter_bundle_empty_transactions() -> eyre::Result<()> { &harness.header, None, None, - None, - None, )?; assert!(output.results.is_empty()); @@ -193,8 +191,6 @@ fn meter_bundle_single_transaction() -> eyre::Result<()> { &harness.header, None, None, - None, - None, )?; assert_eq!(output.results.len(), 1); @@ -367,10 +363,7 @@ fn meter_bundle_state_root_time_invariant() -> eyre::Result<()> { ); // State root time should be non-zero - assert!( - output.state_root_time_us > 0, - "state_root_time_us should be greater than zero" - ); + assert!(output.state_root_time_us > 0, "state_root_time_us should be greater than zero"); Ok(()) } diff --git a/crates/metering/src/tests/rpc.rs b/crates/metering/src/tests/rpc.rs index 22dddcbd..553a8ffc 100644 --- a/crates/metering/src/tests/rpc.rs +++ b/crates/metering/src/tests/rpc.rs @@ -1,27 +1,27 @@ -use crate::rpc::{MeteringApiImpl, MeteringApiServer}; use alloy_eips::Encodable2718; -use alloy_primitives::{address, Bytes, U256}; +use alloy_primitives::{Bytes, U256, address}; use alloy_provider::Provider; -use base_reth_test_utils::harness::TestHarness; -use base_reth_test_utils::node::{ - default_launcher, LocalFlashblocksState, LocalNodeProvider, BASE_CHAIN_ID, +use base_reth_test_utils::{ + flashblocks_harness::FlashblocksHarness, + node::{BASE_CHAIN_ID, LocalFlashblocksState, LocalNodeProvider}, }; -use eyre::{eyre, Result}; +use eyre::{Result, eyre}; use op_alloy_consensus::OpTxEnvelope; use reth_optimism_primitives::OpTransactionSigned; use reth_transaction_pool::test_utils::TransactionBuilder; use tips_core::types::Bundle; use super::utils::secret_from_hex; +use crate::rpc::{MeteringApiImpl, MeteringApiServer}; struct RpcTestContext { - harness: TestHarness, + harness: FlashblocksHarness, api: MeteringApiImpl, } impl RpcTestContext { async fn new() -> Result { - let harness = TestHarness::new(default_launcher).await?; + let harness = FlashblocksHarness::new().await?; let provider = harness.blockchain_provider(); let flashblocks_state = harness.flashblocks_state(); let api = MeteringApiImpl::new(provider, flashblocks_state); @@ -33,7 +33,7 @@ impl RpcTestContext { self.harness.accounts() } - fn harness(&self) -> &TestHarness { + fn harness(&self) -> &FlashblocksHarness { &self.harness } @@ -75,7 +75,7 @@ async fn test_meter_bundle_empty() -> Result<()> { assert_eq!(response.results.len(), 0); assert_eq!(response.total_gas_used, 0); - assert_eq!(response.gas_fees, "0"); + assert_eq!(response.gas_fees, U256::ZERO); let latest_block = ctx.harness().provider().get_block_number().await?; assert_eq!(response.state_block_number, latest_block); @@ -113,19 +113,12 @@ async fn test_meter_bundle_single_transaction() -> Result<()> { assert_eq!(response.results.len(), 1); assert_eq!(response.total_gas_used, 21_000); assert!(response.total_execution_time_us > 0); - assert!( - response.state_root_time_us > 0, - "state_root_time_us should be greater than zero" - ); let result = &response.results[0]; assert_eq!(result.from_address, sender_address); - assert_eq!( - result.to_address, - Some(address!("0x1111111111111111111111111111111111111111")) - ); + assert_eq!(result.to_address, Some(address!("0x1111111111111111111111111111111111111111"))); assert_eq!(result.gas_used, 21_000); - assert_eq!(result.gas_price, "1000000000"); + assert_eq!(result.gas_price, U256::from(1_000_000_000u64)); assert!(result.execution_time_us > 0); Ok(()) @@ -150,10 +143,8 @@ async fn test_meter_bundle_multiple_transactions() -> Result<()> { .max_priority_fee_per_gas(1_000_000_000) .into_eip1559(); let tx1_bytes = Bytes::from( - OpTxEnvelope::from(OpTransactionSigned::Eip1559( - tx1.as_eip1559().unwrap().clone(), - )) - .encoded_2718(), + OpTxEnvelope::from(OpTransactionSigned::Eip1559(tx1.as_eip1559().unwrap().clone())) + .encoded_2718(), ); let tx2 = TransactionBuilder::default() @@ -167,10 +158,8 @@ async fn test_meter_bundle_multiple_transactions() -> Result<()> { .max_priority_fee_per_gas(2_000_000_000) .into_eip1559(); let tx2_bytes = Bytes::from( - OpTxEnvelope::from(OpTransactionSigned::Eip1559( - tx2.as_eip1559().unwrap().clone(), - )) - .encoded_2718(), + OpTxEnvelope::from(OpTransactionSigned::Eip1559(tx2.as_eip1559().unwrap().clone())) + .encoded_2718(), ); let bundle = create_bundle(vec![tx1_bytes, tx2_bytes], 0, None); @@ -181,11 +170,11 @@ async fn test_meter_bundle_multiple_transactions() -> Result<()> { let result1 = &response.results[0]; assert_eq!(result1.from_address, ctx.accounts().alice.address); - assert_eq!(result1.gas_price, "1000000000"); + assert_eq!(result1.gas_price, U256::from(1_000_000_000u64)); let result2 = &response.results[1]; assert_eq!(result2.from_address, ctx.accounts().bob.address); - assert_eq!(result2.gas_price, "2000000000"); + assert_eq!(result2.gas_price, U256::from(2_000_000_000u64)); Ok(()) } @@ -284,10 +273,8 @@ async fn test_meter_bundle_gas_calculations() -> Result<()> { .max_priority_fee_per_gas(3_000_000_000) .into_eip1559(); let tx1_bytes = Bytes::from( - OpTxEnvelope::from(OpTransactionSigned::Eip1559( - tx1.as_eip1559().unwrap().clone(), - )) - .encoded_2718(), + OpTxEnvelope::from(OpTransactionSigned::Eip1559(tx1.as_eip1559().unwrap().clone())) + .encoded_2718(), ); let tx2 = TransactionBuilder::default() @@ -301,10 +288,8 @@ async fn test_meter_bundle_gas_calculations() -> Result<()> { .max_priority_fee_per_gas(7_000_000_000) .into_eip1559(); let tx2_bytes = Bytes::from( - OpTxEnvelope::from(OpTransactionSigned::Eip1559( - tx2.as_eip1559().unwrap().clone(), - )) - .encoded_2718(), + OpTxEnvelope::from(OpTransactionSigned::Eip1559(tx2.as_eip1559().unwrap().clone())) + .encoded_2718(), ); let bundle = create_bundle(vec![tx1_bytes, tx2_bytes], 0, None); @@ -315,16 +300,16 @@ async fn test_meter_bundle_gas_calculations() -> Result<()> { let expected_fees_1 = U256::from(21_000) * U256::from(3_000_000_000u64); let expected_fees_2 = U256::from(21_000) * U256::from(7_000_000_000u64); - assert_eq!(response.results[0].gas_fees, expected_fees_1.to_string()); - assert_eq!(response.results[0].gas_price, "3000000000"); - assert_eq!(response.results[1].gas_fees, expected_fees_2.to_string()); - assert_eq!(response.results[1].gas_price, "7000000000"); + assert_eq!(response.results[0].gas_fees, expected_fees_1); + assert_eq!(response.results[0].gas_price, U256::from(3_000_000_000u64)); + assert_eq!(response.results[1].gas_fees, expected_fees_2); + assert_eq!(response.results[1].gas_price, U256::from(7_000_000_000u64)); let total_fees = expected_fees_1 + expected_fees_2; - assert_eq!(response.gas_fees, total_fees.to_string()); - assert_eq!(response.coinbase_diff, total_fees.to_string()); + assert_eq!(response.gas_fees, total_fees); + assert_eq!(response.coinbase_diff, total_fees); assert_eq!(response.total_gas_used, 42_000); - assert_eq!(response.bundle_gas_price, "5000000000"); + assert_eq!(response.bundle_gas_price, U256::from(5_000_000_000u64)); Ok(()) } diff --git a/crates/metering/src/tests/utils.rs b/crates/metering/src/tests/utils.rs index b4fc3a9d..7fd3a775 100644 --- a/crates/metering/src/tests/utils.rs +++ b/crates/metering/src/tests/utils.rs @@ -1,14 +1,13 @@ use std::sync::Arc; -use alloy_primitives::{hex::FromHex, B256}; +use alloy_primitives::{B256, hex::FromHex}; use reth::api::{NodeTypes, NodeTypesWithDBAdapter}; use reth_db::{ - init_db, - mdbx::{DatabaseArguments, MaxReadTransactionDuration, KILOBYTE, MEGABYTE}, - test_utils::{create_test_static_files_dir, tempdir_path, TempDatabase, ERROR_DB_CREATION}, - ClientVersion, DatabaseEnv, + ClientVersion, DatabaseEnv, init_db, + mdbx::{DatabaseArguments, KILOBYTE, MEGABYTE, MaxReadTransactionDuration}, + test_utils::{ERROR_DB_CREATION, TempDatabase, create_test_static_files_dir, tempdir_path}, }; -use reth_provider::{providers::StaticFileProvider, ProviderFactory}; +use reth_provider::{ProviderFactory, providers::StaticFileProvider}; pub fn secret_from_hex(hex_key: &str) -> B256 { B256::from_hex(hex_key).expect("32-byte private key") diff --git a/crates/test-utils/README.md b/crates/test-utils/README.md index 6d020e9b..32842b2c 100644 --- a/crates/test-utils/README.md +++ b/crates/test-utils/README.md @@ -15,7 +15,7 @@ This crate provides reusable testing utilities for integration tests across the ## Quick Start ```rust -use base_reth_test_utils::TestHarness; +use base_reth_test_utils::harness::TestHarness; #[tokio::test] async fn test_example() -> eyre::Result<()> { @@ -44,6 +44,7 @@ The framework follows a three-layer architecture: │ - Coordinates node + engine │ │ - Builds blocks from transactions │ │ - Manages test accounts │ +│ - Manages flashblocks │ └─────────────────────────────────────┘ │ │ ┌──────┘ └──────┐ @@ -64,10 +65,10 @@ The framework follows a three-layer architecture: ### 1. TestHarness -The main entry point for integration tests. Combines node, engine, and accounts into a single interface. +The main entry point for integration tests that only need canonical chain control. Combines node, engine, and accounts into a single interface. ```rust -use base_reth_test_utils::TestHarness; +use base_reth_test_utils::harness::TestHarness; use alloy_primitives::Bytes; #[tokio::test] @@ -89,24 +90,18 @@ async fn test_harness() -> eyre::Result<()> { let txs: Vec = vec![/* signed transaction bytes */]; harness.build_block_from_transactions(txs).await?; - // Build block from flashblocks - harness.build_block_from_flashblocks(&flashblocks).await?; - - // Send flashblocks for pending state testing - harness.send_flashblock(flashblock).await?; - Ok(()) } ``` +> Need pending-state testing? Use `FlashblocksHarness` (see Flashblocks section below) to gain `send_flashblock` helpers. + **Key Methods:** - `new()` - Create new harness with node, engine, and accounts - `provider()` - Get Alloy RootProvider for RPC calls - `accounts()` - Access test accounts - `advance_chain(n)` - Build N empty blocks - `build_block_from_transactions(txs)` - Build block with specific transactions (auto-prepends the L1 block info deposit) -- `send_flashblock(fb)` - Send a single flashblock to the node for pending state processing -- `send_flashblocks(iter)` - Convenience helper that sends multiple flashblocks sequentially **Block Building Process:** 1. Fetches latest block header from provider (no local state tracking) @@ -122,32 +117,35 @@ async fn test_harness() -> eyre::Result<()> { In-process Optimism node with Base Sepolia configuration. ```rust -use base_reth_test_utils::LocalNode; +use base_reth_test_utils::node::LocalNode; #[tokio::test] async fn test_node() -> eyre::Result<()> { - let node = LocalNode::new().await?; + let node = LocalNode::new(default_launcher).await?; - // Get provider let provider = node.provider()?; - - // Get Engine API let engine = node.engine_api()?; - // Send flashblocks - node.send_flashblock(flashblock).await?; - Ok(()) } ``` -**Features:** +**Features (base):** - Base Sepolia chain configuration - Disabled P2P discovery (isolated testing) - Random unused ports (parallel test safety) - HTTP RPC server at `node.http_api_addr` - Engine API IPC at `node.engine_ipc_path` -- Flashblocks-canon ExEx integration + +For flashblocks-enabled nodes, use `FlashblocksLocalNode`: + +```rust +use base_reth_test_utils::node::FlashblocksLocalNode; + +let node = FlashblocksLocalNode::new().await?; +let pending_state = node.flashblocks_state(); +node.send_flashblock(flashblock).await?; +``` **Note:** Most tests should use `TestHarness` instead of `LocalNode` directly. @@ -156,7 +154,7 @@ async fn test_node() -> eyre::Result<()> { Type-safe Engine API client wrapping raw CL operations. ```rust -use base_reth_test_utils::EngineApi; +use base_reth_test_utils::engine::EngineApi; use alloy_primitives::B256; use op_alloy_rpc_types_engine::OpPayloadAttributes; @@ -181,7 +179,8 @@ let status = engine.new_payload(payload, vec![], parent_root, requests).await?; Hardcoded test accounts with deterministic addresses (Anvil-compatible). ```rust -use base_reth_test_utils::TestAccounts; +use base_reth_test_utils::accounts::TestAccounts; +use base_reth_test_utils::harness::TestHarness; let accounts = TestAccounts::new(); @@ -209,34 +208,40 @@ Each account includes: ### 5. Flashblocks Support -Test flashblocks delivery without WebSocket connections. +Use `FlashblocksHarness` when you need `send_flashblock` and access to the in-memory pending state. ```rust -use base_reth_test_utils::{FlashblocksContext, FlashblockBuilder}; +use base_reth_test_utils::flashblocks_harness::FlashblocksHarness; #[tokio::test] async fn test_flashblocks() -> eyre::Result<()> { - let (fb_ctx, receiver) = FlashblocksContext::new(); + let harness = FlashblocksHarness::new().await?; - // Create base flashblock - let flashblock = FlashblockBuilder::new(1, 0) - .as_base(B256::ZERO, 1000) - .with_transaction(tx_bytes, tx_hash, 21000) - .with_balance(address, U256::from(1000)) - .build(); + harness.send_flashblock(flashblock).await?; - fb_ctx.send_flashblock(flashblock).await?; + let pending = harness.flashblocks_state(); + // assertions... Ok(()) } ``` -**Via TestHarness:** +Need to craft a flashblock (including intentionally malformed payloads)? Use `build_flashblock`: + ```rust -let harness = TestHarness::new().await?; +let flashblock = harness.build_flashblock( + next_block_number, + parent_hash, + B256::ZERO, // force missing beacon root to test validation + timestamp, + gas_limit, + vec![(tx_bytes, Some((tx_hash, receipt)))], +); harness.send_flashblock(flashblock).await?; ``` +`FlashblocksHarness` derefs to the base `TestHarness`, so you can keep using methods like `provider()`, `build_block_from_transactions`, etc. Test flashblocks delivery without WebSocket connections by constructing payloads and sending them through `FlashblocksHarness` (or the lower-level `FlashblocksLocalNode`). + ## Configuration Constants Key constants defined in `harness.rs`: @@ -257,8 +262,8 @@ test-utils/ │ ├── accounts.rs # Test account definitions │ ├── node.rs # LocalNode (EL wrapper) │ ├── engine.rs # EngineApi (CL wrapper) -│ ├── harness.rs # TestHarness (orchestration) -│ └── flashblocks.rs # Flashblocks support +│ ├── harness.rs # TestHarness (orchestration) +│ └── flashblocks_harness.rs # FlashblocksHarness + helpers ├── assets/ │ └── genesis.json # Base Sepolia genesis └── Cargo.toml @@ -276,12 +281,10 @@ base-reth-test-utils.workspace = true Import in tests: ```rust -use base_reth_test_utils::TestHarness; +use base_reth_test_utils::harness::TestHarness; #[tokio::test] async fn my_test() -> eyre::Result<()> { - reth_tracing::init_test_tracing(); - let harness = TestHarness::new().await?; // Your test logic @@ -318,6 +321,7 @@ cargo test -p base-reth-test-utils test_harness_setup - Snapshot/restore functionality - Multi-node network simulation - Performance benchmarking utilities +- Helper builder for Flashblocks ## References diff --git a/crates/test-utils/src/flashblocks.rs b/crates/test-utils/src/flashblocks.rs deleted file mode 100644 index 199a0022..00000000 --- a/crates/test-utils/src/flashblocks.rs +++ /dev/null @@ -1,93 +0,0 @@ -//! Flashblock testing utilities - -use alloy_consensus::Receipt; -use alloy_primitives::{b256, bytes, Address, Bytes, B256, U256}; -use alloy_rpc_types_engine::PayloadId; -use base_reth_flashblocks_rpc::subscription::{Flashblock, Metadata}; -use op_alloy_consensus::OpDepositReceipt; -use reth_optimism_primitives::OpReceipt; -use rollup_boost::{ExecutionPayloadBaseV1, ExecutionPayloadFlashblockDeltaV1}; -use std::collections::HashMap; - -const FLASHBLOCK_PAYLOAD_ID: [u8; 8] = [0; 8]; - -// Pre-captured deposit transaction and hash used by flashblocks tests for the L1 block info deposit. -// Values match `BLOCK_INFO_TXN` and `BLOCK_INFO_TXN_HASH` from `crates/flashblocks-rpc/src/tests/mod.rs`. -const BLOCK_INFO_DEPOSIT_TX: Bytes = bytes!("0x7ef90104a06c0c775b6b492bab9d7e81abdf27f77cafb698551226455a82f559e0f93fea3794deaddeaddeaddeaddeaddeaddeaddeaddead00019442000000000000000000000000000000000000158080830f424080b8b0098999be000008dd00101c1200000000000000020000000068869d6300000000015f277f000000000000000000000000000000000000000000000000000000000d42ac290000000000000000000000000000000000000000000000000000000000000001abf52777e63959936b1bf633a2a643f0da38d63deffe49452fed1bf8a44975d50000000000000000000000005050f69a9786f081509234f1a7f4684b5e5b76c9000000000000000000000000"); -const BLOCK_INFO_DEPOSIT_TX_HASH: B256 = - b256!("0xba56c8b0deb460ff070f8fca8e2ee01e51a3db27841cc862fdd94cc1a47662b6"); - -/// Builds a single flashblock for testing purposes. -/// -/// This utility creates a base flashblock (index 0) with the required L1 block info deposit -/// transaction and any additional transactions provided. -/// -/// # Arguments -/// -/// * `block_number` - The block number for this flashblock -/// * `parent_hash` - Hash of the parent block -/// * `parent_beacon_block_root` - Parent beacon block root -/// * `timestamp` - Block timestamp -/// * `gas_limit` - Gas limit for the block -/// * `transactions` - Vector of (transaction bytes, optional (hash, receipt)) tuples -/// -/// # Returns -/// -/// A `Flashblock` configured for testing with the provided parameters -pub fn build_single_flashblock( - block_number: u64, - parent_hash: B256, - parent_beacon_block_root: B256, - timestamp: u64, - gas_limit: u64, - transactions: Vec<(Bytes, Option<(B256, OpReceipt)>)>, -) -> Flashblock { - let base = ExecutionPayloadBaseV1 { - parent_beacon_block_root, - parent_hash, - fee_recipient: Address::ZERO, - prev_randao: B256::ZERO, - block_number, - gas_limit, - timestamp, - extra_data: Bytes::new(), - base_fee_per_gas: U256::from(1), - }; - - let mut flashblock_txs = vec![BLOCK_INFO_DEPOSIT_TX.clone()]; - let mut receipts = HashMap::default(); - receipts.insert( - BLOCK_INFO_DEPOSIT_TX_HASH, - OpReceipt::Deposit(OpDepositReceipt { - inner: Receipt { - status: true.into(), - cumulative_gas_used: 10_000, - logs: vec![], - }, - deposit_nonce: Some(4_012_991u64), - deposit_receipt_version: None, - }), - ); - - for (tx_bytes, maybe_receipt) in transactions { - if let Some((hash, receipt)) = maybe_receipt { - receipts.insert(hash, receipt); - } - flashblock_txs.push(tx_bytes); - } - - Flashblock { - payload_id: PayloadId::new(FLASHBLOCK_PAYLOAD_ID), - index: 0, - base: Some(base), - diff: ExecutionPayloadFlashblockDeltaV1 { - transactions: flashblock_txs, - ..Default::default() - }, - metadata: Metadata { - receipts, - new_account_balances: Default::default(), - block_number, - }, - } -} diff --git a/crates/test-utils/src/flashblocks_harness.rs b/crates/test-utils/src/flashblocks_harness.rs index 6ed61caf..68fcf7a4 100644 --- a/crates/test-utils/src/flashblocks_harness.rs +++ b/crates/test-utils/src/flashblocks_harness.rs @@ -1,11 +1,17 @@ -use std::{ops::Deref, sync::Arc}; +use std::{collections::HashMap, ops::Deref, sync::Arc}; -use base_reth_flashblocks_rpc::subscription::Flashblock; +use alloy_consensus::Receipt; +use alloy_primitives::{Address, B256, Bytes, U256, b256, bytes}; +use alloy_rpc_types_engine::PayloadId; +use base_reth_flashblocks_rpc::subscription::{Flashblock, Metadata}; use eyre::Result; use futures_util::Future; +use op_alloy_consensus::OpDepositReceipt; use reth::builder::NodeHandle; use reth_e2e_test_utils::Adapter; use reth_optimism_node::OpNode; +use reth_optimism_primitives::OpReceipt; +use rollup_boost::{ExecutionPayloadBaseV1, ExecutionPayloadFlashblockDeltaV1}; use crate::{ harness::TestHarness, @@ -16,6 +22,13 @@ use crate::{ tracing::init_silenced_tracing, }; +const FLASHBLOCK_PAYLOAD_ID: [u8; 8] = [0; 8]; +const BLOCK_INFO_DEPOSIT_TX: Bytes = bytes!( + "0x7ef90104a06c0c775b6b492bab9d7e81abdf27f77cafb698551226455a82f559e0f93fea3794deaddeaddeaddeaddeaddeaddeaddeaddead00019442000000000000000000000000000000000000158080830f424080b8b0098999be000008dd00101c1200000000000000020000000068869d6300000000015f277f000000000000000000000000000000000000000000000000000000000d42ac290000000000000000000000000000000000000000000000000000000000000001abf52777e63959936b1bf633a2a643f0da38d63deffe49452fed1bf8a44975d50000000000000000000000005050f69a9786f081509234f1a7f4684b5e5b76c9000000000000000000000000" +); +const BLOCK_INFO_DEPOSIT_TX_HASH: B256 = + b256!("0xba56c8b0deb460ff070f8fca8e2ee01e51a3db27841cc862fdd94cc1a47662b6"); + pub struct FlashblocksHarness { inner: TestHarness, parts: FlashblocksParts, @@ -68,6 +81,61 @@ impl FlashblocksHarness { Ok(()) } + /// Builds a flashblock payload for testing. Callers can intentionally pass invalid + /// values (for example a zeroed beacon root) to assert how downstream components + /// react to malformed flashblocks. + #[allow(clippy::too_many_arguments)] + pub fn build_flashblock( + &self, + block_number: u64, + parent_hash: B256, + parent_beacon_block_root: B256, + timestamp: u64, + gas_limit: u64, + transactions: Vec<(Bytes, Option<(B256, OpReceipt)>)>, + ) -> Flashblock { + let base = ExecutionPayloadBaseV1 { + parent_beacon_block_root, + parent_hash, + fee_recipient: Address::ZERO, + prev_randao: B256::ZERO, + block_number, + gas_limit, + timestamp, + extra_data: Bytes::new(), + base_fee_per_gas: U256::from(1), + }; + + let mut flashblock_txs = vec![BLOCK_INFO_DEPOSIT_TX.clone()]; + let mut receipts = HashMap::default(); + receipts.insert( + BLOCK_INFO_DEPOSIT_TX_HASH, + OpReceipt::Deposit(OpDepositReceipt { + inner: Receipt { status: true.into(), cumulative_gas_used: 10_000, logs: vec![] }, + deposit_nonce: Some(4_012_991u64), + deposit_receipt_version: None, + }), + ); + + for (tx_bytes, maybe_receipt) in transactions { + if let Some((hash, receipt)) = maybe_receipt { + receipts.insert(hash, receipt); + } + flashblock_txs.push(tx_bytes); + } + + Flashblock { + payload_id: PayloadId::new(FLASHBLOCK_PAYLOAD_ID), + index: 0, + base: Some(base), + diff: ExecutionPayloadFlashblockDeltaV1 { + transactions: flashblock_txs, + ..Default::default() + }, + metadata: Metadata { receipts, new_account_balances: Default::default(), block_number }, + } + } + pub fn into_inner(self) -> TestHarness { self.inner } diff --git a/crates/test-utils/src/harness.rs b/crates/test-utils/src/harness.rs index d70a7b9a..be656c82 100644 --- a/crates/test-utils/src/harness.rs +++ b/crates/test-utils/src/harness.rs @@ -1,31 +1,41 @@ -//! Unified test harness combining node, engine API, and flashblocks functionality +//! Unified test harness combining node and engine helpers, plus optional flashblocks adapter. -use crate::accounts::TestAccounts; -use crate::engine::{EngineApi, IpcEngine}; -use crate::node::{LocalFlashblocksState, LocalNode, LocalNodeProvider, OpAddOns, OpBuilder}; -use alloy_eips::eip7685::Requests; -use alloy_primitives::{bytes, Bytes, B256}; +use std::time::Duration; + +use alloy_eips::{BlockHashOrNumber, eip7685::Requests}; +use alloy_primitives::{B64, B256, Bytes, bytes}; use alloy_provider::{Provider, RootProvider}; use alloy_rpc_types::BlockNumberOrTag; use alloy_rpc_types_engine::PayloadAttributes; -use base_reth_flashblocks_rpc::subscription::Flashblock; -use eyre::{eyre, Result}; +use eyre::{Result, eyre}; use futures_util::Future; use op_alloy_network::Optimism; use op_alloy_rpc_types_engine::OpPayloadAttributes; -use reth::builder::NodeHandle; +use reth::{ + builder::NodeHandle, + providers::{BlockNumReader, BlockReader, ChainSpecProvider}, +}; use reth_e2e_test_utils::Adapter; use reth_optimism_node::OpNode; -use std::sync::Arc; -use std::time::Duration; +use reth_optimism_primitives::OpBlock; +use reth_primitives_traits::{Block as BlockT, RecoveredBlock}; use tokio::time::sleep; +use crate::{ + accounts::TestAccounts, + engine::{EngineApi, IpcEngine}, + node::{LocalNode, LocalNodeProvider, OpAddOns, OpBuilder, default_launcher}, + tracing::init_silenced_tracing, +}; + const BLOCK_TIME_SECONDS: u64 = 2; const GAS_LIMIT: u64 = 200_000_000; const NODE_STARTUP_DELAY_MS: u64 = 500; const BLOCK_BUILD_DELAY_MS: u64 = 100; -// Pre-captured L1 block info deposit transaction required by the Optimism EVM. -const L1_BLOCK_INFO_DEPOSIT_TX: Bytes = bytes!("0x7ef90104a06c0c775b6b492bab9d7e81abdf27f77cafb698551226455a82f559e0f93fea3794deaddeaddeaddeaddeaddeaddeaddeaddead00019442000000000000000000000000000000000000158080830f424080b8b0098999be000008dd00101c1200000000000000020000000068869d6300000000015f277f000000000000000000000000000000000000000000000000000000000d42ac290000000000000000000000000000000000000000000000000000000000000001abf52777e63959936b1bf633a2a643f0da38d63deffe49452fed1bf8a44975d50000000000000000000000005050f69a9786f081509234f1a7f4684b5e5b76c9000000000000000000000000"); +// Pre-captured L1 block info deposit transaction required by OP Stack. +const L1_BLOCK_INFO_DEPOSIT_TX: Bytes = bytes!( + "0x7ef90104a06c0c775b6b492bab9d7e81abdf27f77cafb698551226455a82f559e0f93fea3794deaddeaddeaddeaddeaddeaddeaddeaddead00019442000000000000000000000000000000000000158080830f424080b8b0098999be000008dd00101c1200000000000000020000000068869d6300000000015f277f000000000000000000000000000000000000000000000000000000000d42ac290000000000000000000000000000000000000000000000000000000000000001abf52777e63959936b1bf633a2a643f0da38d63deffe49452fed1bf8a44975d50000000000000000000000005050f69a9786f081509234f1a7f4684b5e5b76c9000000000000000000000000" +); pub struct TestHarness { node: LocalNode, @@ -34,28 +44,31 @@ pub struct TestHarness { } impl TestHarness { - pub async fn new(launcher: L) -> Result + pub async fn new() -> Result { + Self::with_launcher(default_launcher).await + } + + pub async fn with_launcher(launcher: L) -> Result where L: FnOnce(OpBuilder) -> LRet, LRet: Future, OpAddOns>>>, { + init_silenced_tracing(); let node = LocalNode::new(launcher).await?; + Self::from_node(node).await + } + + pub(crate) async fn from_node(node: LocalNode) -> Result { let engine = node.engine_api()?; let accounts = TestAccounts::new(); sleep(Duration::from_millis(NODE_STARTUP_DELAY_MS)).await; - Ok(Self { - node, - engine, - accounts, - }) + Ok(Self { node, engine, accounts }) } pub fn provider(&self) -> RootProvider { - self.node - .provider() - .expect("provider should always be available after node initialization") + self.node.provider().expect("provider should always be available after node initialization") } pub fn accounts(&self) -> &TestAccounts { @@ -66,20 +79,13 @@ impl TestHarness { self.node.blockchain_provider() } - pub fn flashblocks_state(&self) -> Arc { - self.node.flashblocks_state() - } - pub fn rpc_url(&self) -> String { format!("http://{}", self.node.http_api_addr) } pub async fn build_block_from_transactions(&self, mut transactions: Vec) -> Result<()> { // Ensure the block always starts with the required L1 block info deposit. - if !transactions - .first() - .is_some_and(|tx| tx == &L1_BLOCK_INFO_DEPOSIT_TX) - { + if !transactions.first().is_some_and(|tx| tx == &L1_BLOCK_INFO_DEPOSIT_TX) { transactions.insert(0, L1_BLOCK_INFO_DEPOSIT_TX.clone()); } @@ -90,12 +96,16 @@ impl TestHarness { .ok_or_else(|| eyre!("No genesis block found"))?; let parent_hash = latest_block.header.hash; - let parent_beacon_block_root = latest_block - .header - .parent_beacon_block_root - .unwrap_or(B256::ZERO); + let parent_beacon_block_root = + latest_block.header.parent_beacon_block_root.unwrap_or(B256::ZERO); let next_timestamp = latest_block.header.timestamp + BLOCK_TIME_SECONDS; + let min_base_fee = latest_block.header.base_fee_per_gas.unwrap_or_default(); + let chain_spec = self.node.blockchain_provider().chain_spec(); + let base_fee_params = chain_spec.base_fee_params_at_timestamp(next_timestamp); + let eip_1559_params = ((base_fee_params.max_change_denominator as u64) << 32) + | (base_fee_params.elasticity_multiplier as u64); + let payload_attributes = OpPayloadAttributes { payload_attributes: PayloadAttributes { timestamp: next_timestamp, @@ -106,6 +116,8 @@ impl TestHarness { transactions: Some(transactions), gas_limit: Some(GAS_LIMIT), no_tx_pool: Some(true), + min_base_fee: Some(min_base_fee), + eip_1559_params: Some(B64::from(eip_1559_params)), ..Default::default() }; @@ -146,47 +158,38 @@ impl TestHarness { .latest_valid_hash .ok_or_else(|| eyre!("Payload status missing latest_valid_hash"))?; - self.engine - .update_forkchoice(parent_hash, new_block_hash, None) - .await?; + self.engine.update_forkchoice(parent_hash, new_block_hash, None).await?; Ok(()) } - pub async fn send_flashblock(&self, flashblock: Flashblock) -> Result<()> { - self.node.send_flashblock(flashblock).await - } - - pub async fn send_flashblocks(&self, flashblocks: I) -> Result<()> - where - I: IntoIterator, - { - for flashblock in flashblocks { - self.send_flashblock(flashblock).await?; - } - Ok(()) - } - pub async fn advance_chain(&self, n: u64) -> Result<()> { for _ in 0..n { self.build_block_from_transactions(vec![]).await?; } Ok(()) } + + pub fn latest_block(&self) -> RecoveredBlock { + let provider = self.blockchain_provider(); + let best_number = provider.best_block_number().expect("able to read best block number"); + let block = provider + .block(BlockHashOrNumber::Number(best_number)) + .expect("able to load canonical block") + .expect("canonical block exists"); + BlockT::try_into_recovered(block).expect("able to recover canonical block") + } } #[cfg(test)] mod tests { - use crate::node::default_launcher; - - use super::*; use alloy_primitives::U256; use alloy_provider::Provider; + use super::*; #[tokio::test] async fn test_harness_setup() -> Result<()> { - reth_tracing::init_test_tracing(); - let harness = TestHarness::new(default_launcher).await?; + let harness = TestHarness::new().await?; assert_eq!(harness.accounts().alice.name, "Alice"); assert_eq!(harness.accounts().bob.name, "Bob"); @@ -195,9 +198,7 @@ mod tests { let chain_id = provider.get_chain_id().await?; assert_eq!(chain_id, crate::node::BASE_CHAIN_ID); - let alice_balance = provider - .get_balance(harness.accounts().alice.address) - .await?; + let alice_balance = provider.get_balance(harness.accounts().alice.address).await?; assert!(alice_balance > U256::ZERO); let block_number = provider.get_block_number().await?; diff --git a/crates/test-utils/src/lib.rs b/crates/test-utils/src/lib.rs index 4c3155cf..2c6b290d 100644 --- a/crates/test-utils/src/lib.rs +++ b/crates/test-utils/src/lib.rs @@ -1,6 +1,5 @@ pub mod accounts; pub mod engine; -pub mod flashblocks; pub mod flashblocks_harness; pub mod harness; pub mod node; diff --git a/crates/test-utils/src/node.rs b/crates/test-utils/src/node.rs index cd713b8c..5a731bfd 100644 --- a/crates/test-utils/src/node.rs +++ b/crates/test-utils/src/node.rs @@ -1,35 +1,51 @@ //! Local node setup with Base Sepolia chainspec -use crate::engine::EngineApi; +use std::{ + any::Any, + net::SocketAddr, + sync::{Arc, Mutex}, +}; + use alloy_genesis::Genesis; use alloy_provider::RootProvider; use alloy_rpc_client::RpcClient; -use base_reth_flashblocks_rpc::rpc::{EthApiExt, EthApiOverrideServer}; -use base_reth_flashblocks_rpc::state::FlashblocksState; -use base_reth_flashblocks_rpc::subscription::{Flashblock, FlashblocksReceiver}; +use base_reth_flashblocks_rpc::{ + rpc::{EthApiExt, EthApiOverrideServer}, + state::FlashblocksState, + subscription::{Flashblock, FlashblocksReceiver}, +}; use eyre::Result; use futures_util::Future; use once_cell::sync::OnceCell; use op_alloy_network::Optimism; -use reth::api::{FullNodeTypesAdapter, NodeTypesWithDBAdapter}; -use reth::args::{DiscoveryArgs, NetworkArgs, RpcServerArgs}; -use reth::builder::{ - Node, NodeBuilder, NodeBuilderWithComponents, NodeConfig, NodeHandle, WithLaunchContext, +use reth::{ + api::{FullNodeTypesAdapter, NodeTypesWithDBAdapter}, + args::{DiscoveryArgs, NetworkArgs, RpcServerArgs}, + builder::{ + Node, NodeBuilder, NodeBuilderWithComponents, NodeConfig, NodeHandle, WithLaunchContext, + }, + core::exit::NodeExitFuture, + tasks::TaskManager, +}; +use reth_db::{ + ClientVersion, DatabaseEnv, init_db, + mdbx::DatabaseArguments, + test_utils::{ERROR_DB_CREATION, TempDatabase, tempdir_path}, }; -use reth::core::exit::NodeExitFuture; -use reth::tasks::TaskManager; use reth_e2e_test_utils::{Adapter, TmpDB}; use reth_exex::ExExEvent; +use reth_node_core::{ + args::DatadirArgs, + dirs::{DataDirPath, MaybePlatformPath}, +}; use reth_optimism_chainspec::OpChainSpec; -use reth_optimism_node::args::RollupArgs; -use reth_optimism_node::OpNode; -use reth_provider::providers::BlockchainProvider; -use std::any::Any; -use std::net::SocketAddr; -use std::sync::Arc; +use reth_optimism_node::{OpNode, args::RollupArgs}; +use reth_provider::{CanonStateSubscriptions, providers::BlockchainProvider}; use tokio::sync::{mpsc, oneshot}; use tokio_stream::StreamExt; +use crate::engine::EngineApi; + pub const BASE_CHAIN_ID: u64 = 84532; pub type LocalNodeProvider = BlockchainProvider>; @@ -38,98 +54,78 @@ pub type LocalFlashblocksState = FlashblocksState; pub struct LocalNode { pub(crate) http_api_addr: SocketAddr, engine_ipc_path: String, - flashblock_sender: mpsc::Sender<(Flashblock, oneshot::Sender<()>)>, - flashblocks_state: Arc, provider: LocalNodeProvider, _node_exit_future: NodeExitFuture, _node: Box, _task_manager: TaskManager, } -pub type OpTypes = - FullNodeTypesAdapter>>; -pub type OpComponentsBuilder = >::ComponentsBuilder; -pub type OpAddOns = >::AddOns; -pub type OpBuilder = - WithLaunchContext>; +#[derive(Clone)] +pub struct FlashblocksParts { + sender: mpsc::Sender<(Flashblock, oneshot::Sender<()>)>, + state: Arc, +} -pub async fn default_launcher( - builder: OpBuilder, -) -> eyre::Result, OpAddOns>> { - let launcher = builder.engine_api_launcher(); - builder.launch_with(launcher).await +impl FlashblocksParts { + pub fn state(&self) -> Arc { + self.state.clone() + } + + pub async fn send(&self, flashblock: Flashblock) -> Result<()> { + let (tx, rx) = oneshot::channel(); + self.sender.send((flashblock, tx)).await.map_err(|err| eyre::eyre!(err))?; + rx.await.map_err(|err| eyre::eyre!(err))?; + Ok(()) + } } -impl LocalNode { - pub async fn new(launcher: L) -> Result - where - L: FnOnce(OpBuilder) -> LRet, - LRet: Future, OpAddOns>>>, - { - let tasks = TaskManager::current(); - let exec = tasks.executor(); - - let genesis: Genesis = serde_json::from_str(include_str!("../assets/genesis.json"))?; - let chain_spec = Arc::new(OpChainSpec::from_genesis(genesis)); - - let network_config = NetworkArgs { - discovery: DiscoveryArgs { - disable_discovery: true, - ..DiscoveryArgs::default() - }, - ..NetworkArgs::default() - }; +#[derive(Clone)] +struct FlashblocksNodeExtensions { + inner: Arc, +} - // Generate unique IPC path for this test instance to avoid conflicts - // Use timestamp + thread ID + process ID for uniqueness - let unique_ipc_path = format!( - "/tmp/reth_engine_api_{}_{}_{:?}.ipc", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(), - std::process::id(), - std::thread::current().id() - ); - - let mut rpc_args = RpcServerArgs::default() - .with_unused_ports() - .with_http() - .with_auth_ipc(); - rpc_args.auth_ipc_path = unique_ipc_path; - - let node_config = NodeConfig::new(chain_spec.clone()) - .with_network(network_config) - .with_rpc(rpc_args) - .with_unused_ports(); - - let node = OpNode::new(RollupArgs::default()); +struct FlashblocksNodeExtensionsInner { + sender: mpsc::Sender<(Flashblock, oneshot::Sender<()>)>, + receiver: Arc)>>>>, + fb_cell: Arc>>, + process_canonical: bool, +} +impl FlashblocksNodeExtensions { + fn new(process_canonical: bool) -> Self { let (sender, receiver) = mpsc::channel::<(Flashblock, oneshot::Sender<()>)>(100); - let fb_cell: Arc>>> = Arc::new(OnceCell::new()); - let provider_cell: Arc> = Arc::new(OnceCell::new()); - - let NodeHandle { - node: node_handle, - node_exit_future, - } = NodeBuilder::new(node_config.clone()) - .testing_node(exec.clone()) - .with_types_and_provider::>() - .with_components(node.components_builder()) - .with_add_ons(node.add_ons()) - .install_exex("flashblocks-canon", { - let fb_cell = fb_cell.clone(); - let provider_cell = provider_cell.clone(); - move |mut ctx| async move { - let provider = provider_cell.get_or_init(|| ctx.provider().clone()).clone(); - let fb = fb_cell - .get_or_init(|| Arc::new(FlashblocksState::new(provider.clone()))) - .clone(); + let inner = FlashblocksNodeExtensionsInner { + sender, + receiver: Arc::new(Mutex::new(Some(receiver))), + fb_cell: Arc::new(OnceCell::new()), + process_canonical, + }; + Self { inner: Arc::new(inner) } + } + + fn apply(&self, builder: OpBuilder) -> OpBuilder { + let fb_cell = self.inner.fb_cell.clone(); + let receiver = self.inner.receiver.clone(); + let process_canonical = self.inner.process_canonical; + + let fb_cell_for_exex = fb_cell.clone(); + + builder + .install_exex("flashblocks-canon", move |mut ctx| { + let fb_cell = fb_cell_for_exex.clone(); + let process_canonical = process_canonical; + async move { + let provider = ctx.provider().clone(); + let fb = init_flashblocks_state(&fb_cell, &provider); Ok(async move { while let Some(note) = ctx.notifications.try_next().await? { if let Some(committed) = note.committed_chain() { - for block in committed.blocks_iter() { - fb.on_canonical_block_received(block); + if process_canonical { + // Many suites drive canonical updates manually to reproduce race conditions, so + // allowing this to be disabled keeps canonical replay deterministic. + for block in committed.blocks_iter() { + fb.on_canonical_block_received(block); + } } let _ = ctx .events @@ -140,74 +136,110 @@ impl LocalNode { }) } }) - .extend_rpc_modules({ + .extend_rpc_modules(move |ctx| { let fb_cell = fb_cell.clone(); - let provider_cell = provider_cell.clone(); - let mut receiver = Some(receiver); - move |ctx| { - let provider = provider_cell.get_or_init(|| ctx.provider().clone()).clone(); - let fb = fb_cell - .get_or_init(|| Arc::new(FlashblocksState::new(provider.clone()))) - .clone(); - fb.start(); - let api_ext = EthApiExt::new( - ctx.registry.eth_api().clone(), - ctx.registry.eth_handlers().filter.clone(), - fb.clone(), - ); - ctx.modules.replace_configured(api_ext.into_rpc())?; - // Spawn task to receive flashblocks from the test context - let fb_for_task = fb.clone(); - let mut receiver = receiver - .take() - .expect("flashblock receiver should only be initialized once"); - tokio::spawn(async move { - while let Some((payload, tx)) = receiver.recv().await { - fb_for_task.on_flashblock_received(payload); - let _ = tx.send(()); - } - }); - Ok(()) - } + let provider = ctx.provider().clone(); + let fb = init_flashblocks_state(&fb_cell, &provider); + + let mut canon_stream = tokio_stream::wrappers::BroadcastStream::new( + ctx.provider().subscribe_to_canonical_state(), + ); + tokio::spawn(async move { + use tokio_stream::StreamExt; + while let Some(Ok(notification)) = canon_stream.next().await { + provider.canonical_in_memory_state().notify_canon_state(notification); + } + }); + let api_ext = EthApiExt::new( + ctx.registry.eth_api().clone(), + ctx.registry.eth_handlers().filter.clone(), + fb.clone(), + ); + ctx.modules.replace_configured(api_ext.into_rpc())?; + + let fb_for_task = fb.clone(); + let mut receiver = receiver + .lock() + .expect("flashblock receiver mutex poisoned") + .take() + .expect("flashblock receiver should only be initialized once"); + tokio::spawn(async move { + while let Some((payload, tx)) = receiver.recv().await { + fb_for_task.on_flashblock_received(payload); + let _ = tx.send(()); + } + }); + + Ok(()) }) - .launch_with_fn(launcher) - .await?; - - let http_api_addr = node_handle - .rpc_server_handle() - .http_local_addr() - .ok_or_else(|| eyre::eyre!("HTTP RPC server failed to bind to address"))?; - - let engine_ipc_path = node_config.rpc.auth_ipc_path; - let flashblocks_state = fb_cell - .get() - .expect("FlashblocksState should be initialized during node launch") - .clone(); - let provider = provider_cell - .get() - .expect("Provider should be initialized during node launch") - .clone(); - - Ok(Self { - http_api_addr, - engine_ipc_path, - flashblock_sender: sender, - flashblocks_state, - provider, - _node_exit_future: node_exit_future, - _node: Box::new(node_handle), - _task_manager: tasks, - }) } - pub async fn send_flashblock(&self, flashblock: Flashblock) -> Result<()> { - let (tx, rx) = oneshot::channel(); - self.flashblock_sender - .send((flashblock, tx)) - .await - .map_err(|err| eyre::eyre!(err))?; - rx.await.map_err(|err| eyre::eyre!(err))?; - Ok(()) + fn wrap_launcher(&self, launcher: L) -> impl FnOnce(OpBuilder) -> LRet + where + L: FnOnce(OpBuilder) -> LRet, + { + let extensions = self.clone(); + move |builder| { + let builder = extensions.apply(builder); + launcher(builder) + } + } + + fn parts(&self) -> Result { + let state = self.inner.fb_cell.get().ok_or_else(|| { + eyre::eyre!("FlashblocksState should be initialized during node launch") + })?; + Ok(FlashblocksParts { sender: self.inner.sender.clone(), state: state.clone() }) + } +} + +pub type OpTypes = + FullNodeTypesAdapter>>; +pub type OpComponentsBuilder = >::ComponentsBuilder; +pub type OpAddOns = >::AddOns; +pub type OpBuilder = + WithLaunchContext>; + +pub async fn default_launcher( + builder: OpBuilder, +) -> eyre::Result, OpAddOns>> { + let launcher = builder.engine_api_launcher(); + builder.launch_with(launcher).await +} + +impl LocalNode { + pub async fn new(launcher: L) -> Result + where + L: FnOnce(OpBuilder) -> LRet, + LRet: Future, OpAddOns>>>, + { + build_node(launcher).await + } + + /// Creates a test database with a smaller map size to reduce memory usage. + /// + /// Unlike `NodeBuilder::testing_node()` which hardcodes an 8 TB map size, + /// this method configures the database with a 100 MB map size. This prevents + /// `ENOMEM` errors when running parallel tests with `cargo test`, as the + /// default 8 TB size can cause memory exhaustion when multiple test processes + /// run concurrently. + fn create_test_database() -> Result>> { + let default_size = 100 * 1024 * 1024; // 100 MB + Self::create_test_database_with_size(default_size) + } + + /// Creates a test database with a configurable map size to reduce memory usage. + /// + /// # Arguments + /// + /// * `max_size` - Maximum map size in bytes. + fn create_test_database_with_size(max_size: usize) -> Result>> { + let path = tempdir_path(); + let emsg = format!("{ERROR_DB_CREATION}: {path:?}"); + let args = + DatabaseArguments::new(ClientVersion::default()).with_geometry_max_size(Some(max_size)); + let db = init_db(&path, args).expect(&emsg); + Ok(Arc::new(TempDatabase::new(db, path))) } pub fn provider(&self) -> Result> { @@ -220,11 +252,149 @@ impl LocalNode { EngineApi::::new(self.engine_ipc_path.clone()) } + pub fn blockchain_provider(&self) -> LocalNodeProvider { + self.provider.clone() + } +} + +async fn build_node(launcher: L) -> Result +where + L: FnOnce(OpBuilder) -> LRet, + LRet: Future, OpAddOns>>>, +{ + let tasks = TaskManager::current(); + let exec = tasks.executor(); + + let genesis: Genesis = serde_json::from_str(include_str!("../assets/genesis.json"))?; + let chain_spec = Arc::new(OpChainSpec::from_genesis(genesis)); + + let network_config = NetworkArgs { + discovery: DiscoveryArgs { disable_discovery: true, ..DiscoveryArgs::default() }, + ..NetworkArgs::default() + }; + + let unique_ipc_path = format!( + "/tmp/reth_engine_api_{}_{}_{:?}.ipc", + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(), + std::process::id(), + std::thread::current().id() + ); + + let mut rpc_args = RpcServerArgs::default().with_unused_ports().with_http().with_auth_ipc(); + rpc_args.auth_ipc_path = unique_ipc_path; + + let node = OpNode::new(RollupArgs::default()); + + let temp_db = LocalNode::create_test_database()?; + let db_path = temp_db.path().to_path_buf(); + + let mut node_config = NodeConfig::new(chain_spec.clone()) + .with_network(network_config) + .with_rpc(rpc_args) + .with_unused_ports(); + + let datadir_path = MaybePlatformPath::::from(db_path.clone()); + node_config = + node_config.with_datadir_args(DatadirArgs { datadir: datadir_path, ..Default::default() }); + + let builder = NodeBuilder::new(node_config.clone()) + .with_database(temp_db) + .with_launch_context(exec.clone()) + .with_types_and_provider::>() + .with_components(node.components_builder()) + .with_add_ons(node.add_ons()); + + let NodeHandle { node: node_handle, node_exit_future } = + builder.launch_with_fn(launcher).await?; + + let http_api_addr = node_handle + .rpc_server_handle() + .http_local_addr() + .ok_or_else(|| eyre::eyre!("HTTP RPC server failed to bind to address"))?; + + let engine_ipc_path = node_config.rpc.auth_ipc_path; + let provider = node_handle.provider().clone(); + + Ok(LocalNode { + http_api_addr, + engine_ipc_path, + provider, + _node_exit_future: node_exit_future, + _node: Box::new(node_handle), + _task_manager: tasks, + }) +} + +fn init_flashblocks_state( + cell: &Arc>>, + provider: &LocalNodeProvider, +) -> Arc { + cell.get_or_init(|| { + let fb = Arc::new(FlashblocksState::new(provider.clone(), 5)); + fb.start(); + fb + }) + .clone() +} + +pub struct FlashblocksLocalNode { + node: LocalNode, + parts: FlashblocksParts, +} + +impl FlashblocksLocalNode { + pub async fn new() -> Result { + Self::with_launcher(default_launcher).await + } + + /// Builds a flashblocks-enabled node with canonical block streaming disabled so tests can call + /// `FlashblocksState::on_canonical_block_received` at precise points. + pub async fn manual_canonical() -> Result { + Self::with_manual_canonical_launcher(default_launcher).await + } + + pub async fn with_launcher(launcher: L) -> Result + where + L: FnOnce(OpBuilder) -> LRet, + LRet: Future, OpAddOns>>>, + { + Self::with_launcher_inner(launcher, true).await + } + + pub async fn with_manual_canonical_launcher(launcher: L) -> Result + where + L: FnOnce(OpBuilder) -> LRet, + LRet: Future, OpAddOns>>>, + { + Self::with_launcher_inner(launcher, false).await + } + + async fn with_launcher_inner(launcher: L, process_canonical: bool) -> Result + where + L: FnOnce(OpBuilder) -> LRet, + LRet: Future, OpAddOns>>>, + { + let extensions = FlashblocksNodeExtensions::new(process_canonical); + let wrapped_launcher = extensions.wrap_launcher(launcher); + let node = LocalNode::new(wrapped_launcher).await?; + + let parts = extensions.parts()?; + Ok(Self { node, parts }) + } + pub fn flashblocks_state(&self) -> Arc { - self.flashblocks_state.clone() + self.parts.state() } - pub fn blockchain_provider(&self) -> LocalNodeProvider { - self.provider.clone() + pub async fn send_flashblock(&self, flashblock: Flashblock) -> Result<()> { + self.parts.send(flashblock).await + } + + pub fn into_parts(self) -> (LocalNode, FlashblocksParts) { + (self.node, self.parts) + } + + pub fn as_node(&self) -> &LocalNode { + &self.node } } From 9d07837f699f1e3ef260be6166b8a98cd5d77423 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Tue, 25 Nov 2025 20:16:58 -0600 Subject: [PATCH 20/25] Silence the global executor errors --- crates/metering/src/tests/chain_state.rs | 10 ++++++---- crates/metering/src/tests/rpc.rs | 19 ++++++++++--------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/crates/metering/src/tests/chain_state.rs b/crates/metering/src/tests/chain_state.rs index baa592d0..3d383a8b 100644 --- a/crates/metering/src/tests/chain_state.rs +++ b/crates/metering/src/tests/chain_state.rs @@ -5,7 +5,9 @@ use alloy_provider::Provider; use alloy_rpc_types_eth::TransactionInput; use alloy_sol_types::{SolCall, sol}; use base_reth_flashblocks_rpc::rpc::FlashblocksAPI; -use base_reth_test_utils::{flashblocks_harness::FlashblocksHarness, node::BASE_CHAIN_ID}; +use base_reth_test_utils::{ + flashblocks_harness::FlashblocksHarness, node::BASE_CHAIN_ID, tracing::init_silenced_tracing, +}; use eyre::{Result, eyre}; use hex_literal::hex; use op_alloy_consensus::OpTxEnvelope; @@ -21,7 +23,7 @@ use crate::rpc::{MeteringApiImpl, MeteringApiServer}; #[tokio::test] async fn meter_bundle_simulation_reflects_pending_state() -> Result<()> { - reth_tracing::init_test_tracing(); + init_silenced_tracing(); let harness = FlashblocksHarness::new().await?; let provider = harness.provider(); @@ -133,7 +135,7 @@ async fn meter_bundle_simulation_reflects_pending_state() -> Result<()> { #[tokio::test] async fn meter_bundle_errors_when_beacon_root_missing() -> Result<()> { - reth_tracing::init_test_tracing(); + init_silenced_tracing(); let harness = FlashblocksHarness::new().await?; let provider = harness.provider(); @@ -265,7 +267,7 @@ fn envelope_from_signed(tx: TransactionSigned) -> (OpTxEnvelope, Bytes) { #[tokio::test] async fn meter_bundle_reads_canonical_storage_without_mutation() -> Result<()> { - reth_tracing::init_test_tracing(); + init_silenced_tracing(); let harness = FlashblocksHarness::new().await?; let alice = &harness.accounts().alice; let alice_secret = secret_from_hex(alice.private_key); diff --git a/crates/metering/src/tests/rpc.rs b/crates/metering/src/tests/rpc.rs index 553a8ffc..46491674 100644 --- a/crates/metering/src/tests/rpc.rs +++ b/crates/metering/src/tests/rpc.rs @@ -4,6 +4,7 @@ use alloy_provider::Provider; use base_reth_test_utils::{ flashblocks_harness::FlashblocksHarness, node::{BASE_CHAIN_ID, LocalFlashblocksState, LocalNodeProvider}, + tracing::init_silenced_tracing, }; use eyre::{Result, eyre}; use op_alloy_consensus::OpTxEnvelope; @@ -67,7 +68,7 @@ fn create_bundle(txs: Vec, block_number: u64, min_timestamp: Option) #[tokio::test] async fn test_meter_bundle_empty() -> Result<()> { - reth_tracing::init_test_tracing(); + init_silenced_tracing(); let ctx = RpcTestContext::new().await?; let bundle = create_bundle(vec![], 0, None); @@ -85,7 +86,7 @@ async fn test_meter_bundle_empty() -> Result<()> { #[tokio::test] async fn test_meter_bundle_single_transaction() -> Result<()> { - reth_tracing::init_test_tracing(); + init_silenced_tracing(); let ctx = RpcTestContext::new().await?; let sender_account = &ctx.accounts().alice; @@ -126,7 +127,7 @@ async fn test_meter_bundle_single_transaction() -> Result<()> { #[tokio::test] async fn test_meter_bundle_multiple_transactions() -> Result<()> { - reth_tracing::init_test_tracing(); + init_silenced_tracing(); let ctx = RpcTestContext::new().await?; let secret1 = secret_from_hex(ctx.accounts().alice.private_key); @@ -181,7 +182,7 @@ async fn test_meter_bundle_multiple_transactions() -> Result<()> { #[tokio::test] async fn test_meter_bundle_invalid_transaction() -> Result<()> { - reth_tracing::init_test_tracing(); + init_silenced_tracing(); let ctx = RpcTestContext::new().await?; let bundle = create_bundle(vec![Bytes::from_static(b"\xde\xad\xbe\xef")], 0, None); @@ -193,7 +194,7 @@ async fn test_meter_bundle_invalid_transaction() -> Result<()> { #[tokio::test] async fn test_meter_bundle_uses_latest_block() -> Result<()> { - reth_tracing::init_test_tracing(); + init_silenced_tracing(); let ctx = RpcTestContext::new().await?; ctx.harness().advance_chain(2).await?; @@ -209,7 +210,7 @@ async fn test_meter_bundle_uses_latest_block() -> Result<()> { #[tokio::test] async fn test_meter_bundle_ignores_bundle_block_number() -> Result<()> { - reth_tracing::init_test_tracing(); + init_silenced_tracing(); let ctx = RpcTestContext::new().await?; let bundle1 = create_bundle(vec![], 0, None); @@ -227,7 +228,7 @@ async fn test_meter_bundle_ignores_bundle_block_number() -> Result<()> { #[tokio::test] async fn test_meter_bundle_custom_timestamp() -> Result<()> { - reth_tracing::init_test_tracing(); + init_silenced_tracing(); let ctx = RpcTestContext::new().await?; let custom_timestamp = 1_234_567_890; @@ -242,7 +243,7 @@ async fn test_meter_bundle_custom_timestamp() -> Result<()> { #[tokio::test] async fn test_meter_bundle_arbitrary_block_number() -> Result<()> { - reth_tracing::init_test_tracing(); + init_silenced_tracing(); let ctx = RpcTestContext::new().await?; let bundle = create_bundle(vec![], 999_999, None); @@ -256,7 +257,7 @@ async fn test_meter_bundle_arbitrary_block_number() -> Result<()> { #[tokio::test] async fn test_meter_bundle_gas_calculations() -> Result<()> { - reth_tracing::init_test_tracing(); + init_silenced_tracing(); let ctx = RpcTestContext::new().await?; let secret1 = secret_from_hex(ctx.accounts().alice.private_key); From d8c71843bea1d4c85b5e3535cf690d3f3c6f61f7 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Tue, 25 Nov 2025 20:19:52 -0600 Subject: [PATCH 21/25] Add TODO for state_root_time --- crates/metering/src/rpc.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/metering/src/rpc.rs b/crates/metering/src/rpc.rs index f55c2227..4c6d40b7 100644 --- a/crates/metering/src/rpc.rs +++ b/crates/metering/src/rpc.rs @@ -209,6 +209,8 @@ where state_block_number: header.number, state_flashblock_index, total_gas_used: result.total_gas_used, + // TODO: reintroduce state_root_time_us once tips-core exposes it again. + // TODO: rename total_execution_time_us to total_time_us since it includes state root time total_execution_time_us: result.total_time_us, }) } From 9752d0ab008d02fa996e82dc12e127dc44d9c89e Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Wed, 26 Nov 2025 15:10:35 -0600 Subject: [PATCH 22/25] Add metrics for bundle state clone cost Track duration and size (number of accounts) when cloning BundleState to help identify if this becomes a performance bottleneck. --- crates/flashblocks-rpc/src/metrics.rs | 6 ++++++ crates/flashblocks-rpc/src/pending_blocks.rs | 18 +++++++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/flashblocks-rpc/src/metrics.rs b/crates/flashblocks-rpc/src/metrics.rs index bd73816a..4c557fae 100644 --- a/crates/flashblocks-rpc/src/metrics.rs +++ b/crates/flashblocks-rpc/src/metrics.rs @@ -66,4 +66,10 @@ pub struct Metrics { #[metric(describe = "Total number of WebSocket reconnection attempts")] pub reconnect_attempts: Counter, + + #[metric(describe = "Time taken to clone bundle state")] + pub bundle_state_clone_duration: Histogram, + + #[metric(describe = "Size of bundle state being cloned (number of accounts)")] + pub bundle_state_clone_size: Histogram, } diff --git a/crates/flashblocks-rpc/src/pending_blocks.rs b/crates/flashblocks-rpc/src/pending_blocks.rs index 8279dda8..1c8b22a4 100644 --- a/crates/flashblocks-rpc/src/pending_blocks.rs +++ b/crates/flashblocks-rpc/src/pending_blocks.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::{sync::Arc, time::Instant}; use alloy_consensus::{Header, Sealed}; use alloy_eips::BlockNumberOrTag; @@ -20,7 +20,7 @@ use reth::revm::{ use reth_rpc_convert::RpcTransaction; use reth_rpc_eth_api::{RpcBlock, RpcReceipt}; -use crate::{rpc::PendingBlocksAPI, subscription::Flashblock}; +use crate::{metrics::Metrics, rpc::PendingBlocksAPI, subscription::Flashblock}; pub struct PendingBlocksBuilder { flashblocks: Vec, @@ -208,8 +208,20 @@ impl PendingBlocks { self.db_cache.clone() } + /// Returns a clone of the bundle state. + /// + /// NOTE: This clones the entire BundleState, which contains a HashMap of all touched + /// accounts and their storage slots. The cost scales with the number of accounts and + /// storage slots modified in the flashblock. Monitor `bundle_state_clone_duration` and + /// `bundle_state_clone_size` metrics to track if this becomes a bottleneck. pub fn get_bundle_state(&self) -> BundleState { - self.bundle_state.clone() + let metrics = Metrics::default(); + let size = self.bundle_state.state.len(); + let start = Instant::now(); + let cloned = self.bundle_state.clone(); + metrics.bundle_state_clone_duration.record(start.elapsed()); + metrics.bundle_state_clone_size.record(size as f64); + cloned } pub fn get_transactions_for_block(&self, block_number: BlockNumber) -> Vec { From 1942db86dd98561d60fd72008801ebf44941ed82 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Wed, 26 Nov 2025 16:03:01 -0600 Subject: [PATCH 23/25] Add database layering tests to flashblocks-rpc Add tests verifying the correct State> layering order. These tests demonstrate that CacheDB> loses writes because CacheDB intercepts all writes into its internal cache without propagating them to State's bundle tracking. --- Cargo.lock | 1 + crates/flashblocks-rpc/Cargo.toml | 1 + crates/flashblocks-rpc/tests/layering.rs | 418 +++++++++++++++++++++++ crates/metering/src/tests/meter.rs | 101 +++++- 4 files changed, 520 insertions(+), 1 deletion(-) create mode 100644 crates/flashblocks-rpc/tests/layering.rs diff --git a/Cargo.lock b/Cargo.lock index 0c23815a..bd983444 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1566,6 +1566,7 @@ dependencies = [ "reth-rpc-eth-api", "reth-testing-utils", "reth-tracing", + "revm", "revm-database", "rollup-boost", "serde", diff --git a/crates/flashblocks-rpc/Cargo.toml b/crates/flashblocks-rpc/Cargo.toml index 54c6f5e8..0247e4ff 100644 --- a/crates/flashblocks-rpc/Cargo.toml +++ b/crates/flashblocks-rpc/Cargo.toml @@ -79,6 +79,7 @@ arc-swap.workspace = true [dev-dependencies] base-reth-test-utils.workspace = true rand.workspace = true +revm.workspace = true reth-db.workspace = true reth-testing-utils.workspace = true reth-db-common.workspace = true diff --git a/crates/flashblocks-rpc/tests/layering.rs b/crates/flashblocks-rpc/tests/layering.rs new file mode 100644 index 00000000..cf631d9d --- /dev/null +++ b/crates/flashblocks-rpc/tests/layering.rs @@ -0,0 +1,418 @@ +// ============================================================================= +// Database Layering Tests +// +// These tests verify that the three-layer state architecture is correct: +// 1. StateProviderDatabase (canonical block base state) +// 2. CacheDB (applies flashblock pending read cache) +// 3. State wrapper (applies bundle_prestate for pending writes + tracks new changes) +// +// The correct layering is State>. Writes go +// through State first (properly tracked for bundle/state root calculation), +// and reads fall through to CacheDB (flashblock read cache) then to +// StateProviderDatabase (canonical state). +// +// The WRONG layering would be CacheDB>. CacheDB intercepts all +// writes into its internal cache and doesn't propagate them to State, so +// State's bundle tracking captures nothing. See the test +// layering_cachedb_wrapping_state_loses_writes for a demonstration. +// ============================================================================= + +use std::sync::Arc; + +use alloy_consensus::crypto::secp256k1::public_key_to_address; +use alloy_genesis::GenesisAccount; +use alloy_primitives::{Address, B256, U256}; +use eyre::Context; +use rand::{SeedableRng, rngs::StdRng}; +use reth::api::NodeTypesWithDBAdapter; +use reth::revm::db::{AccountState, BundleState, Cache, CacheDB, DbAccount, State}; +use reth_db::{ + ClientVersion, DatabaseEnv, init_db, + mdbx::{DatabaseArguments, KILOBYTE, MEGABYTE, MaxReadTransactionDuration}, + test_utils::{ERROR_DB_CREATION, TempDatabase, create_test_static_files_dir, tempdir_path}, +}; +use reth_optimism_chainspec::{BASE_MAINNET, OpChainSpec, OpChainSpecBuilder}; +use reth_optimism_node::OpNode; +use reth_primitives_traits::SealedHeader; +use reth_provider::{ + HeaderProvider, ProviderFactory, StateProviderFactory, providers::{BlockchainProvider, StaticFileProvider}, +}; +use reth_testing_utils::generators::generate_keys; +use revm::Database; +use revm::primitives::KECCAK_EMPTY; + +type NodeTypes = NodeTypesWithDBAdapter>>; + +#[derive(Eq, PartialEq, Debug, Hash, Clone, Copy)] +enum User { + Alice, + Bob, +} + +#[derive(Debug, Clone)] +struct TestHarness { + provider: BlockchainProvider, + header: SealedHeader, + #[allow(dead_code)] + chain_spec: Arc, + user_to_address: std::collections::HashMap, + #[allow(dead_code)] + user_to_private_key: std::collections::HashMap, +} + +impl TestHarness { + fn address(&self, u: User) -> Address { + self.user_to_address[&u] + } +} + +fn create_chain_spec( + seed: u64, +) -> ( + Arc, + std::collections::HashMap, + std::collections::HashMap, +) { + let keys = generate_keys(&mut StdRng::seed_from_u64(seed), 2); + + let mut addresses = std::collections::HashMap::new(); + let mut private_keys = std::collections::HashMap::new(); + + let alice_key = keys[0]; + let alice_address = public_key_to_address(alice_key.public_key()); + let alice_secret = B256::from(alice_key.secret_bytes()); + addresses.insert(User::Alice, alice_address); + private_keys.insert(User::Alice, alice_secret); + + let bob_key = keys[1]; + let bob_address = public_key_to_address(bob_key.public_key()); + let bob_secret = B256::from(bob_key.secret_bytes()); + addresses.insert(User::Bob, bob_address); + private_keys.insert(User::Bob, bob_secret); + + let genesis = BASE_MAINNET + .genesis + .clone() + .extend_accounts(vec![ + (alice_address, GenesisAccount::default().with_balance(U256::from(1_000_000_000_u64))), + (bob_address, GenesisAccount::default().with_balance(U256::from(1_000_000_000_u64))), + ]) + .with_gas_limit(100_000_000); + + let spec = + Arc::new(OpChainSpecBuilder::base_mainnet().genesis(genesis).isthmus_activated().build()); + + (spec, addresses, private_keys) +} + +fn create_provider_factory( + chain_spec: Arc, +) -> ProviderFactory>>> { + let (static_dir, _) = create_test_static_files_dir(); + let db = create_test_db(); + ProviderFactory::new( + db, + chain_spec, + StaticFileProvider::read_write(static_dir.keep()).expect("static file provider"), + ) +} + +fn create_test_db() -> Arc> { + let path = tempdir_path(); + let emsg = format!("{ERROR_DB_CREATION}: {path:?}"); + + let db = init_db( + &path, + DatabaseArguments::new(ClientVersion::default()) + .with_max_read_transaction_duration(Some(MaxReadTransactionDuration::Unbounded)) + .with_geometry_max_size(Some(4 * MEGABYTE)) + .with_growth_step(Some(4 * KILOBYTE)), + ) + .expect(&emsg); + + Arc::new(TempDatabase::new(db, path)) +} + +fn setup_harness() -> eyre::Result { + let (chain_spec, user_to_address, user_to_private_key) = create_chain_spec(1337); + let factory = create_provider_factory(chain_spec.clone()); + + reth_db_common::init::init_genesis(&factory).context("initializing genesis state")?; + + let provider = BlockchainProvider::new(factory.clone()).context("creating provider")?; + let header = provider + .sealed_header(0) + .context("fetching genesis header")? + .expect("genesis header exists"); + + Ok(TestHarness { provider, header, chain_spec, user_to_address, user_to_private_key }) +} + +/// Demonstrates that State alone cannot see pending state. +/// +/// Without CacheDB or bundle_prestate, State can only see canonical block state. +#[test] +fn layering_old_state_only_cannot_see_pending_state() -> eyre::Result<()> { + let harness = setup_harness()?; + let alice_address = harness.address(User::Alice); + + let state_provider = harness + .provider + .state_by_block_hash(harness.header.hash()) + .context("getting state provider")?; + + // OLD implementation: just State wrapping StateProviderDatabase directly + // No CacheDB, no bundle_prestate - cannot see any pending flashblock state + let state_db = reth::revm::database::StateProviderDatabase::new(state_provider); + let mut db = State::builder().with_database(state_db).with_bundle_update().build(); + + // Read through State - can only see canonical state (nonce 0) + let account = db.basic(alice_address)?.expect("account should exist"); + + // Old implementation sees canonical nonce (0), not any pending state + assert_eq!( + account.nonce, 0, + "Old State-only layering can only see canonical state" + ); + + Ok(()) +} + +/// Demonstrates that CacheDB> is the WRONG layering order. +/// +/// CacheDB is a read-through cache. When the EVM writes state changes, those +/// writes go into CacheDB's internal cache and are NOT propagated to the +/// underlying State. As a result, State's bundle tracking captures nothing, +/// and take_bundle() returns an empty bundle - breaking state root calculation. +/// +/// The correct layering is State> where State wraps CacheDB, +/// so all writes go through State first and are properly tracked. +#[test] +fn layering_cachedb_wrapping_state_loses_writes() -> eyre::Result<()> { + use revm::DatabaseCommit; + + let harness = setup_harness()?; + let alice_address = harness.address(User::Alice); + let bob_address = harness.address(User::Bob); + + // ========================================================================= + // WRONG layering: CacheDB> + // ========================================================================= + let state_provider = harness + .provider + .state_by_block_hash(harness.header.hash()) + .context("getting state provider")?; + + let state_db = reth::revm::database::StateProviderDatabase::new(state_provider); + let state = State::builder().with_database(state_db).with_bundle_update().build(); + let mut wrong_db = CacheDB::new(state); + + // Simulate a write: modify Alice's account through the CacheDB + let mut alice_account = wrong_db.basic(alice_address)?.expect("alice should exist"); + alice_account.nonce = 999; + alice_account.balance = U256::from(12345u64); + + // Commit the change through CacheDB + let mut state_changes = revm::state::EvmState::default(); + state_changes.insert( + alice_address, + revm::state::Account { + info: alice_account.clone(), + storage: Default::default(), + status: revm::state::AccountStatus::Touched, + transaction_id: 0, + }, + ); + wrong_db.commit(state_changes); + + // The write went into CacheDB's cache - verify we can read it back + let alice_from_cache = wrong_db.basic(alice_address)?.expect("alice should exist"); + assert_eq!(alice_from_cache.nonce, 999, "CacheDB should have the written nonce"); + + // BUT: The underlying State captured NOTHING! + // CacheDB doesn't propagate writes to its underlying database. + wrong_db.db.merge_transitions(revm_database::states::bundle_state::BundleRetention::Reverts); + let wrong_bundle = wrong_db.db.take_bundle(); + + assert!( + wrong_bundle.state.is_empty(), + "WRONG layering: State's bundle should be EMPTY because CacheDB intercepted all writes. \ + Got {} accounts in bundle.", + wrong_bundle.state.len() + ); + + // ========================================================================= + // CORRECT layering: State> + // ========================================================================= + let state_provider2 = harness + .provider + .state_by_block_hash(harness.header.hash()) + .context("getting state provider")?; + + let state_db2 = reth::revm::database::StateProviderDatabase::new(state_provider2); + let cache_db = CacheDB::new(state_db2); + let mut correct_db = State::builder().with_database(cache_db).with_bundle_update().build(); + + // Simulate the same write through State + let mut bob_account = correct_db.basic(bob_address)?.expect("bob should exist"); + bob_account.nonce = 888; + bob_account.balance = U256::from(54321u64); + + // Commit through State + let mut state_changes2 = revm::state::EvmState::default(); + state_changes2.insert( + bob_address, + revm::state::Account { + info: bob_account.clone(), + storage: Default::default(), + status: revm::state::AccountStatus::Touched, + transaction_id: 0, + }, + ); + correct_db.commit(state_changes2); + + // State properly captures the write + correct_db.merge_transitions(revm_database::states::bundle_state::BundleRetention::Reverts); + let correct_bundle = correct_db.take_bundle(); + + assert!( + !correct_bundle.state.is_empty(), + "CORRECT layering: State's bundle should contain the written account" + ); + assert!( + correct_bundle.state.contains_key(&bob_address), + "Bundle should contain Bob's account changes" + ); + + Ok(()) +} + +/// Verifies that CacheDB layer is required for pending balance visibility. +/// +/// This test demonstrates that without the CacheDB layer, pending balance +/// changes from flashblocks would not be visible to bundle execution. +#[test] +fn layering_cachedb_makes_pending_balance_visible() -> eyre::Result<()> { + let harness = setup_harness()?; + + // Get the canonical balance for Alice from the state provider + let state_provider = harness + .provider + .state_by_block_hash(harness.header.hash()) + .context("getting state provider")?; + + let alice_address = harness.address(User::Alice); + let canonical_balance = state_provider.account_balance(&alice_address)?.unwrap_or(U256::ZERO); + + // Create a cache with a modified balance (simulating a pending flashblock) + let pending_balance = canonical_balance + U256::from(999_999_u64); + let mut cache = Cache::default(); + cache.accounts.insert( + alice_address, + DbAccount { + info: revm::state::AccountInfo { + balance: pending_balance, + nonce: 0, + code_hash: KECCAK_EMPTY, + code: None, + }, + account_state: AccountState::Touched, + storage: Default::default(), + }, + ); + + // Wrap with CacheDB to apply the pending cache + let state_db = reth::revm::database::StateProviderDatabase::new(state_provider); + let mut cache_db = CacheDB { cache, db: state_db }; + + // Read the balance through CacheDB - should see pending value + let account = cache_db.basic(alice_address)?.expect("account should exist"); + assert_eq!( + account.balance, pending_balance, + "CacheDB should return pending balance from cache" + ); + + // Verify the canonical state still has the original balance + let state_provider2 = harness + .provider + .state_by_block_hash(harness.header.hash()) + .context("getting state provider")?; + let canonical_balance2 = + state_provider2.account_balance(&alice_address)?.unwrap_or(U256::ZERO); + assert_eq!( + canonical_balance, canonical_balance2, + "Canonical state should be unchanged" + ); + + Ok(()) +} + +/// Verifies that bundle_prestate is required for pending state changes visibility. +/// +/// This test demonstrates that without with_bundle_prestate(), the State wrapper +/// would start with empty state and not see pending flashblock state changes. +#[test] +fn layering_bundle_prestate_makes_pending_nonce_visible() -> eyre::Result<()> { + let harness = setup_harness()?; + let alice_address = harness.address(User::Alice); + + let state_provider = harness + .provider + .state_by_block_hash(harness.header.hash()) + .context("getting state provider")?; + + // Create a BundleState with a modified nonce (simulating pending flashblock writes) + let pending_nonce = 42u64; + let bundle_state = BundleState::new( + [( + alice_address, + Some(revm::state::AccountInfo { + balance: U256::from(1_000_000_000u64), + nonce: 0, // original + code_hash: KECCAK_EMPTY, + code: None, + }), + Some(revm::state::AccountInfo { + balance: U256::from(1_000_000_000u64), + nonce: pending_nonce, // pending + code_hash: KECCAK_EMPTY, + code: None, + }), + Default::default(), + )], + Vec::>, Vec<(U256, U256)>)>>::new(), + Vec::<(B256, revm::bytecode::Bytecode)>::new(), + ); + + // Create the three-layer stack WITH bundle_prestate + let state_db = reth::revm::database::StateProviderDatabase::new(state_provider); + let cache_db = CacheDB::new(state_db); + let mut db_with_prestate = State::builder() + .with_database(cache_db) + .with_bundle_update() + .with_bundle_prestate(bundle_state.clone()) + .build(); + + // Read through the State - should see pending nonce from bundle_prestate + let account = db_with_prestate.basic(alice_address)?.expect("account should exist"); + assert_eq!(account.nonce, pending_nonce, "State with bundle_prestate should see pending nonce"); + + // Now create WITHOUT bundle_prestate to show the difference + let state_provider2 = harness + .provider + .state_by_block_hash(harness.header.hash()) + .context("getting state provider")?; + let state_db2 = reth::revm::database::StateProviderDatabase::new(state_provider2); + let cache_db2 = CacheDB::new(state_db2); + let mut db_without_prestate = + State::builder().with_database(cache_db2).with_bundle_update().build(); + + // Read through State without prestate - should see canonical nonce (0) + let account2 = db_without_prestate.basic(alice_address)?.expect("account should exist"); + assert_eq!( + account2.nonce, 0, + "State without bundle_prestate should see canonical nonce" + ); + + Ok(()) +} diff --git a/crates/metering/src/tests/meter.rs b/crates/metering/src/tests/meter.rs index 3512c0aa..7601938c 100644 --- a/crates/metering/src/tests/meter.rs +++ b/crates/metering/src/tests/meter.rs @@ -7,6 +7,7 @@ use alloy_primitives::{Address, B256, Bytes, U256, keccak256}; use eyre::Context; use op_alloy_consensus::OpTxEnvelope; use rand::{SeedableRng, rngs::StdRng}; +use reth::revm::db::{BundleState, Cache}; use reth::{api::NodeTypesWithDBAdapter, chainspec::EthChainSpec}; use reth_db::{DatabaseEnv, test_utils::TempDatabase}; use reth_optimism_chainspec::{BASE_MAINNET, OpChainSpec, OpChainSpecBuilder}; @@ -16,10 +17,11 @@ use reth_primitives_traits::SealedHeader; use reth_provider::{HeaderProvider, StateProviderFactory, providers::BlockchainProvider}; use reth_testing_utils::generators::generate_keys; use reth_transaction_pool::test_utils::TransactionBuilder; +use revm::primitives::KECCAK_EMPTY; use tips_core::types::{Bundle, ParsedBundle}; use super::utils::create_provider_factory; -use crate::meter_bundle; +use crate::{FlashblocksState, meter_bundle}; type NodeTypes = NodeTypesWithDBAdapter>>; @@ -367,3 +369,100 @@ fn meter_bundle_state_root_time_invariant() -> eyre::Result<()> { Ok(()) } + +/// Integration test: verifies meter_bundle uses flashblocks state correctly. +/// +/// A transaction using nonce=1 should fail without flashblocks state (since +/// canonical nonce is 0), but succeed when flashblocks state indicates nonce=1. +#[test] +fn meter_bundle_requires_correct_layering_for_pending_nonce() -> eyre::Result<()> { + let harness = setup_harness()?; + let alice_address = harness.address(User::Alice); + + // Create a transaction that requires nonce=1 (assuming canonical nonce is 0) + let to = Address::random(); + let signed_tx = TransactionBuilder::default() + .signer(harness.signer(User::Alice)) + .chain_id(harness.chain_spec.chain_id()) + .nonce(1) // Requires pending state to have nonce=1 + .to(to) + .value(100) + .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 parsed_bundle = create_parsed_bundle(vec![envelope])?; + + // Without flashblocks state, transaction should fail (nonce mismatch) + let state_provider = harness + .provider + .state_by_block_hash(harness.header.hash()) + .context("getting state provider")?; + + let result_without_flashblocks = meter_bundle( + state_provider, + harness.chain_spec.clone(), + parsed_bundle.clone(), + &harness.header, + None, // No flashblocks state + None, + ); + + assert!( + result_without_flashblocks.is_err(), + "Transaction with nonce=1 should fail without pending state (canonical nonce is 0)" + ); + + // Now create flashblocks state with nonce=1 for Alice + // Use BundleState::new() to properly calculate state_size + let bundle_state = BundleState::new( + [( + alice_address, + Some(revm::state::AccountInfo { + balance: U256::from(1_000_000_000u64), + nonce: 0, // original + code_hash: KECCAK_EMPTY, + code: None, + }), + Some(revm::state::AccountInfo { + balance: U256::from(1_000_000_000u64), + nonce: 1, // pending (after first flashblock tx) + code_hash: KECCAK_EMPTY, + code: None, + }), + Default::default(), // no storage changes + )], + Vec::>, Vec<(U256, U256)>)>>::new(), + Vec::<(B256, revm::bytecode::Bytecode)>::new(), + ); + + let flashblocks_state = FlashblocksState { cache: Cache::default(), bundle_state }; + + // With correct flashblocks state, transaction should succeed + let state_provider2 = harness + .provider + .state_by_block_hash(harness.header.hash()) + .context("getting state provider")?; + + let result_with_flashblocks = meter_bundle( + state_provider2, + harness.chain_spec.clone(), + parsed_bundle, + &harness.header, + Some(flashblocks_state), + None, + ); + + assert!( + result_with_flashblocks.is_ok(), + "Transaction with nonce=1 should succeed with pending state showing nonce=1: {:?}", + result_with_flashblocks.err() + ); + + Ok(()) +} + From 5fbc033fe92ce83c5ed006a6ab0d419bdce130ae Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Wed, 26 Nov 2025 22:17:16 -0600 Subject: [PATCH 24/25] Add tests for flashblock state visibility Add two tests that verify pending state from flashblocks is correctly visible: - test_eth_call_sees_flashblock_state_changes: Verifies eth_call to pending block sees balance changes from executed flashblock transactions - test_sequential_nonces_across_flashblocks: Verifies transactions in flashblock N+1 can see state changes (nonces) from flashblock N --- crates/flashblocks-rpc/tests/state.rs | 140 ++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/crates/flashblocks-rpc/tests/state.rs b/crates/flashblocks-rpc/tests/state.rs index 5c93c1e8..4a3d0ea1 100644 --- a/crates/flashblocks-rpc/tests/state.rs +++ b/crates/flashblocks-rpc/tests/state.rs @@ -838,6 +838,146 @@ async fn test_duplicate_flashblock_ignored() { assert_eq!(block, block_two); } +/// Verifies that eth_call targeting pending block sees flashblock state changes. +/// +/// This test catches database layering bugs where pending state from flashblocks +/// isn't visible to RPC callers. After a flashblock transfers ETH to Bob, an +/// eth_call simulating a transfer FROM Bob should succeed because Bob now has +/// more funds from the flashblock. +#[tokio::test] +async fn test_eth_call_sees_flashblock_state_changes() { + use alloy_eips::BlockNumberOrTag; + use alloy_provider::Provider; + use alloy_rpc_types_eth::TransactionInput; + use op_alloy_rpc_types::OpTransactionRequest; + + let test = TestHarness::new().await; + let provider = test.node.provider(); + + let bob_address = test.address(User::Bob); + let charlie_address = test.address(User::Charlie); + + // Get Bob's canonical balance to calculate a transfer amount that exceeds it + let canonical_balance = provider.get_balance(bob_address).await.unwrap(); + + // Send base flashblock + test.send_flashblock(FlashblockBuilder::new_base(&test).build()).await; + + // Flashblock 1: Alice sends a large amount to Bob + let transfer_to_bob = 1_000_000_000_000_000_000u128; // 1 ETH + let tx = test.build_transaction_to_send_eth_with_nonce( + User::Alice, + User::Bob, + transfer_to_bob, + 0, + ); + test.send_flashblock( + FlashblockBuilder::new(&test, 1) + .with_transactions(vec![tx]) + .build(), + ) + .await; + + // Verify via state overrides that Bob received the funds + let overrides = test + .flashblocks + .get_pending_blocks() + .get_state_overrides() + .expect("state overrides should exist after flashblock execution"); + let bob_override = overrides.get(&bob_address).expect("Bob should have a state override"); + let bob_pending_balance = bob_override.balance.expect("Bob's balance override should be set"); + assert_eq!( + bob_pending_balance, + canonical_balance + U256::from(transfer_to_bob), + "State override should show Bob's increased balance" + ); + + // Now the key test: eth_call from Bob should see this pending balance. + // Try to transfer more than Bob's canonical balance (but less than pending). + // This would fail if eth_call can't see the pending state. + let transfer_amount = canonical_balance + U256::from(100_000u64); + let call_request = OpTransactionRequest::default() + .from(bob_address) + .to(charlie_address) + .value(transfer_amount) + .gas_limit(21_000) + .input(TransactionInput::default()); + + let result = provider.call(call_request).block(BlockNumberOrTag::Pending.into()).await; + assert!( + result.is_ok(), + "eth_call from Bob should succeed because pending state shows increased balance. \ + If this fails, eth_call may not be seeing flashblock state changes. Error: {:?}", + result.err() + ); +} + +/// Verifies that transactions in flashblock N+1 can see state changes from flashblock N. +/// +/// This test catches database layering bugs where writes from earlier flashblocks +/// aren't visible to later flashblock execution. The key is that flashblock 2's +/// transaction uses nonce=1, which only succeeds if the execution layer sees +/// flashblock 1's transaction (which used nonce=0). +#[tokio::test] +async fn test_sequential_nonces_across_flashblocks() { + let test = TestHarness::new().await; + + // Send base flashblock + test.send_flashblock(FlashblockBuilder::new_base(&test).build()).await; + + // Flashblock 1: Alice sends to Bob with nonce 0 + let tx_nonce_0 = test.build_transaction_to_send_eth_with_nonce(User::Alice, User::Bob, 1000, 0); + test.send_flashblock( + FlashblockBuilder::new(&test, 1) + .with_transactions(vec![tx_nonce_0]) + .build(), + ) + .await; + + // Verify flashblock 1 was processed - Alice's pending nonce should now be 1 + let alice_state = test.account_state(User::Alice); + assert_eq!( + alice_state.nonce, 1, + "After flashblock 1, Alice's pending nonce should be 1" + ); + + // Flashblock 2: Alice sends to Charlie with nonce 1 + // This will FAIL if the execution layer can't see flashblock 1's state change + let tx_nonce_1 = + test.build_transaction_to_send_eth_with_nonce(User::Alice, User::Charlie, 2000, 1); + test.send_flashblock( + FlashblockBuilder::new(&test, 2) + .with_transactions(vec![tx_nonce_1]) + .build(), + ) + .await; + + // Verify flashblock 2 was processed - Alice's pending nonce should now be 2 + let alice_state_after = test.account_state(User::Alice); + assert_eq!( + alice_state_after.nonce, 2, + "After flashblock 2, Alice's pending nonce should be 2. \ + If this fails, the database layering may be preventing flashblock 2 \ + from seeing flashblock 1's state changes." + ); + + // Also verify Bob and Charlie received their funds + let overrides = test + .flashblocks + .get_pending_blocks() + .get_state_overrides() + .expect("state overrides should exist"); + + assert!( + overrides.get(&test.address(User::Bob)).is_some(), + "Bob should have received funds from flashblock 1" + ); + assert!( + overrides.get(&test.address(User::Charlie)).is_some(), + "Charlie should have received funds from flashblock 2" + ); +} + #[tokio::test] async fn test_progress_canonical_blocks_without_flashblocks() { let mut test = TestHarness::new().await; From 4d324f57571b37100c5844cc32f7bc9c2e3e6fc1 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Wed, 26 Nov 2025 22:18:36 -0600 Subject: [PATCH 25/25] just fix --- crates/flashblocks-rpc/tests/layering.rs | 30 +++++++++--------------- crates/flashblocks-rpc/tests/state.rs | 29 ++++++----------------- crates/metering/src/tests/meter.rs | 8 ++++--- 3 files changed, 23 insertions(+), 44 deletions(-) diff --git a/crates/flashblocks-rpc/tests/layering.rs b/crates/flashblocks-rpc/tests/layering.rs index cf631d9d..049198d3 100644 --- a/crates/flashblocks-rpc/tests/layering.rs +++ b/crates/flashblocks-rpc/tests/layering.rs @@ -24,8 +24,10 @@ use alloy_genesis::GenesisAccount; use alloy_primitives::{Address, B256, U256}; use eyre::Context; use rand::{SeedableRng, rngs::StdRng}; -use reth::api::NodeTypesWithDBAdapter; -use reth::revm::db::{AccountState, BundleState, Cache, CacheDB, DbAccount, State}; +use reth::{ + api::NodeTypesWithDBAdapter, + revm::db::{AccountState, BundleState, Cache, CacheDB, DbAccount, State}, +}; use reth_db::{ ClientVersion, DatabaseEnv, init_db, mdbx::{DatabaseArguments, KILOBYTE, MEGABYTE, MaxReadTransactionDuration}, @@ -35,11 +37,11 @@ use reth_optimism_chainspec::{BASE_MAINNET, OpChainSpec, OpChainSpecBuilder}; use reth_optimism_node::OpNode; use reth_primitives_traits::SealedHeader; use reth_provider::{ - HeaderProvider, ProviderFactory, StateProviderFactory, providers::{BlockchainProvider, StaticFileProvider}, + HeaderProvider, ProviderFactory, StateProviderFactory, + providers::{BlockchainProvider, StaticFileProvider}, }; use reth_testing_utils::generators::generate_keys; -use revm::Database; -use revm::primitives::KECCAK_EMPTY; +use revm::{Database, primitives::KECCAK_EMPTY}; type NodeTypes = NodeTypesWithDBAdapter>>; @@ -170,10 +172,7 @@ fn layering_old_state_only_cannot_see_pending_state() -> eyre::Result<()> { let account = db.basic(alice_address)?.expect("account should exist"); // Old implementation sees canonical nonce (0), not any pending state - assert_eq!( - account.nonce, 0, - "Old State-only layering can only see canonical state" - ); + assert_eq!(account.nonce, 0, "Old State-only layering can only see canonical state"); Ok(()) } @@ -337,12 +336,8 @@ fn layering_cachedb_makes_pending_balance_visible() -> eyre::Result<()> { .provider .state_by_block_hash(harness.header.hash()) .context("getting state provider")?; - let canonical_balance2 = - state_provider2.account_balance(&alice_address)?.unwrap_or(U256::ZERO); - assert_eq!( - canonical_balance, canonical_balance2, - "Canonical state should be unchanged" - ); + let canonical_balance2 = state_provider2.account_balance(&alice_address)?.unwrap_or(U256::ZERO); + assert_eq!(canonical_balance, canonical_balance2, "Canonical state should be unchanged"); Ok(()) } @@ -409,10 +404,7 @@ fn layering_bundle_prestate_makes_pending_nonce_visible() -> eyre::Result<()> { // Read through State without prestate - should see canonical nonce (0) let account2 = db_without_prestate.basic(alice_address)?.expect("account should exist"); - assert_eq!( - account2.nonce, 0, - "State without bundle_prestate should see canonical nonce" - ); + assert_eq!(account2.nonce, 0, "State without bundle_prestate should see canonical nonce"); Ok(()) } diff --git a/crates/flashblocks-rpc/tests/state.rs b/crates/flashblocks-rpc/tests/state.rs index 4a3d0ea1..456d3b7a 100644 --- a/crates/flashblocks-rpc/tests/state.rs +++ b/crates/flashblocks-rpc/tests/state.rs @@ -865,18 +865,10 @@ async fn test_eth_call_sees_flashblock_state_changes() { // Flashblock 1: Alice sends a large amount to Bob let transfer_to_bob = 1_000_000_000_000_000_000u128; // 1 ETH - let tx = test.build_transaction_to_send_eth_with_nonce( - User::Alice, - User::Bob, - transfer_to_bob, - 0, - ); - test.send_flashblock( - FlashblockBuilder::new(&test, 1) - .with_transactions(vec![tx]) - .build(), - ) - .await; + let tx = + test.build_transaction_to_send_eth_with_nonce(User::Alice, User::Bob, transfer_to_bob, 0); + test.send_flashblock(FlashblockBuilder::new(&test, 1).with_transactions(vec![tx]).build()) + .await; // Verify via state overrides that Bob received the funds let overrides = test @@ -928,27 +920,20 @@ async fn test_sequential_nonces_across_flashblocks() { // Flashblock 1: Alice sends to Bob with nonce 0 let tx_nonce_0 = test.build_transaction_to_send_eth_with_nonce(User::Alice, User::Bob, 1000, 0); test.send_flashblock( - FlashblockBuilder::new(&test, 1) - .with_transactions(vec![tx_nonce_0]) - .build(), + FlashblockBuilder::new(&test, 1).with_transactions(vec![tx_nonce_0]).build(), ) .await; // Verify flashblock 1 was processed - Alice's pending nonce should now be 1 let alice_state = test.account_state(User::Alice); - assert_eq!( - alice_state.nonce, 1, - "After flashblock 1, Alice's pending nonce should be 1" - ); + assert_eq!(alice_state.nonce, 1, "After flashblock 1, Alice's pending nonce should be 1"); // Flashblock 2: Alice sends to Charlie with nonce 1 // This will FAIL if the execution layer can't see flashblock 1's state change let tx_nonce_1 = test.build_transaction_to_send_eth_with_nonce(User::Alice, User::Charlie, 2000, 1); test.send_flashblock( - FlashblockBuilder::new(&test, 2) - .with_transactions(vec![tx_nonce_1]) - .build(), + FlashblockBuilder::new(&test, 2).with_transactions(vec![tx_nonce_1]).build(), ) .await; diff --git a/crates/metering/src/tests/meter.rs b/crates/metering/src/tests/meter.rs index 7601938c..b8655fd0 100644 --- a/crates/metering/src/tests/meter.rs +++ b/crates/metering/src/tests/meter.rs @@ -7,8 +7,11 @@ use alloy_primitives::{Address, B256, Bytes, U256, keccak256}; use eyre::Context; use op_alloy_consensus::OpTxEnvelope; use rand::{SeedableRng, rngs::StdRng}; -use reth::revm::db::{BundleState, Cache}; -use reth::{api::NodeTypesWithDBAdapter, chainspec::EthChainSpec}; +use reth::{ + api::NodeTypesWithDBAdapter, + chainspec::EthChainSpec, + revm::db::{BundleState, Cache}, +}; use reth_db::{DatabaseEnv, test_utils::TempDatabase}; use reth_optimism_chainspec::{BASE_MAINNET, OpChainSpec, OpChainSpecBuilder}; use reth_optimism_node::OpNode; @@ -465,4 +468,3 @@ fn meter_bundle_requires_correct_layering_for_pending_nonce() -> eyre::Result<() Ok(()) } -