diff --git a/crates/op-rbuilder/src/args/op.rs b/crates/op-rbuilder/src/args/op.rs
index bd860f155..f1f040606 100644
--- a/crates/op-rbuilder/src/args/op.rs
+++ b/crates/op-rbuilder/src/args/op.rs
@@ -166,6 +166,17 @@ pub struct FlashblocksArgs {
env = "FLASHBLOCK_NUMBER_CONTRACT_ADDRESS"
)]
pub flashblocks_number_contract_address: Option
,
+
+ /// Maximum number of flashblocks per block
+ ///
+ /// This caps the maximum number of flashblocks that can be produced per block,
+ /// regardless of the calculated number based on timing.
+ #[arg(
+ long = "flashblocks.max-per-block",
+ env = "FLASHBLOCKS_MAX_PER_BLOCK",
+ default_value = "10"
+ )]
+ pub max_flashblocks_per_block: u64,
}
impl Default for FlashblocksArgs {
diff --git a/crates/op-rbuilder/src/builders/flashblocks/config.rs b/crates/op-rbuilder/src/builders/flashblocks/config.rs
index f2dca7759..cd587233c 100644
--- a/crates/op-rbuilder/src/builders/flashblocks/config.rs
+++ b/crates/op-rbuilder/src/builders/flashblocks/config.rs
@@ -38,6 +38,12 @@ pub struct FlashblocksConfig {
///
/// If set a builder tx will be added to the start of every flashblock instead of the regular builder tx.
pub flashblocks_number_contract_address: Option,
+
+ /// Maximum number of flashblocks per block.
+ ///
+ /// This caps the maximum number of flashblocks that can be produced per block,
+ /// regardless of the calculated number based on timing.
+ pub max_flashblocks_per_block: u64,
}
impl Default for FlashblocksConfig {
@@ -49,6 +55,7 @@ impl Default for FlashblocksConfig {
fixed: false,
calculate_state_root: true,
flashblocks_number_contract_address: None,
+ max_flashblocks_per_block: 10,
}
}
}
@@ -73,6 +80,8 @@ impl TryFrom for FlashblocksConfig {
let flashblocks_number_contract_address =
args.flashblocks.flashblocks_number_contract_address;
+ let max_flashblocks_per_block = args.flashblocks.max_flashblocks_per_block;
+
Ok(Self {
ws_addr,
interval,
@@ -80,6 +89,7 @@ impl TryFrom for FlashblocksConfig {
fixed,
calculate_state_root,
flashblocks_number_contract_address,
+ max_flashblocks_per_block,
})
}
}
@@ -93,6 +103,7 @@ impl FlashBlocksConfigExt for BuilderConfig {
if self.block_time.as_millis() == 0 {
return 0;
}
- (self.block_time.as_millis() / self.specific.interval.as_millis()) as u64
+ let calculated = (self.block_time.as_millis() / self.specific.interval.as_millis()) as u64;
+ calculated.min(self.specific.max_flashblocks_per_block)
}
}
diff --git a/crates/op-rbuilder/src/builders/flashblocks/payload.rs b/crates/op-rbuilder/src/builders/flashblocks/payload.rs
index ae11a6da6..2a92ac1c8 100644
--- a/crates/op-rbuilder/src/builders/flashblocks/payload.rs
+++ b/crates/op-rbuilder/src/builders/flashblocks/payload.rs
@@ -853,7 +853,7 @@ where
let interval = self.config.specific.interval.as_millis() as u64;
let time_drift = time_drift.as_millis() as u64;
let first_flashblock_offset = time_drift.rem(interval);
- if first_flashblock_offset == 0 {
+ let (calculated_flashblocks, first_offset) = if first_flashblock_offset == 0 {
// We have perfect division, so we use interval as first fb offset
(time_drift.div(interval), Duration::from_millis(interval))
} else {
@@ -862,7 +862,13 @@ where
time_drift.div(interval) + 1,
Duration::from_millis(first_flashblock_offset),
)
- }
+ };
+
+ // Apply the maximum flashblocks per block cap
+ (
+ calculated_flashblocks.min(self.config.specific.max_flashblocks_per_block),
+ first_offset,
+ )
}
}
diff --git a/crates/op-rbuilder/src/tests/flashblocks.rs b/crates/op-rbuilder/src/tests/flashblocks.rs
index 8204af75f..f508e8936 100644
--- a/crates/op-rbuilder/src/tests/flashblocks.rs
+++ b/crates/op-rbuilder/src/tests/flashblocks.rs
@@ -18,6 +18,7 @@ use crate::{
flashblocks_fixed: false,
flashblocks_calculate_state_root: true,
flashblocks_number_contract_address: None,
+ max_flashblocks_per_block: 10,
},
..Default::default()
})]
@@ -57,6 +58,7 @@ async fn smoke_dynamic_base(rbuilder: LocalInstance) -> eyre::Result<()> {
flashblocks_fixed: false,
flashblocks_calculate_state_root: true,
flashblocks_number_contract_address: None,
+ max_flashblocks_per_block: 10,
},
..Default::default()
})]
@@ -96,6 +98,7 @@ async fn smoke_dynamic_unichain(rbuilder: LocalInstance) -> eyre::Result<()> {
flashblocks_fixed: true,
flashblocks_calculate_state_root: true,
flashblocks_number_contract_address: None,
+ max_flashblocks_per_block: 10,
},
..Default::default()
})]
@@ -135,6 +138,7 @@ async fn smoke_classic_unichain(rbuilder: LocalInstance) -> eyre::Result<()> {
flashblocks_fixed: true,
flashblocks_calculate_state_root: true,
flashblocks_number_contract_address: None,
+ max_flashblocks_per_block: 10,
},
..Default::default()
})]
@@ -174,6 +178,7 @@ async fn smoke_classic_base(rbuilder: LocalInstance) -> eyre::Result<()> {
flashblocks_fixed: false,
flashblocks_calculate_state_root: true,
flashblocks_number_contract_address: None,
+ max_flashblocks_per_block: 10,
},
..Default::default()
})]
@@ -220,6 +225,7 @@ async fn unichain_dynamic_with_lag(rbuilder: LocalInstance) -> eyre::Result<()>
flashblocks_fixed: false,
flashblocks_calculate_state_root: true,
flashblocks_number_contract_address: None,
+ max_flashblocks_per_block: 10,
},
..Default::default()
})]
@@ -259,6 +265,7 @@ async fn dynamic_with_full_block_lag(rbuilder: LocalInstance) -> eyre::Result<()
flashblocks_fixed: false,
flashblocks_calculate_state_root: true,
flashblocks_number_contract_address: None,
+ max_flashblocks_per_block: 10,
},
..Default::default()
})]
@@ -320,6 +327,7 @@ async fn test_flashblock_min_filtering(rbuilder: LocalInstance) -> eyre::Result<
flashblocks_fixed: false,
flashblocks_calculate_state_root: true,
flashblocks_number_contract_address: None,
+ max_flashblocks_per_block: 10,
},
..Default::default()
})]
@@ -377,6 +385,7 @@ async fn test_flashblock_max_filtering(rbuilder: LocalInstance) -> eyre::Result<
flashblocks_fixed: false,
flashblocks_calculate_state_root: true,
flashblocks_number_contract_address: None,
+ max_flashblocks_per_block: 10,
},
..Default::default()
})]
@@ -423,6 +432,7 @@ async fn test_flashblock_min_max_filtering(rbuilder: LocalInstance) -> eyre::Res
flashblocks_fixed: false,
flashblocks_calculate_state_root: false,
flashblocks_number_contract_address: None,
+ max_flashblocks_per_block: 10,
},
..Default::default()
})]
@@ -456,3 +466,117 @@ async fn test_flashblocks_no_state_root_calculation(rbuilder: LocalInstance) ->
Ok(())
}
+
+#[rb_test(flashblocks, args = OpRbuilderArgs {
+ chain_block_time: 1000,
+ flashblocks: FlashblocksArgs {
+ enabled: true,
+ flashblocks_port: 1239,
+ flashblocks_addr: "127.0.0.1".into(),
+ flashblocks_block_time: 200,
+ flashblocks_leeway_time: 100,
+ flashblocks_fixed: true,
+ flashblocks_calculate_state_root: true,
+ flashblocks_number_contract_address: None,
+ max_flashblocks_per_block: 3, // Cap at 3 flashblocks instead of default 5
+ },
+ ..Default::default()
+})]
+async fn test_max_flashblocks_per_block_cap(rbuilder: LocalInstance) -> eyre::Result<()> {
+ let driver = rbuilder.driver().await?;
+ let flashblocks_listener = rbuilder.spawn_flashblocks_listener();
+
+ // Send transactions to ensure blocks have activity
+ for _ in 0..5 {
+ let _ = driver
+ .create_transaction()
+ .random_valid_transfer()
+ .send()
+ .await?;
+ }
+
+ // Build a block - normally would produce 5 flashblocks (1000ms / 200ms = 5)
+ // But with max_flashblocks_per_block: 3, it should cap at 3
+ let block = driver.build_new_block().await?;
+ assert_eq!(
+ block.transactions.len(),
+ 8,
+ "Block should contain all transactions"
+ ); // 5 normal txn + deposit + 2 builder txn
+
+ let flashblocks = flashblocks_listener.get_flashblocks();
+ // Should produce only 3 flashblocks + 1 base flashblock = 4 total
+ assert_eq!(
+ 4,
+ flashblocks.len(),
+ "Should be capped at 3 flashblocks + 1 base = 4 total"
+ );
+
+ // Verify flashblock indices are within expected range
+ for fb in &flashblocks {
+ assert!(
+ fb.index <= 3,
+ "Flashblock index should not exceed 3, got index {}",
+ fb.index
+ );
+ }
+
+ flashblocks_listener.stop().await
+}
+
+#[rb_test(flashblocks, args = OpRbuilderArgs {
+ chain_block_time: 2000,
+ flashblocks: FlashblocksArgs {
+ enabled: true,
+ flashblocks_port: 1239,
+ flashblocks_addr: "127.0.0.1".into(),
+ flashblocks_block_time: 100, // Short interval would normally create 20 flashblocks
+ flashblocks_leeway_time: 50,
+ flashblocks_fixed: false,
+ flashblocks_calculate_state_root: true,
+ flashblocks_number_contract_address: None,
+ max_flashblocks_per_block: 5, // Cap at 5 flashblocks
+ },
+ ..Default::default()
+})]
+async fn test_max_flashblocks_dynamic_timing(rbuilder: LocalInstance) -> eyre::Result<()> {
+ let driver = rbuilder.driver().await?;
+ let flashblocks_listener = rbuilder.spawn_flashblocks_listener();
+
+ // Send transactions
+ for _ in 0..3 {
+ let _ = driver
+ .create_transaction()
+ .random_valid_transfer()
+ .send()
+ .await?;
+ }
+
+ // Build block with current timestamp (dynamic timing)
+ // Would normally create ~20 flashblocks (2000ms / 100ms = 20)
+ // But should be capped at 5
+ let block = driver.build_new_block_with_current_timestamp(None).await?;
+ assert!(
+ block.transactions.len() >= 5,
+ "Block should contain transactions"
+ );
+
+ let flashblocks = flashblocks_listener.get_flashblocks();
+ // Should be capped at 5 flashblocks + 1 base = 6 total
+ assert!(
+ flashblocks.len() <= 6,
+ "Should be capped at 5 flashblocks + 1 base = 6 total, got {}",
+ flashblocks.len()
+ );
+
+ // Verify no flashblock index exceeds the cap
+ for fb in &flashblocks {
+ assert!(
+ fb.index <= 5,
+ "Flashblock index should not exceed 5, got index {}",
+ fb.index
+ );
+ }
+
+ flashblocks_listener.stop().await
+}