From 7d4dcd74178fecafafc2ef791a2a54731436d70d Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Thu, 20 Nov 2025 23:27:43 -0600 Subject: [PATCH 01/14] feat: add execution time limits to resource metering --- crates/op-rbuilder/src/builders/context.rs | 13 +++++++++- .../src/builders/flashblocks/ctx.rs | 4 ++++ .../src/builders/flashblocks/payload.rs | 7 ++++++ .../src/builders/standard/payload.rs | 2 ++ .../src/primitives/reth/execution.rs | 24 +++++++++++++++++++ 5 files changed, 49 insertions(+), 1 deletion(-) diff --git a/crates/op-rbuilder/src/builders/context.rs b/crates/op-rbuilder/src/builders/context.rs index 1e0f22da..91f0122c 100644 --- a/crates/op-rbuilder/src/builders/context.rs +++ b/crates/op-rbuilder/src/builders/context.rs @@ -80,6 +80,8 @@ pub struct OpPayloadBuilderCtx { pub address_gas_limiter: AddressGasLimiter, /// Per transaction resource metering information pub resource_metering: ResourceMetering, + /// Block execution time limit in microseconds + pub block_execution_time_limit_us: u128, } impl OpPayloadBuilderCtx { @@ -389,6 +391,7 @@ impl OpPayloadBuilderCtx { block_gas_limit: u64, block_da_limit: Option, block_da_footprint_limit: Option, + block_execution_time_limit_us: u128, ) -> Result, PayloadBuilderError> { let execute_txs_start_time = Instant::now(); let mut num_txs_considered = 0; @@ -445,7 +448,11 @@ impl OpPayloadBuilderCtx { num_txs_considered += 1; - let _resource_usage = self.resource_metering.get(&tx_hash); + let resource_usage = self.resource_metering.get(&tx_hash); + let tx_execution_time_us = resource_usage + .as_ref() + .map(|r| r.total_execution_time_us) + .unwrap_or(0); // TODO: ideally we should get this from the txpool stream if let Some(conditional) = conditional @@ -477,6 +484,8 @@ impl OpPayloadBuilderCtx { tx.gas_limit(), info.da_footprint_scalar, block_da_footprint_limit, + tx_execution_time_us, + block_execution_time_limit_us, ) { // we can't fit this transaction into the block, so we need to mark it as // invalid which also removes all dependent transaction from @@ -577,6 +586,8 @@ impl OpPayloadBuilderCtx { info.cumulative_gas_used += gas_used; // record tx da size info.cumulative_da_bytes_used += tx_da_size; + // record tx execution time + info.cumulative_execution_time_us += tx_execution_time_us; // 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..32b4e4e8 100644 --- a/crates/op-rbuilder/src/builders/flashblocks/ctx.rs +++ b/crates/op-rbuilder/src/builders/flashblocks/ctx.rs @@ -32,6 +32,8 @@ pub(super) struct OpPayloadSyncerCtx { metrics: Arc, /// Resource metering tracking resource_metering: ResourceMetering, + /// Block execution time limit in microseconds + block_execution_time_limit_us: u128, } impl OpPayloadSyncerCtx { @@ -52,6 +54,7 @@ impl OpPayloadSyncerCtx { max_gas_per_txn: builder_config.max_gas_per_txn, metrics, resource_metering: builder_config.resource_metering, + block_execution_time_limit_us: builder_config.block_time.as_micros(), }) } @@ -85,6 +88,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(), + block_execution_time_limit_us: self.block_execution_time_limit_us, } } } diff --git a/crates/op-rbuilder/src/builders/flashblocks/payload.rs b/crates/op-rbuilder/src/builders/flashblocks/payload.rs index 35e16834..8b9dc5d7 100644 --- a/crates/op-rbuilder/src/builders/flashblocks/payload.rs +++ b/crates/op-rbuilder/src/builders/flashblocks/payload.rs @@ -92,6 +92,8 @@ pub struct FlashblocksExtraCtx { da_per_batch: Option, /// DA footprint limit per flashblock da_footprint_per_batch: Option, + /// Execution time (us) limit per flashblock + execution_time_per_batch_us: u128, /// Whether to disable state root calculation for each flashblock disable_state_root: bool, } @@ -283,6 +285,7 @@ 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(), + block_execution_time_limit_us: self.config.block_time.as_micros(), }) } @@ -442,6 +445,8 @@ where .da_config .max_da_block_size() .map(|da_limit| da_limit / flashblocks_per_block); + // Use flashblock interval as the execution time limit per flashblock (in microseconds) + let execution_time_per_batch_us = self.config.specific.interval.as_micros(); // Check that builder tx won't affect fb limit too much if let Some(da_limit) = da_per_batch { // We error if we can't insert any tx aside from builder tx in flashblock @@ -463,6 +468,7 @@ where gas_per_batch, da_per_batch, da_footprint_per_batch, + execution_time_per_batch_us, disable_state_root, target_da_footprint_for_batch: da_footprint_per_batch, }; @@ -688,6 +694,7 @@ where target_gas_for_batch.min(ctx.block_gas_limit()), target_da_for_batch, target_da_footprint_for_batch, + ctx.extra_ctx.execution_time_per_batch_us, ) .wrap_err("failed to execute best transactions")?; // Extract last transactions diff --git a/crates/op-rbuilder/src/builders/standard/payload.rs b/crates/op-rbuilder/src/builders/standard/payload.rs index d9a74add..79626792 100644 --- a/crates/op-rbuilder/src/builders/standard/payload.rs +++ b/crates/op-rbuilder/src/builders/standard/payload.rs @@ -252,6 +252,7 @@ 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(), + block_execution_time_limit_us: self.config.block_time.as_micros(), }; let builder = OpBuilder::new(best); @@ -415,6 +416,7 @@ impl OpBuilder<'_, Txs> { block_gas_limit, block_da_limit, block_da_footprint, + ctx.block_execution_time_limit_us, )? .is_some() { diff --git a/crates/op-rbuilder/src/primitives/reth/execution.rs b/crates/op-rbuilder/src/primitives/reth/execution.rs index 7865a1c8..92682907 100644 --- a/crates/op-rbuilder/src/primitives/reth/execution.rs +++ b/crates/op-rbuilder/src/primitives/reth/execution.rs @@ -12,6 +12,10 @@ pub enum TxnExecutionResult { BlockDALimitExceeded(u64, u64, u64), #[display("TransactionGasLimitExceeded: total_gas_used={_0} tx_gas_limit={_1}")] TransactionGasLimitExceeded(u64, u64, u64), + #[display( + "BlockExecutionTimeLimitExceeded: total_time_us={_0} tx_time_us={_1} block_time_limit_us={_2}" + )] + BlockExecutionTimeLimitExceeded(u128, u128, u128), SequencerTransaction, NonceTooLow, InteropFailed, @@ -42,6 +46,8 @@ pub struct ExecutionInfo { pub extra: Extra, /// DA Footprint Scalar for Jovian pub da_footprint_scalar: Option, + /// Cumulative execution time in microseconds + pub cumulative_execution_time_us: u128, } impl ExecutionInfo { @@ -56,6 +62,7 @@ impl ExecutionInfo { total_fees: U256::ZERO, extra: Default::default(), da_footprint_scalar: None, + cumulative_execution_time_us: 0, } } @@ -65,6 +72,8 @@ impl ExecutionInfo { /// per tx. /// - block DA limit: if configured, ensures the transaction's DA size does not exceed the /// maximum allowed DA limit per block. + /// - block execution time limit: if configured, ensures the transaction's execution time does + /// not exceed the maximum allowed execution time per block. #[allow(clippy::too_many_arguments)] pub fn is_tx_over_limits( &self, @@ -75,6 +84,8 @@ impl ExecutionInfo { tx_gas_limit: u64, da_footprint_gas_scalar: Option, block_da_footprint_limit: Option, + tx_execution_time_us: u128, + block_execution_time_limit_us: u128, ) -> Result<(), TxnExecutionResult> { if tx_data_limit.is_some_and(|da_limit| tx_da_size > da_limit) { return Err(TxnExecutionResult::TransactionDALimitExceeded); @@ -108,6 +119,19 @@ impl ExecutionInfo { block_gas_limit, )); } + + // Check block execution time limit + let total_execution_time_us = self + .cumulative_execution_time_us + .saturating_add(tx_execution_time_us); + if total_execution_time_us > block_execution_time_limit_us { + return Err(TxnExecutionResult::BlockExecutionTimeLimitExceeded( + self.cumulative_execution_time_us, + tx_execution_time_us, + block_execution_time_limit_us, + )); + } + Ok(()) } } From ad848abebd5ff8884244e69ad8d6435edc8b93fa Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Fri, 21 Nov 2025 01:26:23 -0600 Subject: [PATCH 02/14] feat: propagate execution time budget to flashblocks --- .../src/builders/flashblocks/payload.rs | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/op-rbuilder/src/builders/flashblocks/payload.rs b/crates/op-rbuilder/src/builders/flashblocks/payload.rs index 8b9dc5d7..ff634bd5 100644 --- a/crates/op-rbuilder/src/builders/flashblocks/payload.rs +++ b/crates/op-rbuilder/src/builders/flashblocks/payload.rs @@ -86,6 +86,8 @@ pub struct FlashblocksExtraCtx { target_da_for_batch: Option, /// Total DA footprint left for the current flashblock target_da_footprint_for_batch: Option, + /// Total execution time (us) left for the current flashblock + target_execution_time_per_batch_us: u128, /// Gas limit per flashblock gas_per_batch: u64, /// DA bytes limit per flashblock @@ -104,12 +106,14 @@ impl FlashblocksExtraCtx { target_gas_for_batch: u64, target_da_for_batch: Option, target_da_footprint_for_batch: Option, + target_execution_time_per_batch_us: u128, ) -> Self { Self { flashblock_index: self.flashblock_index + 1, target_gas_for_batch, target_da_for_batch, target_da_footprint_for_batch, + target_execution_time_per_batch_us, ..self } } @@ -465,12 +469,13 @@ where target_flashblock_count: flashblocks_per_block, target_gas_for_batch: gas_per_batch, target_da_for_batch: da_per_batch, + target_da_footprint_for_batch: da_footprint_per_batch, + target_execution_time_per_batch_us: execution_time_per_batch_us, gas_per_batch, da_per_batch, da_footprint_per_batch, execution_time_per_batch_us, disable_state_root, - target_da_footprint_for_batch: da_footprint_per_batch, }; let mut fb_cancel = block_cancel.child_token(); @@ -694,7 +699,7 @@ where target_gas_for_batch.min(ctx.block_gas_limit()), target_da_for_batch, target_da_footprint_for_batch, - ctx.extra_ctx.execution_time_per_batch_us, + ctx.extra_ctx.target_execution_time_per_batch_us, ) .wrap_err("failed to execute best transactions")?; // Extract last transactions @@ -801,6 +806,9 @@ where } } + // Any unused gas carries over to the next batch. The total + // gas used for the block after the next flashblock can be + // up to the total gas for allocated for each batch so far. let target_gas_for_batch = ctx.extra_ctx.target_gas_for_batch + ctx.extra_ctx.gas_per_batch; @@ -811,10 +819,18 @@ where *footprint += da_footprint_limit; } + // Any unused execution time *does not* carry over to the next + // batch. The total execution time for the block after the next + // flashblock can only be up to the execution time *used* so far + // plus its own limit. + let target_execution_time_per_batch_us = + info.cumulative_execution_time_us + ctx.extra_ctx.execution_time_per_batch_us; + let next_extra_ctx = ctx.extra_ctx.clone().next( target_gas_for_batch, target_da_for_batch, target_da_footprint_for_batch, + target_execution_time_per_batch_us, ); info!( From ad408fc7cbb3c0f261af0079e4adfaa195b0c866 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Fri, 21 Nov 2025 01:29:28 -0600 Subject: [PATCH 03/14] test: integration coverage for execution-time limits --- .../src/tests/framework/instance.rs | 6 + crates/op-rbuilder/src/tests/mod.rs | 3 + .../src/tests/resource_metering.rs | 210 ++++++++++++++++++ 3 files changed, 219 insertions(+) create mode 100644 crates/op-rbuilder/src/tests/resource_metering.rs 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..aabe1c07 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 resource_metering; + #[cfg(test)] mod smoke; diff --git a/crates/op-rbuilder/src/tests/resource_metering.rs b/crates/op-rbuilder/src/tests/resource_metering.rs new file mode 100644 index 00000000..e43d01e4 --- /dev/null +++ b/crates/op-rbuilder/src/tests/resource_metering.rs @@ -0,0 +1,210 @@ +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.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(()) +} + +#[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(()) +} + +#[rb_test( + flashblocks, + 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 / 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"); +} From 36fa1dede2b71151f795320f19b05e7a13ac4cdb Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Fri, 21 Nov 2025 01:56:54 -0600 Subject: [PATCH 04/14] refactor: model tx resource usage explicitly --- crates/op-rbuilder/src/builders/context.rs | 33 +++++--- .../src/primitives/reth/execution.rs | 84 +++++++++++++------ crates/op-rbuilder/src/primitives/reth/mod.rs | 4 +- 3 files changed, 83 insertions(+), 38 deletions(-) diff --git a/crates/op-rbuilder/src/builders/context.rs b/crates/op-rbuilder/src/builders/context.rs index 91f0122c..315fc448 100644 --- a/crates/op-rbuilder/src/builders/context.rs +++ b/crates/op-rbuilder/src/builders/context.rs @@ -42,7 +42,9 @@ use tracing::{debug, info, trace}; use crate::{ gas_limiter::AddressGasLimiter, metrics::OpRBuilderMetrics, - primitives::reth::{ExecutionInfo, TxnExecutionResult}, + primitives::reth::{ + BlockLimits, ExecutionInfo, LimitContext, TxLimits, TxUsage, TxnExecutionResult, + }, resource_metering::ResourceMetering, traits::PayloadTxsBounds, tx::MaybeRevertingTransaction, @@ -475,18 +477,25 @@ impl OpPayloadBuilderCtx { } } + let resource_limits = LimitContext { + block: BlockLimits { + gas: block_gas_limit, + data: block_da_limit, + da_footprint: block_da_footprint_limit, + execution_time_us: block_execution_time_limit_us, + }, + tx: TxLimits { data: tx_da_limit }, + da_footprint_gas_scalar: info.da_footprint_scalar, + }; + + let usage = TxUsage { + data_size: tx_da_size, + gas_limit: tx.gas_limit(), + execution_time_us: tx_execution_time_us, + }; + // ensure we still have capacity for this transaction - if let Err(result) = info.is_tx_over_limits( - tx_da_size, - block_gas_limit, - tx_da_limit, - block_da_limit, - tx.gas_limit(), - info.da_footprint_scalar, - block_da_footprint_limit, - tx_execution_time_us, - block_execution_time_limit_us, - ) { + if let Err(result) = info.is_tx_over_limits(&usage, &resource_limits) { // we can't fit this transaction into the block, so we need to mark it as // invalid which also removes all dependent transaction from // the iterator before we can continue diff --git a/crates/op-rbuilder/src/primitives/reth/execution.rs b/crates/op-rbuilder/src/primitives/reth/execution.rs index 92682907..b2b23a79 100644 --- a/crates/op-rbuilder/src/primitives/reth/execution.rs +++ b/crates/op-rbuilder/src/primitives/reth/execution.rs @@ -50,6 +50,37 @@ pub struct ExecutionInfo { pub cumulative_execution_time_us: u128, } +/// Block-wide resource ceilings. +#[derive(Debug, Clone, Copy)] +pub struct BlockLimits { + pub gas: u64, + pub data: Option, + pub da_footprint: Option, + pub execution_time_us: u128, +} + +/// Transaction-specific ceilings (per-tx limits imposed by protocol rules). +#[derive(Debug, Clone, Copy)] +pub struct TxLimits { + pub data: Option, +} + +/// Additional limit modifiers derived from the chain state. +#[derive(Debug, Clone, Copy)] +pub struct LimitContext { + pub block: BlockLimits, + pub tx: TxLimits, + pub da_footprint_gas_scalar: Option, +} + +/// Measured resource usage for a candidate transaction. +#[derive(Debug, Clone, Copy)] +pub struct TxUsage { + pub data_size: u64, + pub gas_limit: u64, + pub execution_time_us: u128, +} + impl ExecutionInfo { /// Create a new instance with allocated slots. pub fn with_capacity(capacity: usize) -> Self { @@ -74,61 +105,64 @@ impl ExecutionInfo { /// maximum allowed DA limit per block. /// - block execution time limit: if configured, ensures the transaction's execution time does /// not exceed the maximum allowed execution time per block. - #[allow(clippy::too_many_arguments)] pub fn is_tx_over_limits( &self, - tx_da_size: u64, - block_gas_limit: u64, - tx_data_limit: Option, - block_data_limit: Option, - tx_gas_limit: u64, - da_footprint_gas_scalar: Option, - block_da_footprint_limit: Option, - tx_execution_time_us: u128, - block_execution_time_limit_us: u128, + usage: &TxUsage, + limits: &LimitContext, ) -> Result<(), TxnExecutionResult> { - if tx_data_limit.is_some_and(|da_limit| tx_da_size > da_limit) { + if limits + .tx + .data + .is_some_and(|da_limit| usage.data_size > da_limit) + { return Err(TxnExecutionResult::TransactionDALimitExceeded); } - let total_da_bytes_used = self.cumulative_da_bytes_used.saturating_add(tx_da_size); - if block_data_limit.is_some_and(|da_limit| total_da_bytes_used > da_limit) { + let total_da_bytes_used = self + .cumulative_da_bytes_used + .saturating_add(usage.data_size); + + if limits + .block + .data + .is_some_and(|da_limit| total_da_bytes_used > da_limit) + { return Err(TxnExecutionResult::BlockDALimitExceeded( self.cumulative_da_bytes_used, - tx_da_size, - block_data_limit.unwrap_or_default(), + usage.data_size, + limits.block.data.unwrap_or_default(), )); } // Post Jovian: the tx DA footprint must be less than the block gas limit - if let Some(da_footprint_gas_scalar) = da_footprint_gas_scalar { + if let Some(da_footprint_gas_scalar) = limits.da_footprint_gas_scalar { let tx_da_footprint = total_da_bytes_used.saturating_mul(da_footprint_gas_scalar as u64); - if tx_da_footprint > block_da_footprint_limit.unwrap_or(block_gas_limit) { + if tx_da_footprint > limits.block.da_footprint.unwrap_or(limits.block.gas) { return Err(TxnExecutionResult::BlockDALimitExceeded( total_da_bytes_used, - tx_da_size, + usage.data_size, tx_da_footprint, )); } } - if self.cumulative_gas_used + tx_gas_limit > block_gas_limit { + if self.cumulative_gas_used + usage.gas_limit > limits.block.gas { return Err(TxnExecutionResult::TransactionGasLimitExceeded( self.cumulative_gas_used, - tx_gas_limit, - block_gas_limit, + usage.gas_limit, + limits.block.gas, )); } // Check block execution time limit let total_execution_time_us = self .cumulative_execution_time_us - .saturating_add(tx_execution_time_us); - if total_execution_time_us > block_execution_time_limit_us { + .saturating_add(usage.execution_time_us); + if total_execution_time_us > limits.block.execution_time_us { return Err(TxnExecutionResult::BlockExecutionTimeLimitExceeded( self.cumulative_execution_time_us, - tx_execution_time_us, - block_execution_time_limit_us, + usage.execution_time_us, + limits.block.execution_time_us, )); } diff --git a/crates/op-rbuilder/src/primitives/reth/mod.rs b/crates/op-rbuilder/src/primitives/reth/mod.rs index ed2b38b9..b04a6f67 100644 --- a/crates/op-rbuilder/src/primitives/reth/mod.rs +++ b/crates/op-rbuilder/src/primitives/reth/mod.rs @@ -1,3 +1,5 @@ pub mod engine_api_builder; mod execution; -pub use execution::{ExecutionInfo, TxnExecutionResult}; +pub use execution::{ + BlockLimits, ExecutionInfo, LimitContext, TxLimits, TxUsage, TxnExecutionResult, +}; From c9073ceb578d44cd32745ab2a95525e9d5873e5c Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Fri, 21 Nov 2025 02:20:11 -0600 Subject: [PATCH 05/14] Update comments --- .../src/builders/flashblocks/payload.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/op-rbuilder/src/builders/flashblocks/payload.rs b/crates/op-rbuilder/src/builders/flashblocks/payload.rs index ff634bd5..7463cb6e 100644 --- a/crates/op-rbuilder/src/builders/flashblocks/payload.rs +++ b/crates/op-rbuilder/src/builders/flashblocks/payload.rs @@ -795,7 +795,8 @@ where .flashblock_num_tx_histogram .record(info.executed_transactions.len() as f64); - // Update bundle_state for next iteration + // Any unused DA carries over to the next batch. Add the + // per-flashblock limit to the last target for the accumulator. if let Some(da_limit) = ctx.extra_ctx.da_per_batch { if let Some(da) = target_da_for_batch.as_mut() { *da += da_limit; @@ -806,12 +807,12 @@ where } } - // Any unused gas carries over to the next batch. The total - // gas used for the block after the next flashblock can be - // up to the total gas for allocated for each batch so far. + // Any unused gas carries over to the next batch. Add the + // per-flashblock limit to the last target for the accumulator. let target_gas_for_batch = ctx.extra_ctx.target_gas_for_batch + ctx.extra_ctx.gas_per_batch; + // Any unused DA footprint carries over to the next batch. if let (Some(footprint), Some(da_footprint_limit)) = ( target_da_footprint_for_batch.as_mut(), ctx.extra_ctx.da_footprint_per_batch, @@ -820,9 +821,8 @@ where } // Any unused execution time *does not* carry over to the next - // batch. The total execution time for the block after the next - // flashblock can only be up to the execution time *used* so far - // plus its own limit. + // batch. Add the per-flashblock limit to the current value of + // the accumulator itself to discard the unused execution time. let target_execution_time_per_batch_us = info.cumulative_execution_time_us + ctx.extra_ctx.execution_time_per_batch_us; From f9f20d10f58082b2cd23d667cb325c4afa8756c8 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Mon, 1 Dec 2025 14:57:56 -0600 Subject: [PATCH 06/14] refactor: minimize upstream diffs for execution time limits Bundle Base-specific execution state into dedicated types in base/execution.rs to reduce inline modifications to upstream-mirrored files. The upstream is_tx_over_limits() signature is preserved (with one additional parameter for DA footprint override), and Base-specific limit checking is added after the upstream check via is_tx_over_base_limits(). --- crates/op-rbuilder/src/base/execution.rs | 71 ++++++++++++ crates/op-rbuilder/src/base/mod.rs | 1 + crates/op-rbuilder/src/builders/context.rs | 56 +++++----- .../src/builders/flashblocks/payload.rs | 6 +- crates/op-rbuilder/src/lib.rs | 1 + .../src/primitives/reth/execution.rs | 101 +++++------------- crates/op-rbuilder/src/primitives/reth/mod.rs | 4 +- 7 files changed, 129 insertions(+), 111 deletions(-) create mode 100644 crates/op-rbuilder/src/base/execution.rs create mode 100644 crates/op-rbuilder/src/base/mod.rs diff --git a/crates/op-rbuilder/src/base/execution.rs b/crates/op-rbuilder/src/base/execution.rs new file mode 100644 index 00000000..9998cf42 --- /dev/null +++ b/crates/op-rbuilder/src/base/execution.rs @@ -0,0 +1,71 @@ +//! Base-specific execution time tracking and limit checking. + +use crate::resource_metering::ResourceMetering; +use alloy_primitives::TxHash; + +/// 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 { + cumulative_us: u128, + tx_us: u128, + limit_us: u128, + }, +} + +impl BaseExecutionState { + /// Check if adding this tx would exceed Base-specific limits. + /// Call this AFTER the upstream is_tx_over_limits(). + pub fn is_tx_over_base_limits( + &self, + usage: &BaseTxUsage, + limits: &BaseBlockLimits, + ) -> Result<(), BaseLimitExceeded> { + let total = self + .cumulative_execution_time_us + .saturating_add(usage.execution_time_us); + if total > limits.execution_time_us { + return Err(BaseLimitExceeded::ExecutionTime { + cumulative_us: self.cumulative_execution_time_us, + tx_us: usage.execution_time_us, + limit_us: limits.execution_time_us, + }); + } + Ok(()) + } + + /// 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/mod.rs b/crates/op-rbuilder/src/base/mod.rs new file mode 100644 index 00000000..fa9fe750 --- /dev/null +++ b/crates/op-rbuilder/src/base/mod.rs @@ -0,0 +1 @@ +pub mod execution; diff --git a/crates/op-rbuilder/src/builders/context.rs b/crates/op-rbuilder/src/builders/context.rs index 315fc448..9942d1d7 100644 --- a/crates/op-rbuilder/src/builders/context.rs +++ b/crates/op-rbuilder/src/builders/context.rs @@ -40,11 +40,10 @@ use tokio_util::sync::CancellationToken; use tracing::{debug, info, trace}; use crate::{ + base::execution::{BaseBlockLimits, BaseTxUsage}, gas_limiter::AddressGasLimiter, metrics::OpRBuilderMetrics, - primitives::reth::{ - BlockLimits, ExecutionInfo, LimitContext, TxLimits, TxUsage, TxnExecutionResult, - }, + primitives::reth::{ExecutionInfo, TxnExecutionResult}, resource_metering::ResourceMetering, traits::PayloadTxsBounds, tx::MaybeRevertingTransaction, @@ -450,12 +449,6 @@ impl OpPayloadBuilderCtx { num_txs_considered += 1; - let resource_usage = self.resource_metering.get(&tx_hash); - let tx_execution_time_us = resource_usage - .as_ref() - .map(|r| r.total_execution_time_us) - .unwrap_or(0); - // TODO: ideally we should get this from the txpool stream if let Some(conditional) = conditional && !conditional.matches_block_attributes(&block_attr) @@ -477,25 +470,16 @@ impl OpPayloadBuilderCtx { } } - let resource_limits = LimitContext { - block: BlockLimits { - gas: block_gas_limit, - data: block_da_limit, - da_footprint: block_da_footprint_limit, - execution_time_us: block_execution_time_limit_us, - }, - tx: TxLimits { data: tx_da_limit }, - da_footprint_gas_scalar: info.da_footprint_scalar, - }; - - let usage = TxUsage { - data_size: tx_da_size, - gas_limit: tx.gas_limit(), - execution_time_us: tx_execution_time_us, - }; - - // ensure we still have capacity for this transaction - if let Err(result) = info.is_tx_over_limits(&usage, &resource_limits) { + // Upstream limit check + if let Err(result) = info.is_tx_over_limits( + tx_da_size, + block_gas_limit, + tx_da_limit, + block_da_limit, + tx.gas_limit(), + info.da_footprint_scalar, + block_da_footprint_limit, + ) { // we can't fit this transaction into the block, so we need to mark it as // invalid which also removes all dependent transaction from // the iterator before we can continue @@ -504,6 +488,18 @@ impl OpPayloadBuilderCtx { continue; } + // Base-specific limit check + let base_usage = BaseTxUsage::from_metering(&self.resource_metering, &tx_hash); + let base_limits = BaseBlockLimits { + execution_time_us: block_execution_time_limit_us, + }; + if let Err(exceeded) = info.base_state.is_tx_over_base_limits(&base_usage, &base_limits) + { + debug!(target: "payload_builder", ?exceeded, "Base limit exceeded"); + best_txs.mark_invalid(tx.signer(), tx.nonce()); + continue; + } + // 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); @@ -595,8 +591,8 @@ impl OpPayloadBuilderCtx { info.cumulative_gas_used += gas_used; // record tx da size info.cumulative_da_bytes_used += tx_da_size; - // record tx execution time - info.cumulative_execution_time_us += tx_execution_time_us; + // 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/payload.rs b/crates/op-rbuilder/src/builders/flashblocks/payload.rs index 7463cb6e..0c272341 100644 --- a/crates/op-rbuilder/src/builders/flashblocks/payload.rs +++ b/crates/op-rbuilder/src/builders/flashblocks/payload.rs @@ -823,8 +823,10 @@ where // Any unused execution time *does not* carry over to the next // batch. Add the per-flashblock limit to the current value of // the accumulator itself to discard the unused execution time. - let target_execution_time_per_batch_us = - info.cumulative_execution_time_us + ctx.extra_ctx.execution_time_per_batch_us; + let target_execution_time_per_batch_us = info + .base_state + .cumulative_execution_time_us + + ctx.extra_ctx.execution_time_per_batch_us; let next_extra_ctx = ctx.extra_ctx.clone().next( target_gas_for_batch, diff --git a/crates/op-rbuilder/src/lib.rs b/crates/op-rbuilder/src/lib.rs index f61c39b0..f898150d 100644 --- a/crates/op-rbuilder/src/lib.rs +++ b/crates/op-rbuilder/src/lib.rs @@ -13,6 +13,7 @@ pub mod tx_signer; #[cfg(test)] pub mod mock_tx; +pub mod base; mod resource_metering; #[cfg(any(test, feature = "testing"))] pub mod tests; diff --git a/crates/op-rbuilder/src/primitives/reth/execution.rs b/crates/op-rbuilder/src/primitives/reth/execution.rs index b2b23a79..4ebbf2d7 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; @@ -12,10 +13,6 @@ pub enum TxnExecutionResult { BlockDALimitExceeded(u64, u64, u64), #[display("TransactionGasLimitExceeded: total_gas_used={_0} tx_gas_limit={_1}")] TransactionGasLimitExceeded(u64, u64, u64), - #[display( - "BlockExecutionTimeLimitExceeded: total_time_us={_0} tx_time_us={_1} block_time_limit_us={_2}" - )] - BlockExecutionTimeLimitExceeded(u128, u128, u128), SequencerTransaction, NonceTooLow, InteropFailed, @@ -46,39 +43,8 @@ pub struct ExecutionInfo { pub extra: Extra, /// DA Footprint Scalar for Jovian pub da_footprint_scalar: Option, - /// Cumulative execution time in microseconds - pub cumulative_execution_time_us: u128, -} - -/// Block-wide resource ceilings. -#[derive(Debug, Clone, Copy)] -pub struct BlockLimits { - pub gas: u64, - pub data: Option, - pub da_footprint: Option, - pub execution_time_us: u128, -} - -/// Transaction-specific ceilings (per-tx limits imposed by protocol rules). -#[derive(Debug, Clone, Copy)] -pub struct TxLimits { - pub data: Option, -} - -/// Additional limit modifiers derived from the chain state. -#[derive(Debug, Clone, Copy)] -pub struct LimitContext { - pub block: BlockLimits, - pub tx: TxLimits, - pub da_footprint_gas_scalar: Option, -} - -/// Measured resource usage for a candidate transaction. -#[derive(Debug, Clone, Copy)] -pub struct TxUsage { - pub data_size: u64, - pub gas_limit: u64, - pub execution_time_us: u128, + /// Base-specific execution state + pub base_state: BaseExecutionState, } impl ExecutionInfo { @@ -93,7 +59,7 @@ impl ExecutionInfo { total_fees: U256::ZERO, extra: Default::default(), da_footprint_scalar: None, - cumulative_execution_time_us: 0, + base_state: BaseExecutionState::default(), } } @@ -103,69 +69,52 @@ impl ExecutionInfo { /// per tx. /// - block DA limit: if configured, ensures the transaction's DA size does not exceed the /// maximum allowed DA limit per block. - /// - block execution time limit: if configured, ensures the transaction's execution time does - /// not exceed the maximum allowed execution time per block. + /// - block DA footprint limit: if configured, overrides block_gas_limit for DA footprint check pub fn is_tx_over_limits( &self, - usage: &TxUsage, - limits: &LimitContext, + tx_da_size: u64, + block_gas_limit: u64, + tx_data_limit: Option, + block_data_limit: Option, + tx_gas_limit: u64, + da_footprint_gas_scalar: Option, + block_da_footprint_limit: Option, ) -> Result<(), TxnExecutionResult> { - if limits - .tx - .data - .is_some_and(|da_limit| usage.data_size > da_limit) - { + if tx_data_limit.is_some_and(|da_limit| tx_da_size > da_limit) { return Err(TxnExecutionResult::TransactionDALimitExceeded); } - let total_da_bytes_used = self - .cumulative_da_bytes_used - .saturating_add(usage.data_size); + let total_da_bytes_used = self.cumulative_da_bytes_used.saturating_add(tx_da_size); - if limits - .block - .data - .is_some_and(|da_limit| total_da_bytes_used > da_limit) - { + if block_data_limit.is_some_and(|da_limit| total_da_bytes_used > da_limit) { return Err(TxnExecutionResult::BlockDALimitExceeded( self.cumulative_da_bytes_used, - usage.data_size, - limits.block.data.unwrap_or_default(), + tx_da_size, + block_data_limit.unwrap_or_default(), )); } // Post Jovian: the tx DA footprint must be less than the block gas limit - if let Some(da_footprint_gas_scalar) = limits.da_footprint_gas_scalar { + // (or block_da_footprint_limit if specified) + if let Some(da_footprint_gas_scalar) = da_footprint_gas_scalar { let tx_da_footprint = total_da_bytes_used.saturating_mul(da_footprint_gas_scalar as u64); - if tx_da_footprint > limits.block.da_footprint.unwrap_or(limits.block.gas) { + let footprint_limit = block_da_footprint_limit.unwrap_or(block_gas_limit); + if tx_da_footprint > footprint_limit { return Err(TxnExecutionResult::BlockDALimitExceeded( total_da_bytes_used, - usage.data_size, + tx_da_size, tx_da_footprint, )); } } - if self.cumulative_gas_used + usage.gas_limit > limits.block.gas { + if self.cumulative_gas_used + tx_gas_limit > block_gas_limit { return Err(TxnExecutionResult::TransactionGasLimitExceeded( self.cumulative_gas_used, - usage.gas_limit, - limits.block.gas, - )); - } - - // Check block execution time limit - let total_execution_time_us = self - .cumulative_execution_time_us - .saturating_add(usage.execution_time_us); - if total_execution_time_us > limits.block.execution_time_us { - return Err(TxnExecutionResult::BlockExecutionTimeLimitExceeded( - self.cumulative_execution_time_us, - usage.execution_time_us, - limits.block.execution_time_us, + tx_gas_limit, + block_gas_limit, )); } - Ok(()) } } diff --git a/crates/op-rbuilder/src/primitives/reth/mod.rs b/crates/op-rbuilder/src/primitives/reth/mod.rs index b04a6f67..ed2b38b9 100644 --- a/crates/op-rbuilder/src/primitives/reth/mod.rs +++ b/crates/op-rbuilder/src/primitives/reth/mod.rs @@ -1,5 +1,3 @@ pub mod engine_api_builder; mod execution; -pub use execution::{ - BlockLimits, ExecutionInfo, LimitContext, TxLimits, TxUsage, TxnExecutionResult, -}; +pub use execution::{ExecutionInfo, TxnExecutionResult}; From 6f6d04431a0262865cd770c5fa5f480934d960f3 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Mon, 1 Dec 2025 16:26:08 -0600 Subject: [PATCH 07/14] refactor: bundle Base-specific state into dedicated types Extract execution time limit tracking into separate types in the base/ module to minimize diffs against upstream: - BaseBuilderCtx: block-level execution time limit - BaseFlashblocksCtx: per-batch execution time tracking - BaseExecutionState: cumulative execution time state This keeps modifications to existing files minimal and additive. --- crates/op-rbuilder/src/base/context.rs | 18 ++++++++ crates/op-rbuilder/src/base/execution.rs | 19 +++++---- crates/op-rbuilder/src/base/flashblocks.rs | 41 ++++++++++++++++++ crates/op-rbuilder/src/base/mod.rs | 2 + crates/op-rbuilder/src/builders/context.rs | 32 +++++++------- .../src/builders/flashblocks/ctx.rs | 9 ++-- .../src/builders/flashblocks/payload.rs | 42 +++++++------------ .../src/builders/standard/payload.rs | 5 ++- .../src/primitives/reth/execution.rs | 7 +--- 9 files changed, 115 insertions(+), 60 deletions(-) create mode 100644 crates/op-rbuilder/src/base/context.rs create mode 100644 crates/op-rbuilder/src/base/flashblocks.rs diff --git a/crates/op-rbuilder/src/base/context.rs b/crates/op-rbuilder/src/base/context.rs new file mode 100644 index 00000000..8cb8b7a6 --- /dev/null +++ b/crates/op-rbuilder/src/base/context.rs @@ -0,0 +1,18 @@ +//! Base-specific builder context. + +/// 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, +} + +impl BaseBuilderCtx { + /// Create a new BaseBuilderCtx with the given execution time limit. + pub fn new(block_execution_time_limit_us: u128) -> Self { + Self { + block_execution_time_limit_us, + } + } +} diff --git a/crates/op-rbuilder/src/base/execution.rs b/crates/op-rbuilder/src/base/execution.rs index 9998cf42..4bab5697 100644 --- a/crates/op-rbuilder/src/base/execution.rs +++ b/crates/op-rbuilder/src/base/execution.rs @@ -33,24 +33,27 @@ pub enum BaseLimitExceeded { } impl BaseExecutionState { - /// Check if adding this tx would exceed Base-specific limits. + /// Check if adding a tx would exceed Base-specific limits. /// Call this AFTER the upstream is_tx_over_limits(). - pub fn is_tx_over_base_limits( + /// Returns the usage for later recording via `record_tx`. + pub fn check_tx( &self, - usage: &BaseTxUsage, - limits: &BaseBlockLimits, - ) -> Result<(), BaseLimitExceeded> { + metering: &ResourceMetering, + tx_hash: &TxHash, + execution_time_limit_us: u128, + ) -> Result { + let usage = BaseTxUsage::from_metering(metering, tx_hash); let total = self .cumulative_execution_time_us .saturating_add(usage.execution_time_us); - if total > limits.execution_time_us { + if total > execution_time_limit_us { return Err(BaseLimitExceeded::ExecutionTime { cumulative_us: self.cumulative_execution_time_us, tx_us: usage.execution_time_us, - limit_us: limits.execution_time_us, + limit_us: execution_time_limit_us, }); } - Ok(()) + Ok(usage) } /// Record that a transaction was included. diff --git a/crates/op-rbuilder/src/base/flashblocks.rs b/crates/op-rbuilder/src/base/flashblocks.rs new file mode 100644 index 00000000..637703f3 --- /dev/null +++ b/crates/op-rbuilder/src/base/flashblocks.rs @@ -0,0 +1,41 @@ +//! Base-specific flashblocks context. + +use super::context::BaseBuilderCtx; +use crate::builders::flashblocks::FlashblocksConfig; + +/// 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, +} + +impl BaseFlashblocksCtx { + /// Create a new BaseFlashblocksCtx from flashblocks config. + pub fn new(config: &FlashblocksConfig) -> Self { + let execution_time_per_batch_us = config.interval.as_micros(); + Self { + target_execution_time_us: execution_time_per_batch_us, + execution_time_per_batch_us, + } + } + + /// 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) + } +} diff --git a/crates/op-rbuilder/src/base/mod.rs b/crates/op-rbuilder/src/base/mod.rs index fa9fe750..b6b65fdd 100644 --- a/crates/op-rbuilder/src/base/mod.rs +++ b/crates/op-rbuilder/src/base/mod.rs @@ -1 +1,3 @@ +pub mod context; pub mod execution; +pub mod flashblocks; diff --git a/crates/op-rbuilder/src/builders/context.rs b/crates/op-rbuilder/src/builders/context.rs index 9942d1d7..c0a7c0dc 100644 --- a/crates/op-rbuilder/src/builders/context.rs +++ b/crates/op-rbuilder/src/builders/context.rs @@ -40,7 +40,7 @@ use tokio_util::sync::CancellationToken; use tracing::{debug, info, trace}; use crate::{ - base::execution::{BaseBlockLimits, BaseTxUsage}, + base::context::BaseBuilderCtx, gas_limiter::AddressGasLimiter, metrics::OpRBuilderMetrics, primitives::reth::{ExecutionInfo, TxnExecutionResult}, @@ -81,8 +81,8 @@ pub struct OpPayloadBuilderCtx { pub address_gas_limiter: AddressGasLimiter, /// Per transaction resource metering information pub resource_metering: ResourceMetering, - /// Block execution time limit in microseconds - pub block_execution_time_limit_us: u128, + /// Base-specific builder context + pub base_ctx: BaseBuilderCtx, } impl OpPayloadBuilderCtx { @@ -392,7 +392,7 @@ impl OpPayloadBuilderCtx { block_gas_limit: u64, block_da_limit: Option, block_da_footprint_limit: Option, - block_execution_time_limit_us: u128, + base_ctx: &BaseBuilderCtx, ) -> Result, PayloadBuilderError> { let execute_txs_start_time = Instant::now(); let mut num_txs_considered = 0; @@ -470,7 +470,7 @@ impl OpPayloadBuilderCtx { } } - // Upstream limit check + // ensure we still have capacity for this transaction if let Err(result) = info.is_tx_over_limits( tx_da_size, block_gas_limit, @@ -488,17 +488,19 @@ impl OpPayloadBuilderCtx { continue; } - // Base-specific limit check - let base_usage = BaseTxUsage::from_metering(&self.resource_metering, &tx_hash); - let base_limits = BaseBlockLimits { - execution_time_us: block_execution_time_limit_us, + // 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, + ) { + Ok(usage) => usage, + Err(exceeded) => { + debug!(target: "payload_builder", ?exceeded, "Base limit exceeded"); + best_txs.mark_invalid(tx.signer(), tx.nonce()); + continue; + } }; - if let Err(exceeded) = info.base_state.is_tx_over_base_limits(&base_usage, &base_limits) - { - debug!(target: "payload_builder", ?exceeded, "Base limit exceeded"); - best_txs.mark_invalid(tx.signer(), tx.nonce()); - continue; - } // A sequencer's block should never contain blob or deposit transactions from the pool. if tx.is_eip4844() || tx.is_deposit() { diff --git a/crates/op-rbuilder/src/builders/flashblocks/ctx.rs b/crates/op-rbuilder/src/builders/flashblocks/ctx.rs index 32b4e4e8..cd31ff6f 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,8 +33,8 @@ pub(super) struct OpPayloadSyncerCtx { metrics: Arc, /// Resource metering tracking resource_metering: ResourceMetering, - /// Block execution time limit in microseconds - block_execution_time_limit_us: u128, + /// Base-specific builder context + base_ctx: BaseBuilderCtx, } impl OpPayloadSyncerCtx { @@ -54,7 +55,7 @@ impl OpPayloadSyncerCtx { max_gas_per_txn: builder_config.max_gas_per_txn, metrics, resource_metering: builder_config.resource_metering, - block_execution_time_limit_us: builder_config.block_time.as_micros(), + base_ctx: BaseBuilderCtx::new(builder_config.block_time.as_micros()), }) } @@ -88,7 +89,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(), - block_execution_time_limit_us: self.block_execution_time_limit_us, + 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 0c272341..507af083 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, @@ -86,18 +87,16 @@ pub struct FlashblocksExtraCtx { target_da_for_batch: Option, /// Total DA footprint left for the current flashblock target_da_footprint_for_batch: Option, - /// Total execution time (us) left for the current flashblock - target_execution_time_per_batch_us: u128, /// Gas limit per flashblock gas_per_batch: u64, /// DA bytes limit per flashblock da_per_batch: Option, /// DA footprint limit per flashblock da_footprint_per_batch: Option, - /// Execution time (us) limit per flashblock - execution_time_per_batch_us: u128, /// Whether to disable state root calculation for each flashblock disable_state_root: bool, + /// Base-specific flashblocks context + base_ctx: BaseFlashblocksCtx, } impl FlashblocksExtraCtx { @@ -106,14 +105,14 @@ impl FlashblocksExtraCtx { target_gas_for_batch: u64, target_da_for_batch: Option, target_da_footprint_for_batch: Option, - target_execution_time_per_batch_us: u128, + base_ctx: BaseFlashblocksCtx, ) -> Self { Self { flashblock_index: self.flashblock_index + 1, target_gas_for_batch, target_da_for_batch, target_da_footprint_for_batch, - target_execution_time_per_batch_us, + base_ctx, ..self } } @@ -289,7 +288,7 @@ 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(), - block_execution_time_limit_us: self.config.block_time.as_micros(), + base_ctx: BaseBuilderCtx::new(self.config.block_time.as_micros()), }) } @@ -449,8 +448,6 @@ where .da_config .max_da_block_size() .map(|da_limit| da_limit / flashblocks_per_block); - // Use flashblock interval as the execution time limit per flashblock (in microseconds) - let execution_time_per_batch_us = self.config.specific.interval.as_micros(); // Check that builder tx won't affect fb limit too much if let Some(da_limit) = da_per_batch { // We error if we can't insert any tx aside from builder tx in flashblock @@ -469,13 +466,12 @@ where target_flashblock_count: flashblocks_per_block, target_gas_for_batch: gas_per_batch, target_da_for_batch: da_per_batch, - target_da_footprint_for_batch: da_footprint_per_batch, - target_execution_time_per_batch_us: execution_time_per_batch_us, gas_per_batch, da_per_batch, da_footprint_per_batch, - execution_time_per_batch_us, disable_state_root, + target_da_footprint_for_batch: da_footprint_per_batch, + base_ctx: BaseFlashblocksCtx::new(&self.config.specific), }; let mut fb_cancel = block_cancel.child_token(); @@ -699,7 +695,7 @@ where target_gas_for_batch.min(ctx.block_gas_limit()), target_da_for_batch, target_da_footprint_for_batch, - ctx.extra_ctx.target_execution_time_per_batch_us, + &(&ctx.extra_ctx.base_ctx).into(), ) .wrap_err("failed to execute best transactions")?; // Extract last transactions @@ -795,8 +791,7 @@ where .flashblock_num_tx_histogram .record(info.executed_transactions.len() as f64); - // Any unused DA carries over to the next batch. Add the - // per-flashblock limit to the last target for the accumulator. + // Update bundle_state for next iteration if let Some(da_limit) = ctx.extra_ctx.da_per_batch { if let Some(da) = target_da_for_batch.as_mut() { *da += da_limit; @@ -807,12 +802,9 @@ where } } - // Any unused gas carries over to the next batch. Add the - // per-flashblock limit to the last target for the accumulator. let target_gas_for_batch = ctx.extra_ctx.target_gas_for_batch + ctx.extra_ctx.gas_per_batch; - // Any unused DA footprint carries over to the next batch. if let (Some(footprint), Some(da_footprint_limit)) = ( target_da_footprint_for_batch.as_mut(), ctx.extra_ctx.da_footprint_per_batch, @@ -820,19 +812,17 @@ where *footprint += da_footprint_limit; } - // Any unused execution time *does not* carry over to the next - // batch. Add the per-flashblock limit to the current value of - // the accumulator itself to discard the unused execution time. - let target_execution_time_per_batch_us = info - .base_state - .cumulative_execution_time_us - + ctx.extra_ctx.execution_time_per_batch_us; + // 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, - target_execution_time_per_batch_us, + next_base_ctx, ); info!( diff --git a/crates/op-rbuilder/src/builders/standard/payload.rs b/crates/op-rbuilder/src/builders/standard/payload.rs index 79626792..2a7fba4c 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,7 +253,7 @@ 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(), - block_execution_time_limit_us: self.config.block_time.as_micros(), + base_ctx: BaseBuilderCtx::new(self.config.block_time.as_micros()), }; let builder = OpBuilder::new(best); @@ -416,7 +417,7 @@ impl OpBuilder<'_, Txs> { block_gas_limit, block_da_limit, block_da_footprint, - ctx.block_execution_time_limit_us, + &ctx.base_ctx, )? .is_some() { diff --git a/crates/op-rbuilder/src/primitives/reth/execution.rs b/crates/op-rbuilder/src/primitives/reth/execution.rs index 4ebbf2d7..b55428bc 100644 --- a/crates/op-rbuilder/src/primitives/reth/execution.rs +++ b/crates/op-rbuilder/src/primitives/reth/execution.rs @@ -69,7 +69,7 @@ impl ExecutionInfo { /// per tx. /// - block DA limit: if configured, ensures the transaction's DA size does not exceed the /// maximum allowed DA limit per block. - /// - block DA footprint limit: if configured, overrides block_gas_limit for DA footprint check + #[allow(clippy::too_many_arguments)] pub fn is_tx_over_limits( &self, tx_da_size: u64, @@ -84,7 +84,6 @@ impl ExecutionInfo { return Err(TxnExecutionResult::TransactionDALimitExceeded); } let total_da_bytes_used = self.cumulative_da_bytes_used.saturating_add(tx_da_size); - if block_data_limit.is_some_and(|da_limit| total_da_bytes_used > da_limit) { return Err(TxnExecutionResult::BlockDALimitExceeded( self.cumulative_da_bytes_used, @@ -94,12 +93,10 @@ impl ExecutionInfo { } // Post Jovian: the tx DA footprint must be less than the block gas limit - // (or block_da_footprint_limit if specified) if let Some(da_footprint_gas_scalar) = da_footprint_gas_scalar { let tx_da_footprint = total_da_bytes_used.saturating_mul(da_footprint_gas_scalar as u64); - let footprint_limit = block_da_footprint_limit.unwrap_or(block_gas_limit); - if tx_da_footprint > footprint_limit { + if tx_da_footprint > block_da_footprint_limit.unwrap_or(block_gas_limit) { return Err(TxnExecutionResult::BlockDALimitExceeded( total_da_bytes_used, tx_da_size, From 72921cb26c51f7dcfc8b374fd027fbf8654cce8f Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Mon, 1 Dec 2025 19:15:21 -0600 Subject: [PATCH 08/14] refactor: simplify BaseFlashblocksCtx to take u128 interval Avoids needing to re-export FlashblocksConfig from the private flashblocks module by having new() accept the computed value directly. --- crates/op-rbuilder/src/base/flashblocks.rs | 6 ++---- crates/op-rbuilder/src/builders/flashblocks/payload.rs | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/op-rbuilder/src/base/flashblocks.rs b/crates/op-rbuilder/src/base/flashblocks.rs index 637703f3..f96c81a5 100644 --- a/crates/op-rbuilder/src/base/flashblocks.rs +++ b/crates/op-rbuilder/src/base/flashblocks.rs @@ -1,7 +1,6 @@ //! Base-specific flashblocks context. use super::context::BaseBuilderCtx; -use crate::builders::flashblocks::FlashblocksConfig; /// Base-specific flashblocks context for per-batch execution time tracking. /// Add this as a single field to FlashblocksExtraCtx to minimize diff. @@ -14,9 +13,8 @@ pub struct BaseFlashblocksCtx { } impl BaseFlashblocksCtx { - /// Create a new BaseFlashblocksCtx from flashblocks config. - pub fn new(config: &FlashblocksConfig) -> Self { - let execution_time_per_batch_us = config.interval.as_micros(); + /// Create a new BaseFlashblocksCtx with the given execution time limit per batch. + pub fn new(execution_time_per_batch_us: u128) -> Self { Self { target_execution_time_us: execution_time_per_batch_us, execution_time_per_batch_us, diff --git a/crates/op-rbuilder/src/builders/flashblocks/payload.rs b/crates/op-rbuilder/src/builders/flashblocks/payload.rs index 507af083..e911d7d6 100644 --- a/crates/op-rbuilder/src/builders/flashblocks/payload.rs +++ b/crates/op-rbuilder/src/builders/flashblocks/payload.rs @@ -471,7 +471,7 @@ where da_footprint_per_batch, disable_state_root, target_da_footprint_for_batch: da_footprint_per_batch, - base_ctx: BaseFlashblocksCtx::new(&self.config.specific), + base_ctx: BaseFlashblocksCtx::new(self.config.specific.interval.as_micros()), }; let mut fb_cancel = block_cancel.child_token(); From e11c195c6642838ca8a047f5fd92e51576c7a215 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Mon, 1 Dec 2025 20:23:31 -0600 Subject: [PATCH 09/14] feat: add metrics and logging for execution time limit checks Add BaseMetrics to track when transactions are excluded due to execution time limits. Metrics include tx execution time, remaining budget, exceeded amount, tx gas, and remaining gas at the point of rejection. Also logs tx_hash with each rejection warning. --- crates/op-rbuilder/src/base/context.rs | 5 ++ crates/op-rbuilder/src/base/execution.rs | 55 ++++++++++++++++++++++ crates/op-rbuilder/src/base/metrics.rs | 24 ++++++++++ crates/op-rbuilder/src/base/mod.rs | 1 + crates/op-rbuilder/src/builders/context.rs | 5 +- 5 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 crates/op-rbuilder/src/base/metrics.rs diff --git a/crates/op-rbuilder/src/base/context.rs b/crates/op-rbuilder/src/base/context.rs index 8cb8b7a6..f073e06a 100644 --- a/crates/op-rbuilder/src/base/context.rs +++ b/crates/op-rbuilder/src/base/context.rs @@ -1,11 +1,15 @@ //! 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, + /// Base-specific metrics + pub metrics: BaseMetrics, } impl BaseBuilderCtx { @@ -13,6 +17,7 @@ impl BaseBuilderCtx { pub fn new(block_execution_time_limit_us: u128) -> Self { Self { block_execution_time_limit_us, + metrics: Default::default(), } } } diff --git a/crates/op-rbuilder/src/base/execution.rs b/crates/op-rbuilder/src/base/execution.rs index 4bab5697..7b32a386 100644 --- a/crates/op-rbuilder/src/base/execution.rs +++ b/crates/op-rbuilder/src/base/execution.rs @@ -1,7 +1,9 @@ //! 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. @@ -26,12 +28,58 @@ pub struct BaseBlockLimits { #[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 { + /// Log and record metrics for this limit exceeded event. + pub fn log_and_record(&self, metrics: &BaseMetrics) { + match self { + Self::ExecutionTime { + tx_hash, + cumulative_us, + tx_us, + limit_us, + tx_gas, + remaining_gas, + } => { + 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(). @@ -41,16 +89,23 @@ impl BaseExecutionState { 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) 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 index b6b65fdd..e4957eb4 100644 --- a/crates/op-rbuilder/src/base/mod.rs +++ b/crates/op-rbuilder/src/base/mod.rs @@ -1,3 +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 c0a7c0dc..de0bc77e 100644 --- a/crates/op-rbuilder/src/builders/context.rs +++ b/crates/op-rbuilder/src/builders/context.rs @@ -493,10 +493,13 @@ impl OpPayloadBuilderCtx { &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) => { - debug!(target: "payload_builder", ?exceeded, "Base limit exceeded"); + exceeded.log_and_record(&self.base_ctx.metrics); best_txs.mark_invalid(tx.signer(), tx.nonce()); continue; } From 790bb25143c0c1bc0060430791829e6644e67f3e Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Mon, 1 Dec 2025 20:43:24 -0600 Subject: [PATCH 10/14] feat: add --builder.enforce-resource-metering flag When false (default), transactions that exceed execution time limits are logged and metrics recorded, but not rejected. Only the first transaction to exceed the limit triggers logging/metrics. When true, transactions exceeding limits are marked invalid and excluded from the block. --- crates/op-rbuilder/src/args/op.rs | 5 ++++- crates/op-rbuilder/src/base/context.rs | 5 ++++- crates/op-rbuilder/src/base/execution.rs | 18 ++++++++++++++++++ crates/op-rbuilder/src/base/flashblocks.rs | 9 ++++++--- crates/op-rbuilder/src/builders/context.rs | 7 +++++-- .../src/builders/flashblocks/ctx.rs | 5 ++++- .../src/builders/flashblocks/payload.rs | 10 ++++++++-- crates/op-rbuilder/src/builders/mod.rs | 5 +++++ .../src/builders/standard/payload.rs | 5 ++++- 9 files changed, 58 insertions(+), 11 deletions(-) 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 index f073e06a..6e340e74 100644 --- a/crates/op-rbuilder/src/base/context.rs +++ b/crates/op-rbuilder/src/base/context.rs @@ -8,15 +8,18 @@ use super::metrics::BaseMetrics; 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) -> Self { + 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 index 7b32a386..5f4b651d 100644 --- a/crates/op-rbuilder/src/base/execution.rs +++ b/crates/op-rbuilder/src/base/execution.rs @@ -38,7 +38,19 @@ pub enum BaseLimitExceeded { } 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 { @@ -49,6 +61,11 @@ impl BaseLimitExceeded { 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!( @@ -97,6 +114,7 @@ impl BaseExecutionState { 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 { diff --git a/crates/op-rbuilder/src/base/flashblocks.rs b/crates/op-rbuilder/src/base/flashblocks.rs index f96c81a5..71fabe82 100644 --- a/crates/op-rbuilder/src/base/flashblocks.rs +++ b/crates/op-rbuilder/src/base/flashblocks.rs @@ -10,19 +10,22 @@ pub struct BaseFlashblocksCtx { 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) -> Self { + 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 { @@ -34,6 +37,6 @@ impl BaseFlashblocksCtx { impl From<&BaseFlashblocksCtx> for BaseBuilderCtx { fn from(ctx: &BaseFlashblocksCtx) -> Self { - BaseBuilderCtx::new(ctx.target_execution_time_us) + BaseBuilderCtx::new(ctx.target_execution_time_us, ctx.enforce_limits) } } diff --git a/crates/op-rbuilder/src/builders/context.rs b/crates/op-rbuilder/src/builders/context.rs index de0bc77e..ae1e0148 100644 --- a/crates/op-rbuilder/src/builders/context.rs +++ b/crates/op-rbuilder/src/builders/context.rs @@ -500,8 +500,11 @@ impl OpPayloadBuilderCtx { Ok(usage) => usage, Err(exceeded) => { exceeded.log_and_record(&self.base_ctx.metrics); - best_txs.mark_invalid(tx.signer(), tx.nonce()); - continue; + if self.base_ctx.enforce_limits { + best_txs.mark_invalid(tx.signer(), tx.nonce()); + continue; + } + exceeded.usage() } }; diff --git a/crates/op-rbuilder/src/builders/flashblocks/ctx.rs b/crates/op-rbuilder/src/builders/flashblocks/ctx.rs index cd31ff6f..7a4b0ebd 100644 --- a/crates/op-rbuilder/src/builders/flashblocks/ctx.rs +++ b/crates/op-rbuilder/src/builders/flashblocks/ctx.rs @@ -55,7 +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()), + base_ctx: BaseBuilderCtx::new( + builder_config.block_time.as_micros(), + builder_config.enforce_resource_metering, + ), }) } diff --git a/crates/op-rbuilder/src/builders/flashblocks/payload.rs b/crates/op-rbuilder/src/builders/flashblocks/payload.rs index e911d7d6..eb5aad1c 100644 --- a/crates/op-rbuilder/src/builders/flashblocks/payload.rs +++ b/crates/op-rbuilder/src/builders/flashblocks/payload.rs @@ -288,7 +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()), + base_ctx: BaseBuilderCtx::new( + self.config.block_time.as_micros(), + self.config.enforce_resource_metering, + ), }) } @@ -471,7 +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()), + base_ctx: BaseFlashblocksCtx::new( + self.config.specific.interval.as_micros(), + self.config.enforce_resource_metering, + ), }; let mut fb_cancel = block_cancel.child_token(); 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 2a7fba4c..3c9bdb4c 100644 --- a/crates/op-rbuilder/src/builders/standard/payload.rs +++ b/crates/op-rbuilder/src/builders/standard/payload.rs @@ -253,7 +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()), + base_ctx: BaseBuilderCtx::new( + self.config.block_time.as_micros(), + self.config.enforce_resource_metering, + ), }; let builder = OpBuilder::new(best); From 913a74a6539d9c9a3241f732a7f1ceb371408315 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Mon, 1 Dec 2025 20:53:06 -0600 Subject: [PATCH 11/14] refactor: move resource metering tests to base/ folder Also fix tests to set enforce_resource_metering=true for tests that expect transactions to be rejected due to execution time limits. --- crates/op-rbuilder/src/tests/base/mod.rs | 2 ++ crates/op-rbuilder/src/tests/{ => base}/resource_metering.rs | 2 ++ crates/op-rbuilder/src/tests/mod.rs | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 crates/op-rbuilder/src/tests/base/mod.rs rename crates/op-rbuilder/src/tests/{ => base}/resource_metering.rs (98%) 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/resource_metering.rs b/crates/op-rbuilder/src/tests/base/resource_metering.rs similarity index 98% rename from crates/op-rbuilder/src/tests/resource_metering.rs rename to crates/op-rbuilder/src/tests/base/resource_metering.rs index e43d01e4..797c9f70 100644 --- a/crates/op-rbuilder/src/tests/resource_metering.rs +++ b/crates/op-rbuilder/src/tests/base/resource_metering.rs @@ -17,6 +17,7 @@ const EXECUTION_LIMIT_MS: u64 = 200; 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 })] @@ -105,6 +106,7 @@ async fn missing_metering_information_defaults_to_zero( 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 } diff --git a/crates/op-rbuilder/src/tests/mod.rs b/crates/op-rbuilder/src/tests/mod.rs index aabe1c07..bb576872 100644 --- a/crates/op-rbuilder/src/tests/mod.rs +++ b/crates/op-rbuilder/src/tests/mod.rs @@ -24,7 +24,7 @@ mod ordering; mod revert; #[cfg(test)] -mod resource_metering; +mod base; #[cfg(test)] mod smoke; From 0543f3d79a51b851453c1a4727a1e20eb3226017 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Mon, 1 Dec 2025 20:55:34 -0600 Subject: [PATCH 12/14] test: add coverage for non-enforcing resource metering mode Add tests verifying that when enforce_resource_metering=false, transactions exceeding execution time limits are still included (for both standard and flashblocks builders). --- .../src/tests/base/resource_metering.rs | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/crates/op-rbuilder/src/tests/base/resource_metering.rs b/crates/op-rbuilder/src/tests/base/resource_metering.rs index 797c9f70..6c5254ad 100644 --- a/crates/op-rbuilder/src/tests/base/resource_metering.rs +++ b/crates/op-rbuilder/src/tests/base/resource_metering.rs @@ -43,6 +43,39 @@ async fn execution_time_limit_rejects_excessive_transactions( 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; @@ -100,6 +133,52 @@ async fn missing_metering_information_defaults_to_zero( 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 = { From ce317cde5ab74ad31791a1ecb0e6ff7578d97930 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Tue, 2 Dec 2025 10:43:35 -0600 Subject: [PATCH 13/14] cargo fmt --- crates/op-rbuilder/src/base/flashblocks.rs | 3 ++- crates/op-rbuilder/src/lib.rs | 2 +- crates/op-rbuilder/src/tests/base/resource_metering.rs | 4 +--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/op-rbuilder/src/base/flashblocks.rs b/crates/op-rbuilder/src/base/flashblocks.rs index 71fabe82..a58a9923 100644 --- a/crates/op-rbuilder/src/base/flashblocks.rs +++ b/crates/op-rbuilder/src/base/flashblocks.rs @@ -29,7 +29,8 @@ impl BaseFlashblocksCtx { /// 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, + target_execution_time_us: cumulative_execution_time_us + + self.execution_time_per_batch_us, ..self } } diff --git a/crates/op-rbuilder/src/lib.rs b/crates/op-rbuilder/src/lib.rs index f898150d..3a0b5062 100644 --- a/crates/op-rbuilder/src/lib.rs +++ b/crates/op-rbuilder/src/lib.rs @@ -11,9 +11,9 @@ pub mod traits; pub mod tx; pub mod tx_signer; +pub mod base; #[cfg(test)] pub mod mock_tx; -pub mod base; mod resource_metering; #[cfg(any(test, feature = "testing"))] pub mod tests; diff --git a/crates/op-rbuilder/src/tests/base/resource_metering.rs b/crates/op-rbuilder/src/tests/base/resource_metering.rs index 6c5254ad..fdced178 100644 --- a/crates/op-rbuilder/src/tests/base/resource_metering.rs +++ b/crates/op-rbuilder/src/tests/base/resource_metering.rs @@ -53,9 +53,7 @@ async fn execution_time_limit_rejects_excessive_transactions( args.flashblocks.flashblocks_block_time = EXECUTION_LIMIT_MS; args })] -async fn non_enforcing_mode_includes_all_transactions( - rbuilder: LocalInstance, -) -> eyre::Result<()> { +async fn non_enforcing_mode_includes_all_transactions(rbuilder: LocalInstance) -> eyre::Result<()> { let driver = rbuilder.driver().await?; enable_metering(driver.provider()).await?; From c6f0aab2c0079b9ee949b453ee4cd0af5e724699 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Tue, 2 Dec 2025 11:05:34 -0600 Subject: [PATCH 14/14] cargo clippy --- crates/op-rbuilder/src/builders/context.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/op-rbuilder/src/builders/context.rs b/crates/op-rbuilder/src/builders/context.rs index ae1e0148..24fd85a1 100644 --- a/crates/op-rbuilder/src/builders/context.rs +++ b/crates/op-rbuilder/src/builders/context.rs @@ -384,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,