diff --git a/.env.example b/.env.example index 5f74038..0daed0e 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,9 @@ RPC_PORT=8081 DEBUG_HOST=127.0.0.1 DEBUG_SERVER_PORT=5555 +# Block Selection Args +BLOCK_SELECTION_STRATEGY=builder + # Extra Args TRACING=false LOG_LEVEL=info diff --git a/README.md b/README.md index 9aac354..cddbe73 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ cargo run -- [OPTIONS] - `--no-boost-sync`: Disables using the proposer to sync the builder node (default: true) - `--debug-host `: Host to run the server on (default: 127.0.0.1) - `--debug-server-port `: Port to run the debug server on (default: 5555) +- `--execution-mode `: Initial execution mode to run (default: enabled) +- `--block_selection_strategy`: Selection strategy between the builder and l2 block (default: builder) ### Environment Variables @@ -193,6 +195,21 @@ To run rollup-boost in debug mode with a specific execution mode, you can use th rollup-boost debug set-execution-mode [enabled|dry-run|disabled] ``` +## Block Selection Strategy + +There are several block selection configurations for rollup-boost if both the builder and local block payloads returned are valid: + +- `builder`: the builder payload will always be chosen +- `l2`: the local l2 block will always be chosen +- `gas-used`: a percentage threshold will be used to decide between the builder and local block +- `no-empty-blocks`: if the builder block has no user transactions while the local l2 block does, use the local block + +By default rollup-boost will always choose the builder payload. + +### Gas Usage Block Selection Strategy + +If `gas-used` is chosen as the block selection strategy, the `--required-builder-gas-pct` flag may optionally be specified. This is the percentage value of the local l2 block the builder payload should be within for the builder payload to be chosen. For instance, a value of 70 means that the gas used by the builder block should be at least 70% of the local l2 block, otherwise fallback to the local l2 block. By default it will be at 100, meaning that the local block will be chosen if it used more gas than the builder block. + ## Maintainers - [@avalonche](https://github.com/avalonche) diff --git a/src/cli.rs b/src/cli.rs index 54291fc..60a29b9 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -8,7 +8,8 @@ use tokio::signal::unix::{SignalKind, signal as unix_signal}; use tracing::{Level, info}; use crate::{ - DebugClient, PayloadSource, ProxyLayer, RollupBoostServer, RpcClient, + BlockSelectionArgs, BlockSelectionConfig, BlockSelector, DebugClient, PayloadSource, + ProxyLayer, RollupBoostServer, RpcClient, client::rpc::{BuilderArgs, L2ClientArgs}, init_metrics, init_tracing, server::ExecutionMode, @@ -81,6 +82,10 @@ pub struct Args { /// Execution mode to start rollup boost with #[arg(long, env, default_value = "enabled")] pub execution_mode: ExecutionMode, + + /// Block selection config + #[clap(flatten)] + pub block_selection: BlockSelectionArgs, } impl Args { @@ -155,11 +160,15 @@ impl Args { info!("Boost sync enabled"); } + let block_selection_config = BlockSelectionConfig::from_args(self.block_selection); + let block_selector = BlockSelector::new(block_selection_config); + let rollup_boost = RollupBoostServer::new( l2_client, builder_client, boost_sync_enabled, self.execution_mode, + block_selector, ); // Spawn the debug server diff --git a/src/server.rs b/src/server.rs index 59d83cd..f437369 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,6 +1,7 @@ use crate::client::rpc::RpcClient; use crate::debug_api::DebugServer; use alloy_primitives::{B256, Bytes}; +use clap::Parser; use metrics::counter; use moka::sync::Cache; use opentelemetry::trace::SpanKind; @@ -29,7 +30,7 @@ const CACHE_SIZE: u64 = 100; pub struct PayloadTraceContext { block_hash_to_payload_ids: Cache>, - payload_id: Cache)>, + payload_id: Cache)>, } impl PayloadTraceContext { @@ -45,10 +46,11 @@ impl PayloadTraceContext { payload_id: PayloadId, parent_hash: B256, has_attributes: bool, + no_tx_pool: bool, trace_id: Option, ) { self.payload_id - .insert(payload_id, (has_attributes, trace_id)); + .insert(payload_id, (has_attributes, no_tx_pool, trace_id)); self.block_hash_to_payload_ids .entry(parent_hash) .and_upsert_with(|o| match o { @@ -69,13 +71,13 @@ impl PayloadTraceContext { .map(|payload_ids| { payload_ids .iter() - .filter_map(|payload_id| self.payload_id.get(payload_id).and_then(|x| x.1)) + .filter_map(|payload_id| self.payload_id.get(payload_id).and_then(|x| x.2)) .collect() }) } fn trace_id(&self, payload_id: &PayloadId) -> Option { - self.payload_id.get(payload_id).and_then(|x| x.1) + self.payload_id.get(payload_id).and_then(|x| x.2) } fn has_attributes(&self, payload_id: &PayloadId) -> bool { @@ -85,6 +87,13 @@ impl PayloadTraceContext { .unwrap_or_default() } + fn no_tx_pool(&self, payload_id: &PayloadId) -> bool { + self.payload_id + .get(payload_id) + .map(|x| x.1) + .unwrap_or_default() + } + fn remove_by_parent_hash(&self, block_hash: &B256) { if let Some(payload_ids) = self.block_hash_to_payload_ids.remove(block_hash) { for payload_id in payload_ids.iter() { @@ -107,6 +116,47 @@ pub enum ExecutionMode { Fallback, } +#[derive(Clone, Parser, Debug)] +pub struct BlockSelectionArgs { + #[arg(long, env, default_value = "builder")] + pub block_selection_strategy: BlockSelectionStrategy, + #[arg(long, env)] + pub required_builder_gas_pct: Option, +} + +#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, clap::ValueEnum)] +pub enum BlockSelectionStrategy { + Builder, + L2, + GasUsed, + NoEmptyBlocks, +} + +#[derive(Debug, Clone)] +pub enum BlockSelectionConfig { + // Always use the builder's payload if valid + Builder, + // Always use the L2's payload if valid + L2, + // Percentage of L2 gas used the builder payload should be within to use the builder payload + GasUsed { pct: u64 }, + // Do not use builder payloads if they are empty + NoEmptyBlocks, +} + +impl BlockSelectionConfig { + pub fn from_args(args: BlockSelectionArgs) -> Self { + match args.block_selection_strategy { + BlockSelectionStrategy::Builder => BlockSelectionConfig::Builder, + BlockSelectionStrategy::L2 => BlockSelectionConfig::L2, + BlockSelectionStrategy::GasUsed => BlockSelectionConfig::GasUsed { + pct: args.required_builder_gas_pct.unwrap_or(100), + }, + BlockSelectionStrategy::NoEmptyBlocks => BlockSelectionConfig::NoEmptyBlocks, + } + } +} + impl ExecutionMode { fn is_get_payload_enabled(&self) -> bool { // get payload is only enabled in 'enabled' mode @@ -122,6 +172,65 @@ impl ExecutionMode { } } +#[derive(Debug, Clone)] +pub struct BlockSelector { + config: BlockSelectionConfig, + payload_id_to_tx_count: Cache, +} + +impl BlockSelector { + pub fn new(config: BlockSelectionConfig) -> Self { + BlockSelector { + config, + payload_id_to_tx_count: Cache::new(CACHE_SIZE), + } + } + + fn store_tx_count(&self, payload_id: PayloadId, tx_count: usize) { + if matches!(self.config, BlockSelectionConfig::NoEmptyBlocks) { + self.payload_id_to_tx_count.insert(payload_id, tx_count); + } + } + + // Returns the payload and payload source + fn select_block( + &self, + payload_id: PayloadId, + l2_payload: OpExecutionPayloadEnvelope, + builder_payload: OpExecutionPayloadEnvelope, + ) -> (OpExecutionPayloadEnvelope, PayloadSource) { + match self.config { + BlockSelectionConfig::Builder => (builder_payload, PayloadSource::Builder), + BlockSelectionConfig::L2 => (l2_payload, PayloadSource::L2), + BlockSelectionConfig::GasUsed { pct } => { + // If the builder payload gas used is more than or equal to the L2 payload gas used * the percentage, we use the builder payload + if builder_payload.gas_used() * 100 >= l2_payload.gas_used() * pct { + (builder_payload, PayloadSource::Builder) + } else { + (l2_payload, PayloadSource::L2) + } + } + BlockSelectionConfig::NoEmptyBlocks => { + let tx_count = self.payload_id_to_tx_count.get(&payload_id); + if let Some(tx_count) = tx_count { + let builder_tx_count = builder_payload.transactions().len(); + let l2_tx_count = l2_payload.transactions().len(); + // Builder payload only contains the transactions from the sequencer and the builder transaction + // and considered an empty block as there are no user transactions. + // We use the l2 payload if the builder payload is empty and the l2 payload has user transactions + if builder_tx_count == tx_count + 1 && builder_tx_count < l2_tx_count + 1 { + return (l2_payload, PayloadSource::L2); + } + (builder_payload, PayloadSource::Builder) + } else { + // If payload attributes are not present, we default to the l2 payload + (l2_payload, PayloadSource::L2) + } + } + } + } +} + #[derive(Clone)] pub struct RollupBoostServer { pub l2_client: Arc, @@ -129,6 +238,7 @@ pub struct RollupBoostServer { pub boost_sync: bool, pub payload_trace_context: Arc, execution_mode: Arc>, + block_selector: BlockSelector, } impl RollupBoostServer { @@ -137,6 +247,7 @@ impl RollupBoostServer { builder_client: RpcClient, boost_sync: bool, initial_execution_mode: ExecutionMode, + block_selector: BlockSelector, ) -> Self { Self { l2_client: Arc::new(l2_client), @@ -144,6 +255,7 @@ impl RollupBoostServer { boost_sync, payload_trace_context: Arc::new(PayloadTraceContext::new()), execution_mode: Arc::new(Mutex::new(initial_execution_mode)), + block_selector, } } @@ -284,13 +396,25 @@ impl EngineApiServer for RollupBoostServer { let execution_mode = self.execution_mode(); let trace_id = span.id(); + + let has_attributes = payload_attributes.is_some(); + let no_tx_pool = payload_attributes + .as_ref() + .is_some_and(|attr| attr.no_tx_pool.unwrap_or_default()); + let tx_count = payload_attributes + .as_ref() + .map_or(0, |attr| attr.transactions.as_ref().map_or(0, |t| t.len())); if let Some(payload_id) = l2_response.payload_id { self.payload_trace_context.store( payload_id, fork_choice_state.head_block_hash, - payload_attributes.is_some(), + has_attributes, + no_tx_pool, trace_id, ); + if has_attributes { + self.block_selector.store_tx_count(payload_id, tx_count); + } } if execution_mode.is_disabled() { @@ -421,6 +545,39 @@ impl OpExecutionPayloadEnvelope { OpExecutionPayloadEnvelope::V4(_) => Version::V4, } } + + pub fn transactions(&self) -> &[Bytes] { + match self { + OpExecutionPayloadEnvelope::V3(v3) => { + &v3.execution_payload + .payload_inner + .payload_inner + .transactions + } + OpExecutionPayloadEnvelope::V4(v4) => { + &v4.execution_payload + .payload_inner + .payload_inner + .payload_inner + .transactions + } + } + } + + pub fn gas_used(&self) -> u64 { + match self { + OpExecutionPayloadEnvelope::V3(v3) => { + v3.execution_payload.payload_inner.payload_inner.gas_used + } + OpExecutionPayloadEnvelope::V4(v4) => { + v4.execution_payload + .payload_inner + .payload_inner + .payload_inner + .gas_used + } + } + } } impl From for ExecutionPayload { @@ -560,8 +717,11 @@ impl RollupBoostServer { tracing::Span::current().follows_from(cause); } - if !self.payload_trace_context.has_attributes(&payload_id) { - // block builder won't build a block without attributes + if (!self.payload_trace_context.has_attributes(&payload_id)) + || (self.payload_trace_context.has_attributes(&payload_id) + && self.payload_trace_context.no_tx_pool(&payload_id)) + { + // block builder won't build a block without attributes or if no_tx_pool is true info!(message = "no attributes found, skipping get_payload call to builder"); return Ok(None); } @@ -587,7 +747,9 @@ impl RollupBoostServer { // Default to op-geth's payload Ok((l2_payload, PayloadSource::L2)) } else { - Ok((builder, PayloadSource::Builder)) + Ok(self + .block_selector + .select_block(payload_id, l2_payload, builder)) } } (_, Ok(l2)) => Ok((l2, PayloadSource::L2)), @@ -728,12 +890,18 @@ mod tests { Uri::from_str(&format!("http://{}:{}", HOST, BUILDER_PORT)).unwrap(); let builder_client = RpcClient::new(builder_auth_rpc, jwt_secret, 2000, PayloadSource::Builder).unwrap(); + let block_selection_config = BlockSelectionConfig::from_args(BlockSelectionArgs { + block_selection_strategy: BlockSelectionStrategy::Builder, + required_builder_gas_pct: None, + }); + let block_selector = BlockSelector::new(block_selection_config); let rollup_boost_client = RollupBoostServer::new( l2_client, builder_client, boost_sync, ExecutionMode::Enabled, + block_selector, ); let module: RpcModule<()> = rollup_boost_client.try_into().unwrap();