diff --git a/crates/op-rbuilder/src/args/op.rs b/crates/op-rbuilder/src/args/op.rs index 4dc0663c..310a20fa 100644 --- a/crates/op-rbuilder/src/args/op.rs +++ b/crates/op-rbuilder/src/args/op.rs @@ -51,7 +51,10 @@ pub struct OpRbuilderArgs { /// Whether to enable TIPS Resource Metering #[arg(long = "builder.enable-resource-metering", default_value = "false")] pub enable_resource_metering: bool, - /// Whether to enable TIPS Resource Metering + /// Whether to enforce resource metering limits (reject transactions that exceed limits) + #[arg(long = "builder.enforce-resource-metering", default_value = "false")] + pub enforce_resource_metering: bool, + /// Buffer size for resource metering data #[arg( long = "builder.resource-metering-buffer-size", default_value = "10000" diff --git a/crates/op-rbuilder/src/base/context.rs b/crates/op-rbuilder/src/base/context.rs new file mode 100644 index 00000000..6e340e74 --- /dev/null +++ b/crates/op-rbuilder/src/base/context.rs @@ -0,0 +1,26 @@ +//! Base-specific builder context. + +use super::metrics::BaseMetrics; + +/// Base-specific context for payload building. +/// Add this as a single field to OpPayloadBuilderCtx to minimize diff. +#[derive(Debug, Default, Clone)] +pub struct BaseBuilderCtx { + /// Block execution time limit in microseconds + pub block_execution_time_limit_us: u128, + /// Whether to enforce resource metering limits + pub enforce_limits: bool, + /// Base-specific metrics + pub metrics: BaseMetrics, +} + +impl BaseBuilderCtx { + /// Create a new BaseBuilderCtx with the given execution time limit. + pub fn new(block_execution_time_limit_us: u128, enforce_limits: bool) -> Self { + Self { + block_execution_time_limit_us, + enforce_limits, + metrics: Default::default(), + } + } +} diff --git a/crates/op-rbuilder/src/base/execution.rs b/crates/op-rbuilder/src/base/execution.rs new file mode 100644 index 00000000..5f4b651d --- /dev/null +++ b/crates/op-rbuilder/src/base/execution.rs @@ -0,0 +1,147 @@ +//! Base-specific execution time tracking and limit checking. + +use super::metrics::BaseMetrics; +use crate::resource_metering::ResourceMetering; +use alloy_primitives::TxHash; +use tracing::warn; + +/// Base-specific execution state bundled into one type. +/// Add this as a single field to ExecutionInfo to minimize diff. +#[derive(Debug, Default, Clone)] +pub struct BaseExecutionState { + pub cumulative_execution_time_us: u128, +} + +/// Base-specific transaction usage bundled into one type. +#[derive(Debug, Default, Clone, Copy)] +pub struct BaseTxUsage { + pub execution_time_us: u128, +} + +/// Base-specific block limits bundled into one type. +#[derive(Debug, Clone, Copy)] +pub struct BaseBlockLimits { + pub execution_time_us: u128, +} + +/// Result type for Base-specific limit checks. +#[derive(Debug)] +pub enum BaseLimitExceeded { + ExecutionTime { + tx_hash: TxHash, + cumulative_us: u128, + tx_us: u128, + limit_us: u128, + tx_gas: u64, + remaining_gas: u64, + }, +} + +impl BaseLimitExceeded { + /// Returns the tx usage that caused the limit to be exceeded. + pub fn usage(&self) -> BaseTxUsage { + match self { + Self::ExecutionTime { tx_us, .. } => BaseTxUsage { + execution_time_us: *tx_us, + }, + } + } + + /// Log and record metrics for this limit exceeded event. + /// + /// Only logs/records if this is the first tx to exceed the limit + /// (i.e., cumulative was within the limit before this tx). + pub fn log_and_record(&self, metrics: &BaseMetrics) { + match self { + Self::ExecutionTime { + tx_hash, + cumulative_us, + tx_us, + limit_us, + tx_gas, + remaining_gas, + } => { + // Only log/record for the first tx that exceeds the limit + if *cumulative_us > *limit_us { + return; + } + + let remaining_us = limit_us.saturating_sub(*cumulative_us); + let exceeded_by_us = tx_us.saturating_sub(remaining_us); + warn!( + target: "payload_builder", + %tx_hash, + cumulative_us, + tx_us, + limit_us, + remaining_us, + exceeded_by_us, + tx_gas, + remaining_gas, + "Execution time limit exceeded" + ); + metrics.execution_time_limit_exceeded.increment(1); + metrics.execution_time_limit_tx_us.record(*tx_us as f64); + metrics + .execution_time_limit_remaining_us + .record(remaining_us as f64); + metrics + .execution_time_limit_exceeded_by_us + .record(exceeded_by_us as f64); + metrics.execution_time_limit_tx_gas.record(*tx_gas as f64); + metrics + .execution_time_limit_remaining_gas + .record(*remaining_gas as f64); + } + } + } +} + +impl BaseExecutionState { + /// Check if adding a tx would exceed Base-specific limits. + /// Call this AFTER the upstream is_tx_over_limits(). + /// Returns the usage for later recording via `record_tx`. + pub fn check_tx( + &self, + metering: &ResourceMetering, + tx_hash: &TxHash, + execution_time_limit_us: u128, + tx_gas: u64, + cumulative_gas_used: u64, + block_gas_limit: u64, + ) -> Result { + let usage = BaseTxUsage::from_metering(metering, tx_hash); + let total = self + .cumulative_execution_time_us + .saturating_add(usage.execution_time_us); + + if total > execution_time_limit_us { + let remaining_gas = block_gas_limit.saturating_sub(cumulative_gas_used); + return Err(BaseLimitExceeded::ExecutionTime { + tx_hash: *tx_hash, + cumulative_us: self.cumulative_execution_time_us, + tx_us: usage.execution_time_us, + limit_us: execution_time_limit_us, + tx_gas, + remaining_gas, + }); + } + Ok(usage) + } + + /// Record that a transaction was included. + pub fn record_tx(&mut self, usage: &BaseTxUsage) { + self.cumulative_execution_time_us += usage.execution_time_us; + } +} + +impl BaseTxUsage { + /// Get tx execution time from resource metering. + pub fn from_metering(metering: &ResourceMetering, tx_hash: &TxHash) -> Self { + let execution_time_us = metering + .get(tx_hash) + .map(|r| r.total_execution_time_us) + .unwrap_or(0); + Self { execution_time_us } + } +} diff --git a/crates/op-rbuilder/src/base/flashblocks.rs b/crates/op-rbuilder/src/base/flashblocks.rs new file mode 100644 index 00000000..a58a9923 --- /dev/null +++ b/crates/op-rbuilder/src/base/flashblocks.rs @@ -0,0 +1,43 @@ +//! Base-specific flashblocks context. + +use super::context::BaseBuilderCtx; + +/// Base-specific flashblocks context for per-batch execution time tracking. +/// Add this as a single field to FlashblocksExtraCtx to minimize diff. +#[derive(Debug, Default, Clone, Copy)] +pub struct BaseFlashblocksCtx { + /// Total execution time (us) limit for the current flashblock batch + pub target_execution_time_us: u128, + /// Execution time (us) limit per flashblock batch + pub execution_time_per_batch_us: u128, + /// Whether to enforce resource metering limits + pub enforce_limits: bool, +} + +impl BaseFlashblocksCtx { + /// Create a new BaseFlashblocksCtx with the given execution time limit per batch. + pub fn new(execution_time_per_batch_us: u128, enforce_limits: bool) -> Self { + Self { + target_execution_time_us: execution_time_per_batch_us, + execution_time_per_batch_us, + enforce_limits, + } + } + + /// Advance to the next batch, updating the target execution time. + /// + /// Unlike gas and DA, execution time does not carry over to the next batch. + pub fn next(self, cumulative_execution_time_us: u128) -> Self { + Self { + target_execution_time_us: cumulative_execution_time_us + + self.execution_time_per_batch_us, + ..self + } + } +} + +impl From<&BaseFlashblocksCtx> for BaseBuilderCtx { + fn from(ctx: &BaseFlashblocksCtx) -> Self { + BaseBuilderCtx::new(ctx.target_execution_time_us, ctx.enforce_limits) + } +} diff --git a/crates/op-rbuilder/src/base/metrics.rs b/crates/op-rbuilder/src/base/metrics.rs new file mode 100644 index 00000000..2df21a1c --- /dev/null +++ b/crates/op-rbuilder/src/base/metrics.rs @@ -0,0 +1,24 @@ +//! Base-specific metrics. + +use reth_metrics::{ + Metrics, + metrics::{Counter, Histogram}, +}; + +/// Base-specific metrics for resource metering. +#[derive(Metrics, Clone)] +#[metrics(scope = "op_rbuilder_base")] +pub struct BaseMetrics { + /// Count of transactions excluded due to execution time limit + pub execution_time_limit_exceeded: Counter, + /// Histogram of tx execution time (us) that caused the limit to be exceeded + pub execution_time_limit_tx_us: Histogram, + /// Histogram of remaining execution time (us) when a tx was excluded + pub execution_time_limit_remaining_us: Histogram, + /// Histogram of how much the tx exceeded the remaining time (us) + pub execution_time_limit_exceeded_by_us: Histogram, + /// Histogram of tx gas limit when excluded due to execution time limit + pub execution_time_limit_tx_gas: Histogram, + /// Histogram of remaining gas when excluded due to execution time limit + pub execution_time_limit_remaining_gas: Histogram, +} diff --git a/crates/op-rbuilder/src/base/mod.rs b/crates/op-rbuilder/src/base/mod.rs new file mode 100644 index 00000000..e4957eb4 --- /dev/null +++ b/crates/op-rbuilder/src/base/mod.rs @@ -0,0 +1,4 @@ +pub mod context; +pub mod execution; +pub mod flashblocks; +pub mod metrics; diff --git a/crates/op-rbuilder/src/builders/context.rs b/crates/op-rbuilder/src/builders/context.rs index 1e0f22da..24fd85a1 100644 --- a/crates/op-rbuilder/src/builders/context.rs +++ b/crates/op-rbuilder/src/builders/context.rs @@ -40,6 +40,7 @@ use tokio_util::sync::CancellationToken; use tracing::{debug, info, trace}; use crate::{ + base::context::BaseBuilderCtx, gas_limiter::AddressGasLimiter, metrics::OpRBuilderMetrics, primitives::reth::{ExecutionInfo, TxnExecutionResult}, @@ -80,6 +81,8 @@ pub struct OpPayloadBuilderCtx { pub address_gas_limiter: AddressGasLimiter, /// Per transaction resource metering information pub resource_metering: ResourceMetering, + /// Base-specific builder context + pub base_ctx: BaseBuilderCtx, } impl OpPayloadBuilderCtx { @@ -381,6 +384,7 @@ impl OpPayloadBuilderCtx { /// Executes the given best transactions and updates the execution info. /// /// Returns `Ok(Some(())` if the job was cancelled. + #[allow(clippy::too_many_arguments)] pub(super) fn execute_best_transactions( &self, info: &mut ExecutionInfo, @@ -389,6 +393,7 @@ impl OpPayloadBuilderCtx { block_gas_limit: u64, block_da_limit: Option, block_da_footprint_limit: Option, + base_ctx: &BaseBuilderCtx, ) -> Result, PayloadBuilderError> { let execute_txs_start_time = Instant::now(); let mut num_txs_considered = 0; @@ -445,8 +450,6 @@ impl OpPayloadBuilderCtx { num_txs_considered += 1; - let _resource_usage = self.resource_metering.get(&tx_hash); - // TODO: ideally we should get this from the txpool stream if let Some(conditional) = conditional && !conditional.matches_block_attributes(&block_attr) @@ -486,6 +489,26 @@ impl OpPayloadBuilderCtx { continue; } + // Base addition: execution time limit check + let base_usage = match info.base_state.check_tx( + &self.resource_metering, + &tx_hash, + base_ctx.block_execution_time_limit_us, + tx.gas_limit(), + info.cumulative_gas_used, + block_gas_limit, + ) { + Ok(usage) => usage, + Err(exceeded) => { + exceeded.log_and_record(&self.base_ctx.metrics); + if self.base_ctx.enforce_limits { + best_txs.mark_invalid(tx.signer(), tx.nonce()); + continue; + } + exceeded.usage() + } + }; + // A sequencer's block should never contain blob or deposit transactions from the pool. if tx.is_eip4844() || tx.is_deposit() { log_txn(TxnExecutionResult::SequencerTransaction); @@ -577,6 +600,8 @@ impl OpPayloadBuilderCtx { info.cumulative_gas_used += gas_used; // record tx da size info.cumulative_da_bytes_used += tx_da_size; + // record Base-specific tx execution time + info.base_state.record_tx(&base_usage); // Push transaction changeset and calculate header bloom filter for receipt. let ctx = ReceiptBuilderCtx { diff --git a/crates/op-rbuilder/src/builders/flashblocks/ctx.rs b/crates/op-rbuilder/src/builders/flashblocks/ctx.rs index 28cbae76..7a4b0ebd 100644 --- a/crates/op-rbuilder/src/builders/flashblocks/ctx.rs +++ b/crates/op-rbuilder/src/builders/flashblocks/ctx.rs @@ -1,4 +1,5 @@ use crate::{ + base::context::BaseBuilderCtx, builders::{BuilderConfig, OpPayloadBuilderCtx, flashblocks::FlashblocksConfig}, gas_limiter::{AddressGasLimiter, args::GasLimiterArgs}, metrics::OpRBuilderMetrics, @@ -32,6 +33,8 @@ pub(super) struct OpPayloadSyncerCtx { metrics: Arc, /// Resource metering tracking resource_metering: ResourceMetering, + /// Base-specific builder context + base_ctx: BaseBuilderCtx, } impl OpPayloadSyncerCtx { @@ -52,6 +55,10 @@ impl OpPayloadSyncerCtx { max_gas_per_txn: builder_config.max_gas_per_txn, metrics, resource_metering: builder_config.resource_metering, + base_ctx: BaseBuilderCtx::new( + builder_config.block_time.as_micros(), + builder_config.enforce_resource_metering, + ), }) } @@ -85,6 +92,7 @@ impl OpPayloadSyncerCtx { max_gas_per_txn: self.max_gas_per_txn, address_gas_limiter: AddressGasLimiter::new(GasLimiterArgs::default()), resource_metering: self.resource_metering.clone(), + base_ctx: self.base_ctx.clone(), } } } diff --git a/crates/op-rbuilder/src/builders/flashblocks/payload.rs b/crates/op-rbuilder/src/builders/flashblocks/payload.rs index 35e16834..eb5aad1c 100644 --- a/crates/op-rbuilder/src/builders/flashblocks/payload.rs +++ b/crates/op-rbuilder/src/builders/flashblocks/payload.rs @@ -1,5 +1,6 @@ use super::{config::FlashblocksConfig, wspub::WebSocketPublisher}; use crate::{ + base::{context::BaseBuilderCtx, flashblocks::BaseFlashblocksCtx}, builders::{ BuilderConfig, builder_tx::BuilderTransactions, @@ -94,6 +95,8 @@ pub struct FlashblocksExtraCtx { da_footprint_per_batch: Option, /// Whether to disable state root calculation for each flashblock disable_state_root: bool, + /// Base-specific flashblocks context + base_ctx: BaseFlashblocksCtx, } impl FlashblocksExtraCtx { @@ -102,12 +105,14 @@ impl FlashblocksExtraCtx { target_gas_for_batch: u64, target_da_for_batch: Option, target_da_footprint_for_batch: Option, + base_ctx: BaseFlashblocksCtx, ) -> Self { Self { flashblock_index: self.flashblock_index + 1, target_gas_for_batch, target_da_for_batch, target_da_footprint_for_batch, + base_ctx, ..self } } @@ -283,6 +288,10 @@ where max_gas_per_txn: self.config.max_gas_per_txn, address_gas_limiter: self.address_gas_limiter.clone(), resource_metering: self.config.resource_metering.clone(), + base_ctx: BaseBuilderCtx::new( + self.config.block_time.as_micros(), + self.config.enforce_resource_metering, + ), }) } @@ -465,6 +474,10 @@ where da_footprint_per_batch, disable_state_root, target_da_footprint_for_batch: da_footprint_per_batch, + base_ctx: BaseFlashblocksCtx::new( + self.config.specific.interval.as_micros(), + self.config.enforce_resource_metering, + ), }; let mut fb_cancel = block_cancel.child_token(); @@ -688,6 +701,7 @@ where target_gas_for_batch.min(ctx.block_gas_limit()), target_da_for_batch, target_da_footprint_for_batch, + &(&ctx.extra_ctx.base_ctx).into(), ) .wrap_err("failed to execute best transactions")?; // Extract last transactions @@ -804,10 +818,17 @@ where *footprint += da_footprint_limit; } + // Base addition: execution time does not carry over to the next batch + let next_base_ctx = ctx + .extra_ctx + .base_ctx + .next(info.base_state.cumulative_execution_time_us); + let next_extra_ctx = ctx.extra_ctx.clone().next( target_gas_for_batch, target_da_for_batch, target_da_footprint_for_batch, + next_base_ctx, ); info!( diff --git a/crates/op-rbuilder/src/builders/mod.rs b/crates/op-rbuilder/src/builders/mod.rs index 48ce625b..2d6f32a0 100644 --- a/crates/op-rbuilder/src/builders/mod.rs +++ b/crates/op-rbuilder/src/builders/mod.rs @@ -130,6 +130,9 @@ pub struct BuilderConfig { /// Resource metering context pub resource_metering: ResourceMetering, + + /// Whether to enforce resource metering limits + pub enforce_resource_metering: bool, } impl core::fmt::Debug for BuilderConfig { @@ -171,6 +174,7 @@ impl Default for BuilderConfig { max_gas_per_txn: None, gas_limiter_config: GasLimiterArgs::default(), resource_metering: ResourceMetering::default(), + enforce_resource_metering: false, } } } @@ -197,6 +201,7 @@ where args.enable_resource_metering, args.resource_metering_buffer_size, ), + enforce_resource_metering: args.enforce_resource_metering, specific: S::try_from(args)?, }) } diff --git a/crates/op-rbuilder/src/builders/standard/payload.rs b/crates/op-rbuilder/src/builders/standard/payload.rs index d9a74add..3c9bdb4c 100644 --- a/crates/op-rbuilder/src/builders/standard/payload.rs +++ b/crates/op-rbuilder/src/builders/standard/payload.rs @@ -1,5 +1,6 @@ use super::super::context::OpPayloadBuilderCtx; use crate::{ + base::context::BaseBuilderCtx, builders::{BuilderConfig, BuilderTransactions, generator::BuildArguments}, gas_limiter::AddressGasLimiter, metrics::OpRBuilderMetrics, @@ -252,6 +253,10 @@ where max_gas_per_txn: self.config.max_gas_per_txn, address_gas_limiter: self.address_gas_limiter.clone(), resource_metering: self.config.resource_metering.clone(), + base_ctx: BaseBuilderCtx::new( + self.config.block_time.as_micros(), + self.config.enforce_resource_metering, + ), }; let builder = OpBuilder::new(best); @@ -415,6 +420,7 @@ impl OpBuilder<'_, Txs> { block_gas_limit, block_da_limit, block_da_footprint, + &ctx.base_ctx, )? .is_some() { diff --git a/crates/op-rbuilder/src/lib.rs b/crates/op-rbuilder/src/lib.rs index f61c39b0..3a0b5062 100644 --- a/crates/op-rbuilder/src/lib.rs +++ b/crates/op-rbuilder/src/lib.rs @@ -11,6 +11,7 @@ pub mod traits; pub mod tx; pub mod tx_signer; +pub mod base; #[cfg(test)] pub mod mock_tx; mod resource_metering; diff --git a/crates/op-rbuilder/src/primitives/reth/execution.rs b/crates/op-rbuilder/src/primitives/reth/execution.rs index 7865a1c8..b55428bc 100644 --- a/crates/op-rbuilder/src/primitives/reth/execution.rs +++ b/crates/op-rbuilder/src/primitives/reth/execution.rs @@ -1,4 +1,5 @@ //! Heavily influenced by [reth](https://github.com/paradigmxyz/reth/blob/1e965caf5fa176f244a31c0d2662ba1b590938db/crates/optimism/payload/src/builder.rs#L570) +use crate::base::execution::BaseExecutionState; use alloy_primitives::{Address, U256}; use core::fmt::Debug; use derive_more::Display; @@ -42,6 +43,8 @@ pub struct ExecutionInfo { pub extra: Extra, /// DA Footprint Scalar for Jovian pub da_footprint_scalar: Option, + /// Base-specific execution state + pub base_state: BaseExecutionState, } impl ExecutionInfo { @@ -56,6 +59,7 @@ impl ExecutionInfo { total_fees: U256::ZERO, extra: Default::default(), da_footprint_scalar: None, + base_state: BaseExecutionState::default(), } } diff --git a/crates/op-rbuilder/src/tests/base/mod.rs b/crates/op-rbuilder/src/tests/base/mod.rs new file mode 100644 index 00000000..c354363d --- /dev/null +++ b/crates/op-rbuilder/src/tests/base/mod.rs @@ -0,0 +1,2 @@ +#[cfg(test)] +mod resource_metering; diff --git a/crates/op-rbuilder/src/tests/base/resource_metering.rs b/crates/op-rbuilder/src/tests/base/resource_metering.rs new file mode 100644 index 00000000..fdced178 --- /dev/null +++ b/crates/op-rbuilder/src/tests/base/resource_metering.rs @@ -0,0 +1,289 @@ +use crate::{ + args::OpRbuilderArgs, + tests::{BlockTransactionsExt, ChainDriver, FlashblocksListener, Ipc, LocalInstance}, +}; +use alloy_primitives::{B256, TxHash, U256}; +use alloy_provider::{Provider, RootProvider}; +use macros::rb_test; +use op_alloy_network::Optimism; +use tips_core::MeterBundleResponse; +use tokio::time::{Duration, sleep}; + +type TestDriver = ChainDriver; + +const EXECUTION_LIMIT_MS: u64 = 200; + +#[rb_test(args = { + let mut args = OpRbuilderArgs::default(); + args.chain_block_time = EXECUTION_LIMIT_MS; + args.enable_resource_metering = true; + args.enforce_resource_metering = true; + args.flashblocks.flashblocks_block_time = EXECUTION_LIMIT_MS; + args +})] +async fn execution_time_limit_rejects_excessive_transactions( + rbuilder: LocalInstance, +) -> eyre::Result<()> { + let driver = rbuilder.driver().await?; + enable_metering(driver.provider()).await?; + + let first = send_metered_tx(&driver, 120_000).await?; + let second = send_metered_tx(&driver, 120_000).await?; + + let block = driver.build_new_block().await?; + assert!( + block.includes(&first), + "first transaction should be included before budget is exhausted" + ); + assert!( + !block.includes(&second), + "second transaction should be excluded once the execution budget is exceeded" + ); + + Ok(()) +} + +/// When enforce_resource_metering is false (the default), transactions that exceed +/// the execution time limit should still be included - only logging/metrics occur. +#[rb_test(args = { + let mut args = OpRbuilderArgs::default(); + args.chain_block_time = EXECUTION_LIMIT_MS; + args.enable_resource_metering = true; + args.enforce_resource_metering = false; + args.flashblocks.flashblocks_block_time = EXECUTION_LIMIT_MS; + args +})] +async fn non_enforcing_mode_includes_all_transactions(rbuilder: LocalInstance) -> eyre::Result<()> { + let driver = rbuilder.driver().await?; + enable_metering(driver.provider()).await?; + + // Both transactions exceed the limit together (120k + 120k > 200k) + let first = send_metered_tx(&driver, 120_000).await?; + let second = send_metered_tx(&driver, 120_000).await?; + + let block = driver.build_new_block().await?; + assert!( + block.includes(&first), + "first transaction should be included" + ); + assert!( + block.includes(&second), + "second transaction should be included when not enforcing limits" + ); + + Ok(()) +} + +#[rb_test(args = { + let mut args = OpRbuilderArgs::default(); + args.chain_block_time = EXECUTION_LIMIT_MS; + args.enable_resource_metering = true; + args.flashblocks.flashblocks_block_time = EXECUTION_LIMIT_MS; + args +})] +async fn execution_time_budget_resets_each_block(rbuilder: LocalInstance) -> eyre::Result<()> { + let driver = rbuilder.driver().await?; + enable_metering(driver.provider()).await?; + + let first = send_metered_tx(&driver, 150_000).await?; + let first_block = driver.build_new_block().await?; + assert!( + first_block.includes(&first), + "transaction should be included while under the execution budget" + ); + + let second = send_metered_tx(&driver, 150_000).await?; + let second_block = driver.build_new_block().await?; + assert!( + second_block.includes(&second), + "execution budget should reset between blocks" + ); + + Ok(()) +} + +#[rb_test(args = { + let mut args = OpRbuilderArgs::default(); + args.chain_block_time = EXECUTION_LIMIT_MS; + args.enable_resource_metering = true; + args.flashblocks.flashblocks_block_time = EXECUTION_LIMIT_MS; + args +})] +async fn missing_metering_information_defaults_to_zero( + rbuilder: LocalInstance, +) -> eyre::Result<()> { + let driver = rbuilder.driver().await?; + enable_metering(driver.provider()).await?; + + let unmetered = send_unmetered_tx(&driver).await?; + let metered = send_metered_tx(&driver, 180_000).await?; + + let block = driver.build_new_block().await?; + assert!( + block.includes(&unmetered), + "transactions without metering info should still be included" + ); + assert!( + block.includes(&metered), + "execution budget should account only for metered time" + ); + + Ok(()) +} + +/// When enforce_resource_metering is false in flashblocks mode, transactions that exceed +/// the per-batch execution time limit should still be included in the same flashblock. +#[rb_test( + flashblocks, + args = { + let mut args = OpRbuilderArgs::default(); + args.chain_block_time = EXECUTION_LIMIT_MS; + args.enable_resource_metering = true; + args.enforce_resource_metering = false; + args.flashblocks.flashblocks_block_time = EXECUTION_LIMIT_MS / 2; + args + } +)] +async fn flashblock_non_enforcing_mode_includes_all_in_same_batch( + rbuilder: LocalInstance, +) -> eyre::Result<()> { + let listener = rbuilder.spawn_flashblocks_listener(); + let driver = rbuilder.driver().await?; + enable_metering(driver.provider()).await?; + + // Both transactions exceed the per-batch limit together (60k + 60k > 100k) + let first = send_metered_tx(&driver, 60_000).await?; + let second = send_metered_tx(&driver, 60_000).await?; + + let block = driver.build_new_block().await?; + assert!( + block.includes(&first), + "first transaction should be included" + ); + assert!( + block.includes(&second), + "second transaction should be included when not enforcing limits" + ); + + // Both should land in the same flashblock when not enforcing + let first_fb = wait_for_flashblock(&listener, &first).await?; + let second_fb = wait_for_flashblock(&listener, &second).await?; + assert_eq!( + first_fb, second_fb, + "both txs should be in the same flashblock when not enforcing limits" + ); + + listener.stop().await?; + Ok(()) +} + +#[rb_test( + flashblocks, + args = { + let mut args = OpRbuilderArgs::default(); + args.chain_block_time = EXECUTION_LIMIT_MS; + args.enable_resource_metering = true; + args.enforce_resource_metering = true; + args.flashblocks.flashblocks_block_time = EXECUTION_LIMIT_MS / 2; + args + } +)] +async fn flashblock_execution_time_limit_enforced_per_batch( + rbuilder: LocalInstance, +) -> eyre::Result<()> { + let listener = rbuilder.spawn_flashblocks_listener(); + let driver = rbuilder.driver().await?; + enable_metering(driver.provider()).await?; + + let first = send_metered_tx(&driver, 60_000).await?; + let second = send_metered_tx(&driver, 60_000).await?; + + let block = driver.build_new_block().await?; + assert!( + block.includes(&first), + "first transaction should be included before the per-batch budget is exhausted" + ); + assert!( + block.includes(&second), + "second transaction should still be included once a new flashblock begins" + ); + + let first_fb = wait_for_flashblock(&listener, &first).await?; + assert_eq!(first_fb, 1, "first tx should land in the first flashblock"); + assert!( + wait_for_flashblock(&listener, &second).await? > first_fb, + "second tx should spill over into a later flashblock once the first budget is exhausted" + ); + + listener.stop().await?; + Ok(()) +} + +async fn send_metered_tx(driver: &TestDriver, execution_time_us: u128) -> eyre::Result { + let pending = driver + .create_transaction() + .with_max_priority_fee_per_gas(100) + .send() + .await?; + let tx_hash = *pending.tx_hash(); + set_metering_information(driver.provider(), tx_hash, execution_time_us).await?; + Ok(tx_hash) +} + +async fn send_unmetered_tx(driver: &TestDriver) -> eyre::Result { + let pending = driver + .create_transaction() + .with_max_priority_fee_per_gas(100) + .send() + .await?; + Ok(*pending.tx_hash()) +} + +async fn enable_metering(provider: &RootProvider) -> eyre::Result<()> { + provider + .raw_request::<(bool,), ()>("base_setMeteringEnabled".into(), (true,)) + .await?; + Ok(()) +} + +async fn set_metering_information( + provider: &RootProvider, + tx_hash: TxHash, + execution_time_us: u128, +) -> eyre::Result<()> { + provider + .raw_request::<(TxHash, MeterBundleResponse), ()>( + "base_setMeteringInformation".into(), + (tx_hash, metering_response(execution_time_us)), + ) + .await?; + Ok(()) +} + +fn metering_response(execution_time_us: u128) -> MeterBundleResponse { + MeterBundleResponse { + bundle_hash: B256::random(), + bundle_gas_price: U256::from(1), + coinbase_diff: U256::ZERO, + eth_sent_to_coinbase: U256::ZERO, + gas_fees: U256::ZERO, + results: vec![], + state_block_number: 0, + state_flashblock_index: None, + total_gas_used: 21_000, + total_execution_time_us: execution_time_us, + } +} + +async fn wait_for_flashblock( + listener: &FlashblocksListener, + tx_hash: &TxHash, +) -> eyre::Result { + for _ in 0..80 { + if let Some(index) = listener.find_transaction_flashblock(tx_hash) { + return Ok(index); + } + sleep(Duration::from_millis(50)).await; + } + eyre::bail!("transaction {tx_hash:?} was not observed in any flashblock"); +} diff --git a/crates/op-rbuilder/src/tests/framework/instance.rs b/crates/op-rbuilder/src/tests/framework/instance.rs index 8c718491..5f6f7925 100644 --- a/crates/op-rbuilder/src/tests/framework/instance.rs +++ b/crates/op-rbuilder/src/tests/framework/instance.rs @@ -2,6 +2,7 @@ use crate::{ args::OpRbuilderArgs, builders::{BuilderConfig, FlashblocksBuilder, PayloadBuilder, StandardBuilder}, primitives::reth::engine_api_builder::OpEngineApiBuilder, + resource_metering::{BaseApiExtServer, ResourceMeteringExt}, revert_protection::{EthApiExtServer, RevertProtectionExt}, tests::{ EngineApi, Ipc, TEE_DEBUG_ADDRESS, TransactionPoolObserver, builder_signer, create_test_db, @@ -110,6 +111,7 @@ impl LocalInstance { let builder_config = BuilderConfig::::try_from(args.clone()) .expect("Failed to convert rollup args to builder config"); + let resource_metering = builder_config.resource_metering.clone(); let da_config = builder_config.da_config.clone(); let gas_limit_config = builder_config.gas_limit_config.clone(); @@ -153,6 +155,10 @@ impl LocalInstance { .add_or_replace_configured(revert_protection_ext.into_rpc())?; } + let resource_metering_ext = ResourceMeteringExt::new(resource_metering.clone()); + ctx.modules + .add_or_replace_configured(resource_metering_ext.into_rpc())?; + Ok(()) }) .on_rpc_started(move |_, _| { diff --git a/crates/op-rbuilder/src/tests/mod.rs b/crates/op-rbuilder/src/tests/mod.rs index fd202a89..bb576872 100644 --- a/crates/op-rbuilder/src/tests/mod.rs +++ b/crates/op-rbuilder/src/tests/mod.rs @@ -23,6 +23,9 @@ mod ordering; #[cfg(test)] mod revert; +#[cfg(test)] +mod base; + #[cfg(test)] mod smoke;