From ad2d384e0329dcf11bb0fd9b5f3415a40af9019a Mon Sep 17 00:00:00 2001 From: Salman Pathan Date: Wed, 12 Nov 2025 16:37:21 +0530 Subject: [PATCH 1/6] chore: add target: flashblock for all flashblock related traces (#19656) --- crates/optimism/flashblocks/src/consensus.rs | 4 ++-- crates/optimism/flashblocks/src/sequence.rs | 6 +++--- crates/optimism/flashblocks/src/service.rs | 21 +++++++++++++------- crates/optimism/flashblocks/src/worker.rs | 4 ++-- crates/optimism/flashblocks/src/ws/stream.rs | 4 +++- 5 files changed, 24 insertions(+), 15 deletions(-) diff --git a/crates/optimism/flashblocks/src/consensus.rs b/crates/optimism/flashblocks/src/consensus.rs index 60314d2f6c8..8df2e01a07b 100644 --- a/crates/optimism/flashblocks/src/consensus.rs +++ b/crates/optimism/flashblocks/src/consensus.rs @@ -51,7 +51,7 @@ impl FlashBlockConsensusClient { previous_block_hashes.push(block_hash); if sequence.state_root().is_none() { - warn!("Missing state root for the complete sequence") + warn!(target: "flashblocks", "Missing state root for the complete sequence") } // Load previous block hashes. We're using (head - 32) and (head - 64) as the @@ -74,7 +74,7 @@ impl FlashBlockConsensusClient { } Err(err) => { warn!( - target: "consensus::flashblock-client", + target: "flashblocks", %err, "error while fetching flashblock completed sequence" ); diff --git a/crates/optimism/flashblocks/src/sequence.rs b/crates/optimism/flashblocks/src/sequence.rs index f2363207e38..4f254d07f9a 100644 --- a/crates/optimism/flashblocks/src/sequence.rs +++ b/crates/optimism/flashblocks/src/sequence.rs @@ -81,7 +81,7 @@ where /// A [`FlashBlock`] with index 0 resets the set. pub fn insert(&mut self, flashblock: FlashBlock) -> eyre::Result<()> { if flashblock.index == 0 { - trace!(number=%flashblock.block_number(), "Tracking new flashblock sequence"); + trace!(target: "flashblocks", number=%flashblock.block_number(), "Tracking new flashblock sequence"); // Flash block at index zero resets the whole state. self.clear_and_broadcast_blocks(); @@ -96,10 +96,10 @@ where let same_payload = self.payload_id() == Some(flashblock.payload_id); if same_block && same_payload { - trace!(number=%flashblock.block_number(), index = %flashblock.index, block_count = self.inner.len() ,"Received followup flashblock"); + trace!(target: "flashblocks", number=%flashblock.block_number(), index = %flashblock.index, block_count = self.inner.len() ,"Received followup flashblock"); self.inner.insert(flashblock.index, PreparedFlashBlock::new(flashblock)?); } else { - trace!(number=%flashblock.block_number(), index = %flashblock.index, current=?self.block_number() ,"Ignoring untracked flashblock following"); + trace!(target: "flashblocks", number=%flashblock.block_number(), index = %flashblock.index, current=?self.block_number() ,"Ignoring untracked flashblock following"); } Ok(()) diff --git a/crates/optimism/flashblocks/src/service.rs b/crates/optimism/flashblocks/src/service.rs index f5d4a4a810d..71b525ea459 100644 --- a/crates/optimism/flashblocks/src/service.rs +++ b/crates/optimism/flashblocks/src/service.rs @@ -137,12 +137,14 @@ where /// Note: this should be spawned pub async fn run(mut self, tx: tokio::sync::watch::Sender>>) { while let Some(block) = self.next().await { - if let Ok(block) = block.inspect_err(|e| tracing::error!("{e}")) { - let _ = tx.send(block).inspect_err(|e| tracing::error!("{e}")); + if let Ok(block) = block.inspect_err(|e| tracing::error!(target: "flashblocks", "{e}")) + { + let _ = + tx.send(block).inspect_err(|e| tracing::error!(target: "flashblocks", "{e}")); } } - warn!("Flashblock service has stopped"); + warn!(target: "flashblocks", "Flashblock service has stopped"); } /// Notifies all subscribers about the received flashblock @@ -165,6 +167,7 @@ where > { let Some(base) = self.blocks.payload_base() else { trace!( + target: "flashblocks", flashblock_number = ?self.blocks.block_number(), count = %self.blocks.count(), "Missing flashblock payload base" @@ -177,12 +180,12 @@ where if let Some(latest) = self.builder.provider().latest_header().ok().flatten() && latest.hash() != base.parent_hash { - trace!(flashblock_parent=?base.parent_hash, flashblock_number=base.block_number, local_latest=?latest.num_hash(), "Skipping non consecutive build attempt"); + trace!(target: "flashblocks", flashblock_parent=?base.parent_hash, flashblock_number=base.block_number, local_latest=?latest.num_hash(), "Skipping non consecutive build attempt"); return None } let Some(last_flashblock) = self.blocks.last_flashblock() else { - trace!(flashblock_number = ?self.blocks.block_number(), count = %self.blocks.count(), "Missing last flashblock"); + trace!(target: "flashblocks", flashblock_number = ?self.blocks.block_number(), count = %self.blocks.count(), "Missing last flashblock"); return None }; @@ -276,6 +279,7 @@ where let elapsed = now.elapsed(); this.metrics.execution_duration.record(elapsed.as_secs_f64()); trace!( + target: "flashblocks", parent_hash = %new_pending.block().parent_hash(), block_number = new_pending.block().number(), flash_blocks = this.blocks.count(), @@ -290,7 +294,7 @@ where } Err(err) => { // we can ignore this error - debug!(%err, "failed to execute flashblock"); + debug!(target: "flashblocks", %err, "failed to execute flashblock"); } } } @@ -305,7 +309,9 @@ where } match this.blocks.insert(flashblock) { Ok(_) => this.rebuild = true, - Err(err) => debug!(%err, "Failed to prepare flashblock"), + Err(err) => { + debug!(target: "flashblocks", %err, "Failed to prepare flashblock") + } } } Err(err) => return Poll::Ready(Some(Err(err))), @@ -320,6 +326,7 @@ where } && let Some(current) = this.on_new_tip(state) { trace!( + target: "flashblocks", parent_hash = %current.block().parent_hash(), block_number = current.block().number(), "Clearing current flashblock on new canonical block" diff --git a/crates/optimism/flashblocks/src/worker.rs b/crates/optimism/flashblocks/src/worker.rs index 8cf7777f6a6..2112d93af26 100644 --- a/crates/optimism/flashblocks/src/worker.rs +++ b/crates/optimism/flashblocks/src/worker.rs @@ -67,7 +67,7 @@ where &self, mut args: BuildArgs, ) -> eyre::Result, CachedReads)>> { - trace!("Attempting new pending block from flashblocks"); + trace!(target: "flashblocks", "Attempting new pending block from flashblocks"); let latest = self .provider @@ -76,7 +76,7 @@ where let latest_hash = latest.hash(); if args.base.parent_hash != latest_hash { - trace!(flashblock_parent = ?args.base.parent_hash, local_latest=?latest.num_hash(),"Skipping non consecutive flashblock"); + trace!(target: "flashblocks", flashblock_parent = ?args.base.parent_hash, local_latest=?latest.num_hash(),"Skipping non consecutive flashblock"); // doesn't attach to the latest block return Ok(None) } diff --git a/crates/optimism/flashblocks/src/ws/stream.rs b/crates/optimism/flashblocks/src/ws/stream.rs index 64cf6f718e2..99128dcf131 100644 --- a/crates/optimism/flashblocks/src/ws/stream.rs +++ b/crates/optimism/flashblocks/src/ws/stream.rs @@ -126,7 +126,9 @@ where } Ok(Message::Ping(bytes)) => this.ping(bytes), Ok(Message::Close(frame)) => this.close(frame), - Ok(msg) => debug!("Received unexpected message: {:?}", msg), + Ok(msg) => { + debug!(target: "flashblocks", "Received unexpected message: {:?}", msg) + } Err(err) => return Poll::Ready(Some(Err(err.into()))), } } From c86715ef2cc8cc38dc2e9af9ca7de43fef360ad4 Mon Sep 17 00:00:00 2001 From: Francis Li Date: Wed, 12 Nov 2025 04:26:42 -0800 Subject: [PATCH 2/6] refactor(flashblock): Move all flashblocks related data structure to op-alloy (#19608) --- Cargo.lock | 25 ++- Cargo.toml | 10 +- crates/optimism/evm/src/config.rs | 14 ++ crates/optimism/flashblocks/Cargo.toml | 6 +- crates/optimism/flashblocks/src/lib.rs | 16 +- crates/optimism/flashblocks/src/payload.rs | 149 +----------------- crates/optimism/flashblocks/src/sequence.rs | 40 +++-- crates/optimism/flashblocks/src/service.rs | 11 +- crates/optimism/flashblocks/src/worker.rs | 9 +- .../optimism/flashblocks/src/ws/decoding.rs | 57 ++----- crates/optimism/flashblocks/src/ws/mod.rs | 2 + crates/optimism/flashblocks/src/ws/stream.rs | 21 +-- crates/optimism/rpc/src/eth/mod.rs | 7 +- examples/custom-node/Cargo.toml | 1 - examples/custom-node/src/evm/config.rs | 6 +- 15 files changed, 108 insertions(+), 266 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f4d7d04783c..75ed8516622 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3631,7 +3631,6 @@ dependencies = [ "reth-network-peers", "reth-node-builder", "reth-op", - "reth-optimism-flashblocks", "reth-optimism-forks", "reth-payload-builder", "reth-rpc-api", @@ -6291,9 +6290,9 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "op-alloy-consensus" -version = "0.22.0" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e42e9de945efe3c2fbd207e69720c9c1af2b8caa6872aee0e216450c25a3ca70" +checksum = "a0d7ec388eb83a3e6c71774131dbbb2ba9c199b6acac7dce172ed8de2f819e91" dependencies = [ "alloy-consensus", "alloy-eips", @@ -6317,9 +6316,9 @@ checksum = "a79f352fc3893dcd670172e615afef993a41798a1d3fc0db88a3e60ef2e70ecc" [[package]] name = "op-alloy-network" -version = "0.22.0" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9da49a2812a0189dd05e81e4418c3ae13fd607a92654107f02ebad8e91ed9e" +checksum = "979fe768bbb571d1d0bd7f84bc35124243b4db17f944b94698872a4701e743a0" dependencies = [ "alloy-consensus", "alloy-network", @@ -6333,9 +6332,9 @@ dependencies = [ [[package]] name = "op-alloy-rpc-jsonrpsee" -version = "0.22.0" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b62ceb771ab9323647093ea2e58dc7f25289a1b95cbef2faa2620f6ca2dee4d9" +checksum = "7bdbb3c0453fe2605fb008851ea0b45f3f2ba607722c9f2e4ffd7198958ce501" dependencies = [ "alloy-primitives", "jsonrpsee", @@ -6343,9 +6342,9 @@ dependencies = [ [[package]] name = "op-alloy-rpc-types" -version = "0.22.0" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cd1eb7bddd2232856ba9d259320a094f9edf2b9061acfe5966e7960208393e6" +checksum = "cc252b5fa74dbd33aa2f9a40e5ff9cfe34ed2af9b9b235781bc7cc8ec7d6aca8" dependencies = [ "alloy-consensus", "alloy-eips", @@ -6363,9 +6362,9 @@ dependencies = [ [[package]] name = "op-alloy-rpc-types-engine" -version = "0.22.0" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5429622150d18d8e6847a701135082622413e2451b64d03f979415d764566bef" +checksum = "c1abe694cd6718b8932da3f824f46778be0f43289e4103c88abc505c63533a04" dependencies = [ "alloy-consensus", "alloy-eips", @@ -9749,19 +9748,18 @@ dependencies = [ "alloy-eips", "alloy-primitives", "alloy-rpc-types-engine", - "alloy-serde", "brotli", "derive_more", "eyre", "futures-util", "metrics", + "op-alloy-rpc-types-engine", "reth-chain-state", "reth-engine-primitives", "reth-errors", "reth-evm", "reth-execution-types", "reth-metrics", - "reth-optimism-evm", "reth-optimism-payload-builder", "reth-optimism-primitives", "reth-payload-primitives", @@ -9771,7 +9769,6 @@ dependencies = [ "reth-storage-api", "reth-tasks", "ringbuffer", - "serde", "serde_json", "test-case", "tokio", diff --git a/Cargo.toml b/Cargo.toml index d09dd011ed7..768913f20c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -528,11 +528,11 @@ alloy-transport-ws = { version = "1.1.0", default-features = false } # op alloy-op-evm = { version = "0.23.0", default-features = false } alloy-op-hardforks = "0.4.4" -op-alloy-rpc-types = { version = "0.22.0", default-features = false } -op-alloy-rpc-types-engine = { version = "0.22.0", default-features = false } -op-alloy-network = { version = "0.22.0", default-features = false } -op-alloy-consensus = { version = "0.22.0", default-features = false } -op-alloy-rpc-jsonrpsee = { version = "0.22.0", default-features = false } +op-alloy-rpc-types = { version = "0.22.1", default-features = false } +op-alloy-rpc-types-engine = { version = "0.22.1", default-features = false } +op-alloy-network = { version = "0.22.1", default-features = false } +op-alloy-consensus = { version = "0.22.1", default-features = false } +op-alloy-rpc-jsonrpsee = { version = "0.22.1", default-features = false } op-alloy-flz = { version = "0.13.1", default-features = false } # misc diff --git a/crates/optimism/evm/src/config.rs b/crates/optimism/evm/src/config.rs index 6ae2a91a6cb..1f1068c40d9 100644 --- a/crates/optimism/evm/src/config.rs +++ b/crates/optimism/evm/src/config.rs @@ -1,6 +1,7 @@ pub use alloy_op_evm::{ spec as revm_spec, spec_by_timestamp_after_bedrock as revm_spec_by_timestamp_after_bedrock, }; +use op_alloy_rpc_types_engine::OpFlashblockPayloadBase; use revm::primitives::{Address, Bytes, B256}; /// Context relevant for execution of a next block w.r.t OP. @@ -35,3 +36,16 @@ impl reth_rpc_eth_api::helpers::pending_block:: } } } + +impl From for OpNextBlockEnvAttributes { + fn from(base: OpFlashblockPayloadBase) -> Self { + Self { + timestamp: base.timestamp, + suggested_fee_recipient: base.fee_recipient, + prev_randao: base.prev_randao, + gas_limit: base.gas_limit, + parent_beacon_block_root: Some(base.parent_beacon_block_root), + extra_data: base.extra_data, + } + } +} diff --git a/crates/optimism/flashblocks/Cargo.toml b/crates/optimism/flashblocks/Cargo.toml index 977e28d37e1..3bb0038a512 100644 --- a/crates/optimism/flashblocks/Cargo.toml +++ b/crates/optimism/flashblocks/Cargo.toml @@ -13,7 +13,6 @@ workspace = true [dependencies] # reth reth-optimism-primitives = { workspace = true, features = ["serde"] } -reth-optimism-evm.workspace = true reth-chain-state = { workspace = true, features = ["serde"] } reth-primitives-traits = { workspace = true, features = ["serde"] } reth-engine-primitives = { workspace = true, features = ["std"] } @@ -30,15 +29,16 @@ reth-metrics.workspace = true # alloy alloy-eips = { workspace = true, features = ["serde"] } -alloy-serde.workspace = true alloy-primitives = { workspace = true, features = ["serde"] } alloy-rpc-types-engine = { workspace = true, features = ["serde"] } alloy-consensus.workspace = true +# op-alloy +op-alloy-rpc-types-engine.workspace = true + # io tokio.workspace = true tokio-tungstenite = { workspace = true, features = ["rustls-tls-native-roots"] } -serde.workspace = true serde_json.workspace = true url.workspace = true futures-util.workspace = true diff --git a/crates/optimism/flashblocks/src/lib.rs b/crates/optimism/flashblocks/src/lib.rs index 7220f443cc1..74f202aed7a 100644 --- a/crates/optimism/flashblocks/src/lib.rs +++ b/crates/optimism/flashblocks/src/lib.rs @@ -11,23 +11,25 @@ use reth_primitives_traits::NodePrimitives; use std::sync::Arc; -pub use payload::{ - ExecutionPayloadBaseV1, ExecutionPayloadFlashblockDeltaV1, FlashBlock, FlashBlockDecoder, - Metadata, -}; -pub use service::{FlashBlockBuildInfo, FlashBlockService}; -pub use ws::{WsConnect, WsFlashBlockStream}; +// Included to enable serde feature for OpReceipt type used transitively +use reth_optimism_primitives as _; mod consensus; pub use consensus::FlashBlockConsensusClient; + mod payload; -pub use payload::PendingFlashBlock; +pub use payload::{FlashBlock, PendingFlashBlock}; + mod sequence; pub use sequence::{FlashBlockCompleteSequence, FlashBlockPendingSequence}; mod service; +pub use service::{FlashBlockBuildInfo, FlashBlockService}; + mod worker; + mod ws; +pub use ws::{WsConnect, WsFlashBlockStream}; /// Receiver of the most recent [`PendingFlashBlock`] built out of [`FlashBlock`]s. /// diff --git a/crates/optimism/flashblocks/src/payload.rs b/crates/optimism/flashblocks/src/payload.rs index 7469538ee3b..c7031c18567 100644 --- a/crates/optimism/flashblocks/src/payload.rs +++ b/crates/optimism/flashblocks/src/payload.rs @@ -1,154 +1,11 @@ use alloy_consensus::BlockHeader; -use alloy_eips::eip4895::Withdrawal; -use alloy_primitives::{bytes, Address, Bloom, Bytes, B256, U256}; -use alloy_rpc_types_engine::PayloadId; +use alloy_primitives::B256; use derive_more::Deref; -use reth_optimism_evm::OpNextBlockEnvAttributes; -use reth_optimism_primitives::OpReceipt; use reth_primitives_traits::NodePrimitives; use reth_rpc_eth_types::PendingBlock; -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; -/// Represents a Flashblock, a real-time block-like structure emitted by the Base L2 chain. -/// -/// A Flashblock provides a snapshot of a block’s effects before finalization, -/// allowing faster insight into state transitions, balance changes, and logs. -/// It includes a diff of the block’s execution and associated metadata. -/// -/// See: [Base Flashblocks Documentation](https://docs.base.org/chain/flashblocks) -#[derive(Default, Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] -pub struct FlashBlock { - /// The unique payload ID as assigned by the execution engine for this block. - pub payload_id: PayloadId, - /// A sequential index that identifies the order of this Flashblock. - pub index: u64, - /// A subset of block header fields. - pub base: Option, - /// The execution diff representing state transitions and transactions. - pub diff: ExecutionPayloadFlashblockDeltaV1, - /// Additional metadata about the block such as receipts and balances. - pub metadata: Metadata, -} - -impl FlashBlock { - /// Returns the block number of this flashblock. - pub const fn block_number(&self) -> u64 { - self.metadata.block_number - } - - /// Returns the first parent hash of this flashblock. - pub fn parent_hash(&self) -> Option { - Some(self.base.as_ref()?.parent_hash) - } - - /// Returns the receipt for the given transaction hash. - pub fn receipt_by_hash(&self, hash: &B256) -> Option<&OpReceipt> { - self.metadata.receipt_by_hash(hash) - } -} - -/// A trait for decoding flashblocks from bytes. -pub trait FlashBlockDecoder: Send + 'static { - /// Decodes `bytes` into a [`FlashBlock`]. - fn decode(&self, bytes: bytes::Bytes) -> eyre::Result; -} - -/// Default implementation of the decoder. -impl FlashBlockDecoder for () { - fn decode(&self, bytes: bytes::Bytes) -> eyre::Result { - FlashBlock::decode(bytes) - } -} - -/// Provides metadata about the block that may be useful for indexing or analysis. -// Note: this uses mixed camel, snake case: -#[derive(Default, Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] -pub struct Metadata { - /// The number of the block in the L2 chain. - pub block_number: u64, - /// A map of addresses to their updated balances after the block execution. - /// This represents balance changes due to transactions, rewards, or system transfers. - pub new_account_balances: BTreeMap, - /// Execution receipts for all transactions in the block. - /// Contains logs, gas usage, and other EVM-level metadata. - pub receipts: BTreeMap, -} - -impl Metadata { - /// Returns the receipt for the given transaction hash. - pub fn receipt_by_hash(&self, hash: &B256) -> Option<&OpReceipt> { - self.receipts.get(hash) - } -} - -/// Represents the base configuration of an execution payload that remains constant -/// throughout block construction. This includes fundamental block properties like -/// parent hash, block number, and other header fields that are determined at -/// block creation and cannot be modified. -#[derive(Clone, Debug, Eq, PartialEq, Default, Deserialize, Serialize)] -pub struct ExecutionPayloadBaseV1 { - /// Ecotone parent beacon block root - pub parent_beacon_block_root: B256, - /// The parent hash of the block. - pub parent_hash: B256, - /// The fee recipient of the block. - pub fee_recipient: Address, - /// The previous randao of the block. - pub prev_randao: B256, - /// The block number. - #[serde(with = "alloy_serde::quantity")] - pub block_number: u64, - /// The gas limit of the block. - #[serde(with = "alloy_serde::quantity")] - pub gas_limit: u64, - /// The timestamp of the block. - #[serde(with = "alloy_serde::quantity")] - pub timestamp: u64, - /// The extra data of the block. - pub extra_data: Bytes, - /// The base fee per gas of the block. - pub base_fee_per_gas: U256, -} - -/// Represents the modified portions of an execution payload within a flashblock. -/// This structure contains only the fields that can be updated during block construction, -/// such as state root, receipts, logs, and new transactions. Other immutable block fields -/// like parent hash and block number are excluded since they remain constant throughout -/// the block's construction. -#[derive(Clone, Debug, Eq, PartialEq, Default, Deserialize, Serialize)] -pub struct ExecutionPayloadFlashblockDeltaV1 { - /// The state root of the block. - pub state_root: B256, - /// The receipts root of the block. - pub receipts_root: B256, - /// The logs bloom of the block. - pub logs_bloom: Bloom, - /// The gas used of the block. - #[serde(with = "alloy_serde::quantity")] - pub gas_used: u64, - /// The block hash of the block. - pub block_hash: B256, - /// The transactions of the block. - pub transactions: Vec, - /// Array of [`Withdrawal`] enabled with V2 - pub withdrawals: Vec, - /// The withdrawals root of the block. - pub withdrawals_root: B256, -} - -impl From for OpNextBlockEnvAttributes { - fn from(value: ExecutionPayloadBaseV1) -> Self { - Self { - timestamp: value.timestamp, - suggested_fee_recipient: value.fee_recipient, - prev_randao: value.prev_randao, - gas_limit: value.gas_limit, - parent_beacon_block_root: Some(value.parent_beacon_block_root), - extra_data: value.extra_data, - } - } -} +/// Type alias for the Optimism flashblock payload. +pub type FlashBlock = op_alloy_rpc_types_engine::OpFlashblockPayload; /// The pending block built with all received Flashblocks alongside the metadata for the last added /// Flashblock. diff --git a/crates/optimism/flashblocks/src/sequence.rs b/crates/optimism/flashblocks/src/sequence.rs index 4f254d07f9a..0bcde2ace17 100644 --- a/crates/optimism/flashblocks/src/sequence.rs +++ b/crates/optimism/flashblocks/src/sequence.rs @@ -1,9 +1,10 @@ -use crate::{ExecutionPayloadBaseV1, FlashBlock, FlashBlockCompleteSequenceRx}; +use crate::{FlashBlock, FlashBlockCompleteSequenceRx}; use alloy_eips::eip2718::WithEncoded; -use alloy_primitives::B256; +use alloy_primitives::{Bytes, B256}; use alloy_rpc_types_engine::PayloadId; use core::mem; use eyre::{bail, OptionExt}; +use op_alloy_rpc_types_engine::OpFlashblockPayloadBase; use reth_primitives_traits::{Recovered, SignedTransaction}; use std::{collections::BTreeMap, ops::Deref}; use tokio::sync::broadcast; @@ -92,7 +93,7 @@ where // only insert if we previously received the same block and payload, assume we received // index 0 - let same_block = self.block_number() == Some(flashblock.metadata.block_number); + let same_block = self.block_number() == Some(flashblock.block_number()); let same_payload = self.payload_id() == Some(flashblock.payload_id); if same_block && same_payload { @@ -129,11 +130,11 @@ where /// Returns the first block number pub fn block_number(&self) -> Option { - Some(self.inner.values().next()?.block().metadata.block_number) + Some(self.inner.values().next()?.block().block_number()) } /// Returns the payload base of the first tracked flashblock. - pub fn payload_base(&self) -> Option { + pub fn payload_base(&self) -> Option { self.inner.values().next()?.block().base.clone() } @@ -194,7 +195,7 @@ impl FlashBlockCompleteSequence { if !blocks.iter().enumerate().all(|(idx, block)| { idx == block.index as usize && block.payload_id == first_block.payload_id && - block.metadata.block_number == first_block.metadata.block_number + block.block_number() == first_block.block_number() }) { bail!("Flashblock inconsistencies detected in sequence"); } @@ -204,11 +205,11 @@ impl FlashBlockCompleteSequence { /// Returns the block number pub fn block_number(&self) -> u64 { - self.inner.first().unwrap().metadata.block_number + self.inner.first().unwrap().block_number() } /// Returns the payload base of the first flashblock. - pub fn payload_base(&self) -> &ExecutionPayloadBaseV1 { + pub fn payload_base(&self) -> &OpFlashblockPayloadBase { self.inner.first().unwrap().base.as_ref().unwrap() } @@ -226,6 +227,11 @@ impl FlashBlockCompleteSequence { pub const fn state_root(&self) -> Option { self.state_root } + + /// Returns all transactions from all flashblocks in the sequence + pub fn all_transactions(&self) -> Vec { + self.inner.iter().flat_map(|fb| fb.diff.transactions.iter().cloned()).collect() + } } impl Deref for FlashBlockCompleteSequence { @@ -297,12 +303,14 @@ impl Deref for PreparedFlashBlock { #[cfg(test)] mod tests { use super::*; - use crate::ExecutionPayloadFlashblockDeltaV1; use alloy_consensus::{ transaction::SignerRecoverable, EthereumTxEnvelope, EthereumTypedTransaction, TxEip1559, }; use alloy_eips::Encodable2718; use alloy_primitives::{hex, Signature, TxKind, U256}; + use op_alloy_rpc_types_engine::{ + OpFlashblockPayload, OpFlashblockPayloadBase, OpFlashblockPayloadDelta, + }; #[test] fn test_sequence_stops_before_gap() { @@ -332,11 +340,11 @@ mod tests { let tx = Recovered::new_unchecked(tx.clone(), tx.recover_signer_unchecked().unwrap()); sequence - .insert(FlashBlock { + .insert(OpFlashblockPayload { payload_id: Default::default(), index: 0, base: None, - diff: ExecutionPayloadFlashblockDeltaV1 { + diff: OpFlashblockPayloadDelta { transactions: vec![tx.encoded_2718().into()], ..Default::default() }, @@ -345,7 +353,7 @@ mod tests { .unwrap(); sequence - .insert(FlashBlock { + .insert(OpFlashblockPayload { payload_id: Default::default(), index: 2, base: None, @@ -367,10 +375,10 @@ mod tests { for idx in 0..10 { sequence - .insert(FlashBlock { + .insert(OpFlashblockPayload { payload_id: Default::default(), index: idx, - base: Some(ExecutionPayloadBaseV1::default()), + base: Some(OpFlashblockPayloadBase::default()), diff: Default::default(), metadata: Default::default(), }) @@ -385,10 +393,10 @@ mod tests { // Let's insert a new flashblock with index 0 sequence - .insert(FlashBlock { + .insert(OpFlashblockPayload { payload_id: Default::default(), index: 0, - base: Some(ExecutionPayloadBaseV1::default()), + base: Some(OpFlashblockPayloadBase::default()), diff: Default::default(), metadata: Default::default(), }) diff --git a/crates/optimism/flashblocks/src/service.rs b/crates/optimism/flashblocks/src/service.rs index 71b525ea459..2c305529bda 100644 --- a/crates/optimism/flashblocks/src/service.rs +++ b/crates/optimism/flashblocks/src/service.rs @@ -1,13 +1,14 @@ use crate::{ sequence::FlashBlockPendingSequence, worker::{BuildArgs, FlashBlockBuilder}, - ExecutionPayloadBaseV1, FlashBlock, FlashBlockCompleteSequence, FlashBlockCompleteSequenceRx, - InProgressFlashBlockRx, PendingFlashBlock, + FlashBlock, FlashBlockCompleteSequence, FlashBlockCompleteSequenceRx, InProgressFlashBlockRx, + PendingFlashBlock, }; use alloy_eips::eip2718::WithEncoded; use alloy_primitives::B256; use futures_util::{FutureExt, Stream, StreamExt}; use metrics::Histogram; +use op_alloy_rpc_types_engine::OpFlashblockPayloadBase; use reth_chain_state::{CanonStateNotification, CanonStateNotifications, CanonStateSubscriptions}; use reth_evm::ConfigureEvm; use reth_metrics::Metrics; @@ -37,7 +38,7 @@ pub(crate) const FB_STATE_ROOT_FROM_INDEX: usize = 9; pub struct FlashBlockService< N: NodePrimitives, S, - EvmConfig: ConfigureEvm, + EvmConfig: ConfigureEvm + Unpin>, Provider, > { rx: S, @@ -67,7 +68,7 @@ impl FlashBlockService where N: NodePrimitives, S: Stream> + Unpin + 'static, - EvmConfig: ConfigureEvm + Unpin> + EvmConfig: ConfigureEvm + Unpin> + Clone + 'static, Provider: StateProviderFactory @@ -231,7 +232,7 @@ impl Stream for FlashBlockService> + Unpin + 'static, - EvmConfig: ConfigureEvm + Unpin> + EvmConfig: ConfigureEvm + Unpin> + Clone + 'static, Provider: StateProviderFactory diff --git a/crates/optimism/flashblocks/src/worker.rs b/crates/optimism/flashblocks/src/worker.rs index 2112d93af26..cf33d5c8ceb 100644 --- a/crates/optimism/flashblocks/src/worker.rs +++ b/crates/optimism/flashblocks/src/worker.rs @@ -1,6 +1,7 @@ -use crate::{ExecutionPayloadBaseV1, PendingFlashBlock}; +use crate::PendingFlashBlock; use alloy_eips::{eip2718::WithEncoded, BlockNumberOrTag}; use alloy_primitives::B256; +use op_alloy_rpc_types_engine::OpFlashblockPayloadBase; use reth_chain_state::{CanonStateSubscriptions, ExecutedBlock}; use reth_errors::RethError; use reth_evm::{ @@ -38,7 +39,7 @@ impl FlashBlockBuilder { } pub(crate) struct BuildArgs { - pub(crate) base: ExecutionPayloadBaseV1, + pub(crate) base: OpFlashblockPayloadBase, pub(crate) transactions: I, pub(crate) cached_state: Option<(B256, CachedReads)>, pub(crate) last_flashblock_index: u64, @@ -49,7 +50,7 @@ pub(crate) struct BuildArgs { impl FlashBlockBuilder where N: NodePrimitives, - EvmConfig: ConfigureEvm + Unpin>, + EvmConfig: ConfigureEvm + Unpin>, Provider: StateProviderFactory + CanonStateSubscriptions + BlockReaderIdExt< @@ -60,7 +61,7 @@ where > + Unpin, { /// Returns the [`PendingFlashBlock`] made purely out of transactions and - /// [`ExecutionPayloadBaseV1`] in `args`. + /// [`OpFlashblockPayloadBase`] in `args`. /// /// Returns `None` if the flashblock doesn't attach to the latest header. pub(crate) fn execute>>>( diff --git a/crates/optimism/flashblocks/src/ws/decoding.rs b/crates/optimism/flashblocks/src/ws/decoding.rs index 267f79cf19a..64d96dc5e3e 100644 --- a/crates/optimism/flashblocks/src/ws/decoding.rs +++ b/crates/optimism/flashblocks/src/ws/decoding.rs @@ -1,50 +1,27 @@ -use crate::{ExecutionPayloadBaseV1, ExecutionPayloadFlashblockDeltaV1, FlashBlock, Metadata}; +use crate::FlashBlock; use alloy_primitives::bytes::Bytes; -use alloy_rpc_types_engine::PayloadId; -use serde::{Deserialize, Serialize}; -use std::{fmt::Debug, io}; +use std::io; -/// Internal helper for decoding -#[derive(Clone, Debug, PartialEq, Default, Deserialize, Serialize)] -struct FlashblocksPayloadV1 { - /// The payload id of the flashblock - pub payload_id: PayloadId, - /// The index of the flashblock in the block - pub index: u64, - /// The base execution payload configuration - #[serde(skip_serializing_if = "Option::is_none")] - pub base: Option, - /// The delta/diff containing modified portions of the execution payload - pub diff: ExecutionPayloadFlashblockDeltaV1, - /// Additional metadata associated with the flashblock - pub metadata: serde_json::Value, +/// A trait for decoding flashblocks from bytes. +pub trait FlashBlockDecoder: Send + 'static { + /// Decodes `bytes` into a [`FlashBlock`]. + fn decode(&self, bytes: Bytes) -> eyre::Result; } -impl FlashBlock { - /// Decodes `bytes` into [`FlashBlock`]. - /// - /// This function is specific to the Base Optimism websocket encoding. - /// - /// It is assumed that the `bytes` are encoded in JSON and optionally compressed using brotli. - /// Whether the `bytes` is compressed or not is determined by looking at the first - /// non ascii-whitespace character. - pub(crate) fn decode(bytes: Bytes) -> eyre::Result { - let bytes = try_parse_message(bytes)?; +/// Default implementation of the decoder. +impl FlashBlockDecoder for () { + fn decode(&self, bytes: Bytes) -> eyre::Result { + decode_flashblock(bytes) + } +} - let payload: FlashblocksPayloadV1 = serde_json::from_slice(&bytes) - .map_err(|e| eyre::eyre!("failed to parse message: {e}"))?; +pub(crate) fn decode_flashblock(bytes: Bytes) -> eyre::Result { + let bytes = crate::ws::decoding::try_parse_message(bytes)?; - let metadata: Metadata = serde_json::from_value(payload.metadata) - .map_err(|e| eyre::eyre!("failed to parse message metadata: {e}"))?; + let payload: FlashBlock = + serde_json::from_slice(&bytes).map_err(|e| eyre::eyre!("failed to parse message: {e}"))?; - Ok(Self { - payload_id: payload.payload_id, - index: payload.index, - base: payload.base, - diff: payload.diff, - metadata, - }) - } + Ok(payload) } /// Maps `bytes` into a potentially different [`Bytes`]. diff --git a/crates/optimism/flashblocks/src/ws/mod.rs b/crates/optimism/flashblocks/src/ws/mod.rs index 2b820899312..8c8a5910892 100644 --- a/crates/optimism/flashblocks/src/ws/mod.rs +++ b/crates/optimism/flashblocks/src/ws/mod.rs @@ -1,4 +1,6 @@ pub use stream::{WsConnect, WsFlashBlockStream}; mod decoding; +pub(crate) use decoding::FlashBlockDecoder; + mod stream; diff --git a/crates/optimism/flashblocks/src/ws/stream.rs b/crates/optimism/flashblocks/src/ws/stream.rs index 99128dcf131..e46fd6d747f 100644 --- a/crates/optimism/flashblocks/src/ws/stream.rs +++ b/crates/optimism/flashblocks/src/ws/stream.rs @@ -1,4 +1,4 @@ -use crate::{FlashBlock, FlashBlockDecoder}; +use crate::{ws::FlashBlockDecoder, FlashBlock}; use futures_util::{ stream::{SplitSink, SplitStream}, FutureExt, Sink, Stream, StreamExt, @@ -240,7 +240,6 @@ impl WsConnect for WsConnector { #[cfg(test)] mod tests { use super::*; - use crate::ExecutionPayloadBaseV1; use alloy_primitives::bytes::Bytes; use brotli::enc::BrotliEncoderParams; use std::{future, iter}; @@ -451,23 +450,7 @@ mod tests { } fn flashblock() -> FlashBlock { - FlashBlock { - payload_id: Default::default(), - index: 0, - base: Some(ExecutionPayloadBaseV1 { - parent_beacon_block_root: Default::default(), - parent_hash: Default::default(), - fee_recipient: Default::default(), - prev_randao: Default::default(), - block_number: 0, - gas_limit: 0, - timestamp: 0, - extra_data: Default::default(), - base_fee_per_gas: Default::default(), - }), - diff: Default::default(), - metadata: Default::default(), - } + Default::default() } #[test_case::test_case(to_json_message(Message::Binary); "json binary")] diff --git a/crates/optimism/rpc/src/eth/mod.rs b/crates/optimism/rpc/src/eth/mod.rs index f31ee3fde9c..4528bb5685a 100644 --- a/crates/optimism/rpc/src/eth/mod.rs +++ b/crates/optimism/rpc/src/eth/mod.rs @@ -17,6 +17,7 @@ use alloy_consensus::BlockHeader; use alloy_primitives::{B256, U256}; use eyre::WrapErr; use op_alloy_network::Optimism; +use op_alloy_rpc_types_engine::OpFlashblockPayloadBase; pub use receipt::{OpReceiptBuilder, OpReceiptFieldsBuilder}; use reqwest::Url; use reth_chainspec::{EthereumHardforks, Hardforks}; @@ -24,8 +25,8 @@ use reth_evm::ConfigureEvm; use reth_node_api::{FullNodeComponents, FullNodeTypes, HeaderTy, NodeTypes}; use reth_node_builder::rpc::{EthApiBuilder, EthApiCtx}; use reth_optimism_flashblocks::{ - ExecutionPayloadBaseV1, FlashBlockBuildInfo, FlashBlockCompleteSequenceRx, FlashBlockRx, - FlashBlockService, FlashblocksListeners, PendingBlockRx, PendingFlashBlock, WsFlashBlockStream, + FlashBlockBuildInfo, FlashBlockCompleteSequenceRx, FlashBlockRx, FlashBlockService, + FlashblocksListeners, PendingBlockRx, PendingFlashBlock, WsFlashBlockStream, }; use reth_rpc::eth::core::EthApiInner; use reth_rpc_eth_api::{ @@ -459,7 +460,7 @@ where N: FullNodeComponents< Evm: ConfigureEvm< NextBlockEnvCtx: BuildPendingEnv> - + From + + From + Unpin, >, Types: NodeTypes, diff --git a/examples/custom-node/Cargo.toml b/examples/custom-node/Cargo.toml index fe1f0006256..d20d9c3abd7 100644 --- a/examples/custom-node/Cargo.toml +++ b/examples/custom-node/Cargo.toml @@ -12,7 +12,6 @@ reth-codecs.workspace = true reth-network-peers.workspace = true reth-node-builder.workspace = true reth-optimism-forks.workspace = true -reth-optimism-flashblocks.workspace = true reth-db-api.workspace = true reth-op = { workspace = true, features = ["node", "pool", "rpc"] } reth-payload-builder.workspace = true diff --git a/examples/custom-node/src/evm/config.rs b/examples/custom-node/src/evm/config.rs index a7dee31a835..f2bd3326893 100644 --- a/examples/custom-node/src/evm/config.rs +++ b/examples/custom-node/src/evm/config.rs @@ -9,6 +9,7 @@ use alloy_eips::{eip2718::WithEncoded, Decodable2718}; use alloy_evm::EvmEnv; use alloy_op_evm::OpBlockExecutionCtx; use alloy_rpc_types_engine::PayloadError; +use op_alloy_rpc_types_engine::flashblock::OpFlashblockPayloadBase; use op_revm::OpSpecId; use reth_engine_primitives::ExecutableTxIterator; use reth_ethereum::{ @@ -23,7 +24,6 @@ use reth_op::{ node::{OpEvmConfig, OpNextBlockEnvAttributes, OpRethReceiptBuilder}, primitives::SignedTransaction, }; -use reth_optimism_flashblocks::ExecutionPayloadBaseV1; use reth_rpc_api::eth::helpers::pending_block::BuildPendingEnv; use std::sync::Arc; @@ -143,8 +143,8 @@ pub struct CustomNextBlockEnvAttributes { extension: u64, } -impl From for CustomNextBlockEnvAttributes { - fn from(value: ExecutionPayloadBaseV1) -> Self { +impl From for CustomNextBlockEnvAttributes { + fn from(value: OpFlashblockPayloadBase) -> Self { Self { inner: value.into(), extension: 0 } } } From 6074ae915fa00b5ce349d1b97350721e855c10b9 Mon Sep 17 00:00:00 2001 From: Alex Pikme <30472093+reject-i@users.noreply.github.com> Date: Fri, 14 Nov 2025 17:23:46 +0200 Subject: [PATCH 3/6] feat(flashblocks): add metrics for current block and index (#19712) Co-authored-by: Matthias Seitz --- crates/optimism/flashblocks/src/service.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/optimism/flashblocks/src/service.rs b/crates/optimism/flashblocks/src/service.rs index 2c305529bda..40046e15967 100644 --- a/crates/optimism/flashblocks/src/service.rs +++ b/crates/optimism/flashblocks/src/service.rs @@ -7,7 +7,7 @@ use crate::{ use alloy_eips::eip2718::WithEncoded; use alloy_primitives::B256; use futures_util::{FutureExt, Stream, StreamExt}; -use metrics::Histogram; +use metrics::{Gauge, Histogram}; use op_alloy_rpc_types_engine::OpFlashblockPayloadBase; use reth_chain_state::{CanonStateNotification, CanonStateNotifications, CanonStateSubscriptions}; use reth_evm::ConfigureEvm; @@ -279,6 +279,7 @@ where let elapsed = now.elapsed(); this.metrics.execution_duration.record(elapsed.as_secs_f64()); + trace!( target: "flashblocks", parent_hash = %new_pending.block().parent_hash(), @@ -349,6 +350,9 @@ where index: args.last_flashblock_index, block_number: args.base.block_number, }; + // Record current block and index metrics + this.metrics.current_block_height.set(fb_info.block_number as f64); + this.metrics.current_index.set(fb_info.index as f64); // Signal that a flashblock build has started with build metadata let _ = this.in_progress_tx.send(Some(fb_info)); let (tx, rx) = oneshot::channel(); @@ -389,4 +393,8 @@ struct FlashBlockServiceMetrics { last_flashblock_length: Histogram, /// The duration applying flashblock state changes in seconds. execution_duration: Histogram, + /// Current block height. + current_block_height: Gauge, + /// Current flashblock index. + current_index: Gauge, } From d3ad5590f4130a33b6e9abba01f34feb3f3a601d Mon Sep 17 00:00:00 2001 From: Francis Li Date: Sun, 16 Nov 2025 02:22:59 -0800 Subject: [PATCH 4/6] feat(flashblock): improve state root calculation condition (#19667) --- Cargo.lock | 1 + crates/node/builder/src/rpc.rs | 9 +- crates/optimism/flashblocks/src/consensus.rs | 299 +++++++++++++++---- crates/optimism/flashblocks/src/sequence.rs | 55 ++-- crates/optimism/flashblocks/src/service.rs | 63 +++- crates/optimism/flashblocks/src/worker.rs | 1 + crates/optimism/node/src/args.rs | 9 + crates/optimism/node/src/node.rs | 16 +- crates/optimism/node/src/rpc.rs | 11 +- crates/optimism/rpc/src/eth/mod.rs | 43 ++- examples/custom-node/Cargo.toml | 1 + examples/custom-node/src/engine.rs | 9 + 12 files changed, 421 insertions(+), 96 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 75ed8516622..104a0630947 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3631,6 +3631,7 @@ dependencies = [ "reth-network-peers", "reth-node-builder", "reth-op", + "reth-optimism-flashblocks", "reth-optimism-forks", "reth-payload-builder", "reth-rpc-api", diff --git a/crates/node/builder/src/rpc.rs b/crates/node/builder/src/rpc.rs index 080bfcd247b..ad9bc08a6e2 100644 --- a/crates/node/builder/src/rpc.rs +++ b/crates/node/builder/src/rpc.rs @@ -973,7 +973,12 @@ where ); let eth_config = config.rpc.eth_config().max_batch_size(config.txpool.max_batch_size()); - let ctx = EthApiCtx { components: &node, config: eth_config, cache }; + let ctx = EthApiCtx { + components: &node, + config: eth_config, + cache, + engine_handle: beacon_engine_handle.clone(), + }; let eth_api = eth_api_builder.build_eth_api(ctx).await?; let auth_config = config.rpc.auth_server_config(jwt_secret)?; @@ -1137,6 +1142,8 @@ pub struct EthApiCtx<'a, N: FullNodeTypes> { pub config: EthConfig, /// Cache for eth state pub cache: EthStateCache>, + /// Handle to the beacon consensus engine + pub engine_handle: ConsensusEngineHandle<::Payload>, } impl<'a, N: FullNodeComponents>> diff --git a/crates/optimism/flashblocks/src/consensus.rs b/crates/optimism/flashblocks/src/consensus.rs index 8df2e01a07b..65926a4d911 100644 --- a/crates/optimism/flashblocks/src/consensus.rs +++ b/crates/optimism/flashblocks/src/consensus.rs @@ -1,86 +1,271 @@ -use crate::FlashBlockCompleteSequenceRx; +use crate::{FlashBlockCompleteSequence, FlashBlockCompleteSequenceRx}; use alloy_primitives::B256; +use alloy_rpc_types_engine::PayloadStatusEnum; +use op_alloy_rpc_types_engine::OpExecutionData; use reth_engine_primitives::ConsensusEngineHandle; use reth_optimism_payload_builder::OpPayloadTypes; -use reth_payload_primitives::EngineApiMessageVersion; +use reth_payload_primitives::{EngineApiMessageVersion, ExecutionPayload, PayloadTypes}; use ringbuffer::{AllocRingBuffer, RingBuffer}; -use tracing::warn; +use tracing::*; + +/// Cache entry for block information: (block hash, block number, timestamp). +type BlockCacheEntry = (B256, u64, u64); /// Consensus client that sends FCUs and new payloads using blocks from a [`FlashBlockService`] /// /// [`FlashBlockService`]: crate::FlashBlockService #[derive(Debug)] -pub struct FlashBlockConsensusClient { +pub struct FlashBlockConsensusClient

+where + P: PayloadTypes, +{ /// Handle to execution client. - engine_handle: ConsensusEngineHandle, + engine_handle: ConsensusEngineHandle

, sequence_receiver: FlashBlockCompleteSequenceRx, + /// Caches previous block info for lookup: (block hash, block number, timestamp). + block_hash_buffer: AllocRingBuffer, } -impl FlashBlockConsensusClient { +impl

FlashBlockConsensusClient

+where + P: PayloadTypes, + P::ExecutionData: for<'a> TryFrom<&'a FlashBlockCompleteSequence, Error: std::fmt::Display>, +{ /// Create a new `FlashBlockConsensusClient` with the given Op engine and sequence receiver. - pub const fn new( - engine_handle: ConsensusEngineHandle, + pub fn new( + engine_handle: ConsensusEngineHandle

, sequence_receiver: FlashBlockCompleteSequenceRx, ) -> eyre::Result { - Ok(Self { engine_handle, sequence_receiver }) + // Buffer size of 768 blocks (64 * 12) supports 1s block time chains like Unichain. + // Oversized for 2s block time chains like Base, but acceptable given minimal memory usage. + let block_hash_buffer = AllocRingBuffer::new(768); + Ok(Self { engine_handle, sequence_receiver, block_hash_buffer }) + } + + /// Return the safe and finalized block hash for FCU calls. + /// + /// Safe blocks are considered 32 L1 blocks (approximately 384s at 12s/block) behind the head, + /// and finalized blocks are 64 L1 blocks (approximately 768s) behind the head. This + /// approximation, while not precisely matching the OP stack's derivation, provides + /// sufficient proximity and enables op-reth to sync the chain independently of an op-node. + /// The offset is dynamically adjusted based on the actual block time detected from the + /// buffer. + fn get_safe_and_finalized_block_hash(&self) -> (B256, B256) { + let cached_blocks_count = self.block_hash_buffer.len(); + + // Not enough blocks to determine safe/finalized yet + if cached_blocks_count < 2 { + return (B256::ZERO, B256::ZERO); + } + + // Calculate average block time using block numbers to handle missing blocks correctly. + // By dividing timestamp difference by block number difference, we get accurate block + // time even when blocks are missing from the buffer. + let (_, latest_block_number, latest_timestamp) = + self.block_hash_buffer.get(cached_blocks_count - 1).unwrap(); + let (_, previous_block_number, previous_timestamp) = + self.block_hash_buffer.get(cached_blocks_count - 2).unwrap(); + let timestamp_delta = latest_timestamp.saturating_sub(*previous_timestamp); + let block_number_delta = latest_block_number.saturating_sub(*previous_block_number).max(1); + let block_time_secs = timestamp_delta / block_number_delta; + + // L1 reference: 32 blocks * 12s = 384s for safe, 64 blocks * 12s = 768s for finalized + const SAFE_TIME_SECS: u64 = 384; + const FINALIZED_TIME_SECS: u64 = 768; + + // Calculate how many L2 blocks correspond to these L1 time periods + let safe_block_offset = + (SAFE_TIME_SECS / block_time_secs).min(cached_blocks_count as u64) as usize; + let finalized_block_offset = + (FINALIZED_TIME_SECS / block_time_secs).min(cached_blocks_count as u64) as usize; + + // Get safe hash: offset from end of buffer + let safe_hash = self + .block_hash_buffer + .get(cached_blocks_count.saturating_sub(safe_block_offset)) + .map(|&(hash, _, _)| hash) + .unwrap(); + + // Get finalized hash: offset from end of buffer + let finalized_hash = self + .block_hash_buffer + .get(cached_blocks_count.saturating_sub(finalized_block_offset)) + .map(|&(hash, _, _)| hash) + .unwrap(); + + (safe_hash, finalized_hash) + } + + /// Receive the next flashblock sequence and cache its block information. + /// + /// Returns `None` if receiving fails (error is already logged). + async fn receive_and_cache_sequence(&mut self) -> Option { + match self.sequence_receiver.recv().await { + Ok(sequence) => { + self.block_hash_buffer.push(( + sequence.payload_base().parent_hash, + sequence.block_number(), + sequence.payload_base().timestamp, + )); + Some(sequence) + } + Err(err) => { + error!( + target: "flashblocks", + %err, + "error while fetching flashblock completed sequence", + ); + None + } + } } - /// Get previous block hash using previous block hash buffer. If it isn't available (buffer - /// started more recently than `offset`), return default zero hash - fn get_previous_block_hash( + /// Convert a flashblock sequence to an execution payload. + /// + /// Returns `None` if conversion fails (error is already logged). + fn convert_sequence_to_payload( &self, - previous_block_hashes: &AllocRingBuffer, - offset: usize, - ) -> B256 { - *previous_block_hashes - .len() - .checked_sub(offset) - .and_then(|index| previous_block_hashes.get(index)) - .unwrap_or_default() + sequence: &FlashBlockCompleteSequence, + ) -> Option { + match P::ExecutionData::try_from(sequence) { + Ok(payload) => Some(payload), + Err(err) => { + error!( + target: "flashblocks", + %err, + "error while converting to payload from completed sequence", + ); + None + } + } } - /// Spawn the client to start sending FCUs and new payloads by periodically fetching recent - /// blocks. - pub async fn run(mut self) { - let mut previous_block_hashes = AllocRingBuffer::new(64); + /// Submit a new payload to the engine. + /// + /// Returns `Ok(block_hash)` if the payload was accepted, `Err(())` otherwise (errors are + /// logged). + async fn submit_new_payload( + &self, + payload: P::ExecutionData, + sequence: &FlashBlockCompleteSequence, + ) -> Result { + let block_number = payload.block_number(); + let block_hash = payload.block_hash(); - loop { - match self.sequence_receiver.recv().await { - Ok(sequence) => { - let block_hash = sequence.payload_base().parent_hash; - previous_block_hashes.push(block_hash); - - if sequence.state_root().is_none() { - warn!(target: "flashblocks", "Missing state root for the complete sequence") - } - - // Load previous block hashes. We're using (head - 32) and (head - 64) as the - // safe and finalized block hashes. - let safe_block_hash = self.get_previous_block_hash(&previous_block_hashes, 32); - let finalized_block_hash = - self.get_previous_block_hash(&previous_block_hashes, 64); - - let state = alloy_rpc_types_engine::ForkchoiceState { - head_block_hash: block_hash, - safe_block_hash, - finalized_block_hash, - }; - - // Send FCU - let _ = self - .engine_handle - .fork_choice_updated(state, None, EngineApiMessageVersion::V3) - .await; - } - Err(err) => { - warn!( + match self.engine_handle.new_payload(payload).await { + Ok(result) => { + debug!( + target: "flashblocks", + flashblock_count = sequence.count(), + block_number, + %block_hash, + ?result, + "Submitted engine_newPayload", + ); + + if let PayloadStatusEnum::Invalid { validation_error } = result.status { + debug!( target: "flashblocks", - %err, - "error while fetching flashblock completed sequence" + block_number, + %block_hash, + %validation_error, + "Payload validation error", ); - break; + return Err(()); } + + Ok(block_hash) + } + Err(err) => { + error!( + target: "flashblocks", + %err, + block_number, + "Failed to submit new payload", + ); + Err(()) + } + } + } + + /// Submit a forkchoice update to the engine. + async fn submit_forkchoice_update( + &self, + head_block_hash: B256, + sequence: &FlashBlockCompleteSequence, + ) { + let block_number = sequence.block_number(); + let (safe_hash, finalized_hash) = self.get_safe_and_finalized_block_hash(); + let fcu_state = alloy_rpc_types_engine::ForkchoiceState { + head_block_hash, + safe_block_hash: safe_hash, + finalized_block_hash: finalized_hash, + }; + + match self + .engine_handle + .fork_choice_updated(fcu_state, None, EngineApiMessageVersion::V5) + .await + { + Ok(result) => { + debug!( + target: "flashblocks", + flashblock_count = sequence.count(), + block_number, + %head_block_hash, + %safe_hash, + %finalized_hash, + ?result, + "Submitted engine_forkChoiceUpdated", + ) } + Err(err) => { + error!( + target: "flashblocks", + %err, + block_number, + %head_block_hash, + %safe_hash, + %finalized_hash, + "Failed to submit fork choice update", + ); + } + } + } + + /// Spawn the client to start sending FCUs and new payloads by periodically fetching recent + /// blocks. + pub async fn run(mut self) { + loop { + let Some(sequence) = self.receive_and_cache_sequence().await else { + continue; + }; + + let Some(payload) = self.convert_sequence_to_payload(&sequence) else { + continue; + }; + + let Ok(block_hash) = self.submit_new_payload(payload, &sequence).await else { + continue; + }; + + self.submit_forkchoice_update(block_hash, &sequence).await; + } + } +} + +impl From<&FlashBlockCompleteSequence> for OpExecutionData { + fn from(sequence: &FlashBlockCompleteSequence) -> Self { + let mut data = Self::from_flashblocks_unchecked(sequence); + // Replace payload's state_root with the calculated one. For flashblocks, there was an + // option to disable state root calculation for blocks, and in that case, the payload's + // state_root will be zero, and we'll need to locally calculate state_root before + // proceeding to call engine_newPayload. + if let Some(execution_outcome) = sequence.execution_outcome() { + let payload = data.payload.as_v1_mut(); + payload.state_root = execution_outcome.state_root; + payload.block_hash = execution_outcome.block_hash; } + data } } diff --git a/crates/optimism/flashblocks/src/sequence.rs b/crates/optimism/flashblocks/src/sequence.rs index 0bcde2ace17..409a13f5163 100644 --- a/crates/optimism/flashblocks/src/sequence.rs +++ b/crates/optimism/flashblocks/src/sequence.rs @@ -13,18 +13,24 @@ use tracing::{debug, trace, warn}; /// The size of the broadcast channel for completed flashblock sequences. const FLASHBLOCK_SEQUENCE_CHANNEL_SIZE: usize = 128; +/// Outcome from executing a flashblock sequence. +#[derive(Debug, Clone, Copy)] +pub struct SequenceExecutionOutcome { + /// The block hash of the executed pending block + pub block_hash: B256, + /// Properly computed state root + pub state_root: B256, +} + /// An ordered B-tree keeping the track of a sequence of [`FlashBlock`]s by their indices. #[derive(Debug)] pub struct FlashBlockPendingSequence { /// tracks the individual flashblocks in order - /// - /// With a blocktime of 2s and flashblock tick-rate of 200ms plus one extra flashblock per new - /// pending block, we expect 11 flashblocks per slot. inner: BTreeMap>, /// Broadcasts flashblocks to subscribers. block_broadcaster: broadcast::Sender, - /// Optional properly computed state root for the current sequence. - state_root: Option, + /// Optional execution outcome from building the current sequence. + execution_outcome: Option, } impl FlashBlockPendingSequence @@ -36,7 +42,7 @@ where // Note: if the channel is full, send will not block but rather overwrite the oldest // messages. Order is preserved. let (tx, _) = broadcast::channel(FLASHBLOCK_SEQUENCE_CHANNEL_SIZE); - Self { inner: BTreeMap::new(), block_broadcaster: tx, state_root: None } + Self { inner: BTreeMap::new(), block_broadcaster: tx, execution_outcome: None } } /// Returns the sender half of the [`FlashBlockCompleteSequence`] channel. @@ -53,13 +59,18 @@ where // Clears the state and broadcasts the blocks produced to subscribers. fn clear_and_broadcast_blocks(&mut self) { + if self.inner.is_empty() { + return; + } + let flashblocks = mem::take(&mut self.inner); + let execution_outcome = mem::take(&mut self.execution_outcome); // If there are any subscribers, send the flashblocks to them. if self.block_broadcaster.receiver_count() > 0 { let flashblocks = match FlashBlockCompleteSequence::new( flashblocks.into_iter().map(|block| block.1.into()).collect(), - self.state_root, + execution_outcome, ) { Ok(flashblocks) => flashblocks, Err(err) => { @@ -106,9 +117,12 @@ where Ok(()) } - /// Set state root - pub const fn set_state_root(&mut self, state_root: Option) { - self.state_root = state_root; + /// Set execution outcome from building the flashblock sequence + pub const fn set_execution_outcome( + &mut self, + execution_outcome: Option, + ) { + self.execution_outcome = execution_outcome; } /// Iterator over sequence of executable transactions. @@ -171,12 +185,12 @@ where /// /// Ensures invariants of a complete flashblocks sequence. /// If this entire sequence of flashblocks was executed on top of latest block, this also includes -/// the computed state root. +/// the execution outcome with block hash and state root. #[derive(Debug, Clone)] pub struct FlashBlockCompleteSequence { inner: Vec, - /// Optional state root for the current sequence - state_root: Option, + /// Optional execution outcome from building the flashblock sequence + execution_outcome: Option, } impl FlashBlockCompleteSequence { @@ -185,7 +199,10 @@ impl FlashBlockCompleteSequence { /// * vector is not empty /// * first flashblock have the base payload /// * sequence of flashblocks is sound (successive index from 0, same payload id, ...) - pub fn new(blocks: Vec, state_root: Option) -> eyre::Result { + pub fn new( + blocks: Vec, + execution_outcome: Option, + ) -> eyre::Result { let first_block = blocks.first().ok_or_eyre("No flashblocks in sequence")?; // Ensure that first flashblock have base @@ -200,7 +217,7 @@ impl FlashBlockCompleteSequence { bail!("Flashblock inconsistencies detected in sequence"); } - Ok(Self { inner: blocks, state_root }) + Ok(Self { inner: blocks, execution_outcome }) } /// Returns the block number @@ -223,9 +240,9 @@ impl FlashBlockCompleteSequence { self.inner.last().unwrap() } - /// Returns the state root for the current sequence - pub const fn state_root(&self) -> Option { - self.state_root + /// Returns the execution outcome of the sequence. + pub const fn execution_outcome(&self) -> Option { + self.execution_outcome } /// Returns all transactions from all flashblocks in the sequence @@ -247,7 +264,7 @@ impl TryFrom> for FlashBlockCompleteSequence { fn try_from(sequence: FlashBlockPendingSequence) -> Result { Self::new( sequence.inner.into_values().map(|block| block.block().clone()).collect::>(), - sequence.state_root, + sequence.execution_outcome, ) } } diff --git a/crates/optimism/flashblocks/src/service.rs b/crates/optimism/flashblocks/src/service.rs index 40046e15967..82a62c9e015 100644 --- a/crates/optimism/flashblocks/src/service.rs +++ b/crates/optimism/flashblocks/src/service.rs @@ -1,5 +1,5 @@ use crate::{ - sequence::FlashBlockPendingSequence, + sequence::{FlashBlockPendingSequence, SequenceExecutionOutcome}, worker::{BuildArgs, FlashBlockBuilder}, FlashBlock, FlashBlockCompleteSequence, FlashBlockCompleteSequenceRx, InProgressFlashBlockRx, PendingFlashBlock, @@ -30,7 +30,8 @@ use tokio::{ }; use tracing::{debug, trace, warn}; -pub(crate) const FB_STATE_ROOT_FROM_INDEX: usize = 9; +/// 200 ms flashblock time. +pub(crate) const FLASHBLOCK_BLOCK_TIME: u64 = 200; /// The `FlashBlockService` maintains an in-memory [`PendingFlashBlock`] built out of a sequence of /// [`FlashBlock`]s. @@ -60,7 +61,7 @@ pub struct FlashBlockService< in_progress_tx: watch::Sender>, /// `FlashBlock` service's metrics metrics: FlashBlockServiceMetrics, - /// Enable state root calculation from flashblock with index [`FB_STATE_ROOT_FROM_INDEX`] + /// Enable state root calculation compute_state_root: bool, } @@ -177,10 +178,13 @@ where return None }; + let Some(latest) = self.builder.provider().latest_header().ok().flatten() else { + trace!(target: "flashblocks", "No latest header found"); + return None + }; + // attempt an initial consecutive check - if let Some(latest) = self.builder.provider().latest_header().ok().flatten() && - latest.hash() != base.parent_hash - { + if latest.hash() != base.parent_hash { trace!(target: "flashblocks", flashblock_parent=?base.parent_hash, flashblock_number=base.block_number, local_latest=?latest.num_hash(), "Skipping non consecutive build attempt"); return None } @@ -190,9 +194,39 @@ where return None }; - // Check if state root must be computed - let compute_state_root = - self.compute_state_root && self.blocks.index() >= Some(FB_STATE_ROOT_FROM_INDEX as u64); + // Auto-detect when to compute state root: only if the builder didn't provide it (sent + // B256::ZERO) and we're near the expected final flashblock index. + // + // Background: Each block period receives multiple flashblocks at regular intervals. + // The sequencer sends an initial "base" flashblock at index 0 when a new block starts, + // then subsequent flashblocks are produced every FLASHBLOCK_BLOCK_TIME intervals (200ms). + // + // Examples with different block times: + // - Base (2s blocks): expect 2000ms / 200ms = 10 intervals → Flashblocks: index 0 (base) + // + indices 1-10 = potentially 11 total + // + // - Unichain (1s blocks): expect 1000ms / 200ms = 5 intervals → Flashblocks: index 0 (base) + // + indices 1-5 = potentially 6 total + // + // Why compute at N-1 instead of N: + // 1. Timing variance in flashblock producing time may mean only N flashblocks were produced + // instead of N+1 (missing the final one). Computing at N-1 ensures we get the state root + // for most common cases. + // + // 2. The +1 case (index 0 base + N intervals): If all N+1 flashblocks do arrive, we'll + // still calculate state root for flashblock N, which sacrifices a little performance but + // still ensures correctness for common cases. + // + // Note: Pathological cases may result in fewer flashblocks than expected (e.g., builder + // downtime, flashblock execution exceeding timing budget). When this occurs, we won't + // compute the state root, causing FlashblockConsensusClient to lack precomputed state for + // engine_newPayload. This is safe: we still have op-node as backstop to maintain + // chain progression. + let block_time_ms = (base.timestamp - latest.timestamp()) * 1000; + let expected_final_flashblock = block_time_ms / FLASHBLOCK_BLOCK_TIME; + let compute_state_root = self.compute_state_root && + last_flashblock.diff.state_root.is_zero() && + self.blocks.index() >= Some(expected_final_flashblock.saturating_sub(1)); Some(BuildArgs { base, @@ -268,8 +302,15 @@ where if let Some((now, result)) = result { match result { Ok(Some((new_pending, cached_reads))) => { - // update state root of the current sequence - this.blocks.set_state_root(new_pending.computed_state_root()); + // update execution outcome of the current sequence + let execution_outcome = + new_pending.computed_state_root().map(|state_root| { + SequenceExecutionOutcome { + block_hash: new_pending.block().hash(), + state_root, + } + }); + this.blocks.set_execution_outcome(execution_outcome); // built a new pending block this.current = Some(new_pending.clone()); diff --git a/crates/optimism/flashblocks/src/worker.rs b/crates/optimism/flashblocks/src/worker.rs index cf33d5c8ceb..16ae67a09ab 100644 --- a/crates/optimism/flashblocks/src/worker.rs +++ b/crates/optimism/flashblocks/src/worker.rs @@ -107,6 +107,7 @@ where // if the real state root should be computed let BlockBuilderOutcome { execution_result, block, hashed_state, .. } = if args.compute_state_root { + trace!(target: "flashblocks", "Computing block state root"); builder.finish(&state_provider)? } else { builder.finish(NoopProvider::default())? diff --git a/crates/optimism/node/src/args.rs b/crates/optimism/node/src/args.rs index 6641aa4acf6..e09743a2e6f 100644 --- a/crates/optimism/node/src/args.rs +++ b/crates/optimism/node/src/args.rs @@ -76,6 +76,14 @@ pub struct RollupArgs { #[arg(long)] pub flashblocks_url: Option, + /// Enable flashblock consensus client to drive the chain forward + /// + /// When enabled, the flashblock consensus client will process flashblock sequences and submit + /// them to the engine API to advance the chain. + /// Requires `flashblocks_url` to be set. + #[arg(long, default_value_t = false, requires = "flashblocks_url")] + pub flashblock_consensus: bool, + /// X Layer specific configuration #[command(flatten)] pub xlayer_args: XLayerArgs, @@ -95,6 +103,7 @@ impl Default for RollupArgs { historical_rpc: None, min_suggested_priority_fee: 1_000_000, flashblocks_url: None, + flashblock_consensus: false, xlayer_args: XLayerArgs::default(), } } diff --git a/crates/optimism/node/src/node.rs b/crates/optimism/node/src/node.rs index bd506bff16b..e7c5ea9a288 100644 --- a/crates/optimism/node/src/node.rs +++ b/crates/optimism/node/src/node.rs @@ -210,6 +210,7 @@ impl OpNode { .with_min_suggested_priority_fee(self.args.min_suggested_priority_fee) .with_historical_rpc(self.args.historical_rpc.clone()) .with_flashblocks(self.args.flashblocks_url.clone()) + .with_flashblock_consensus(self.args.flashblock_consensus) } /// Instantiates the [`ProviderFactoryBuilder`] for an opstack node. @@ -722,6 +723,8 @@ pub struct OpAddOnsBuilder { tokio_runtime: Option, /// A URL pointing to a secure websocket service that streams out flashblocks. flashblocks_url: Option, + /// Enable flashblock consensus client to drive chain forward. + flashblock_consensus: bool, } impl Default for OpAddOnsBuilder { @@ -738,6 +741,7 @@ impl Default for OpAddOnsBuilder { rpc_middleware: Identity::new(), tokio_runtime: None, flashblocks_url: None, + flashblock_consensus: false, } } } @@ -806,6 +810,7 @@ impl OpAddOnsBuilder { tokio_runtime, _nt, flashblocks_url, + flashblock_consensus, .. } = self; OpAddOnsBuilder { @@ -820,6 +825,7 @@ impl OpAddOnsBuilder { rpc_middleware, tokio_runtime, flashblocks_url, + flashblock_consensus, } } @@ -828,6 +834,12 @@ impl OpAddOnsBuilder { self.flashblocks_url = flashblocks_url; self } + + /// With a flashblock consensus client to drive chain forward. + pub const fn with_flashblock_consensus(mut self, flashblock_consensus: bool) -> Self { + self.flashblock_consensus = flashblock_consensus; + self + } } impl OpAddOnsBuilder { @@ -853,6 +865,7 @@ impl OpAddOnsBuilder { rpc_middleware, tokio_runtime, flashblocks_url, + flashblock_consensus, .. } = self; @@ -862,7 +875,8 @@ impl OpAddOnsBuilder { .with_sequencer(sequencer_url.clone()) .with_sequencer_headers(sequencer_headers.clone()) .with_min_suggested_priority_fee(min_suggested_priority_fee) - .with_flashblocks(flashblocks_url), + .with_flashblocks(flashblocks_url) + .with_flashblock_consensus(flashblock_consensus), PVB::default(), EB::default(), EVB::default(), diff --git a/crates/optimism/node/src/rpc.rs b/crates/optimism/node/src/rpc.rs index db811a7f921..b87800a54ee 100644 --- a/crates/optimism/node/src/rpc.rs +++ b/crates/optimism/node/src/rpc.rs @@ -13,7 +13,7 @@ //! components::ComponentsBuilder, //! hooks::OnComponentInitializedHook, //! rpc::{EthApiBuilder, EthApiCtx}, -//! LaunchContext, NodeConfig, RethFullAdapter, +//! ConsensusEngineHandle, LaunchContext, NodeConfig, RethFullAdapter, //! }; //! use reth_optimism_chainspec::OP_SEPOLIA; //! use reth_optimism_evm::OpEvmConfig; @@ -67,7 +67,14 @@ //! config.cache, //! node.task_executor().clone(), //! ); -//! let ctx = EthApiCtx { components: node.node_adapter(), config, cache }; +//! // Create a dummy beacon engine handle for offline mode +//! let (tx, _) = tokio::sync::mpsc::unbounded_channel(); +//! let ctx = EthApiCtx { +//! components: node.node_adapter(), +//! config, +//! cache, +//! engine_handle: ConsensusEngineHandle::new(tx), +//! }; //! let eth_api = OpEthApiBuilder::::default().build_eth_api(ctx).await.unwrap(); //! //! // build `trace` namespace API diff --git a/crates/optimism/rpc/src/eth/mod.rs b/crates/optimism/rpc/src/eth/mod.rs index 4528bb5685a..e5313c0cb18 100644 --- a/crates/optimism/rpc/src/eth/mod.rs +++ b/crates/optimism/rpc/src/eth/mod.rs @@ -25,8 +25,9 @@ use reth_evm::ConfigureEvm; use reth_node_api::{FullNodeComponents, FullNodeTypes, HeaderTy, NodeTypes}; use reth_node_builder::rpc::{EthApiBuilder, EthApiCtx}; use reth_optimism_flashblocks::{ - FlashBlockBuildInfo, FlashBlockCompleteSequenceRx, FlashBlockRx, FlashBlockService, - FlashblocksListeners, PendingBlockRx, PendingFlashBlock, WsFlashBlockStream, + FlashBlockBuildInfo, FlashBlockCompleteSequence, FlashBlockCompleteSequenceRx, + FlashBlockConsensusClient, FlashBlockRx, FlashBlockService, FlashblocksListeners, + PendingBlockRx, PendingFlashBlock, WsFlashBlockStream, }; use reth_rpc::eth::core::EthApiInner; use reth_rpc_eth_api::{ @@ -402,6 +403,12 @@ pub struct OpEthApiBuilder { /// /// [flashblocks]: reth_optimism_flashblocks flashblocks_url: Option, + /// Enable flashblock consensus client to drive the chain forward. + /// + /// When enabled, flashblock sequences are submitted to the engine API via + /// `newPayload` and `forkchoiceUpdated` calls, advancing the canonical chain state. + /// Requires `flashblocks_url` to be set. + flashblock_consensus: bool, /// Marker for network types. _nt: PhantomData, } @@ -413,6 +420,7 @@ impl Default for OpEthApiBuilder { sequencer_headers: Vec::new(), min_suggested_priority_fee: 1_000_000, flashblocks_url: None, + flashblock_consensus: false, _nt: PhantomData, } } @@ -426,6 +434,7 @@ impl OpEthApiBuilder { sequencer_headers: Vec::new(), min_suggested_priority_fee: 1_000_000, flashblocks_url: None, + flashblock_consensus: false, _nt: PhantomData, } } @@ -453,6 +462,12 @@ impl OpEthApiBuilder { self.flashblocks_url = flashblocks_url; self } + + /// With flashblock consensus client enabled to drive chain forward + pub const fn with_flashblock_consensus(mut self, flashblock_consensus: bool) -> Self { + self.flashblock_consensus = flashblock_consensus; + self + } } impl EthApiBuilder for OpEthApiBuilder @@ -463,7 +478,15 @@ where + From + Unpin, >, - Types: NodeTypes, + Types: NodeTypes< + ChainSpec: Hardforks + EthereumHardforks, + Payload: reth_node_api::PayloadTypes< + ExecutionData: for<'a> TryFrom< + &'a FlashBlockCompleteSequence, + Error: std::fmt::Display, + >, + >, + >, >, NetworkT: RpcTypes, OpRpcConvert: RpcConvert, @@ -478,6 +501,7 @@ where sequencer_headers, min_suggested_priority_fee, flashblocks_url, + flashblock_consensus, .. } = self; let rpc_converter = @@ -504,14 +528,23 @@ where ctx.components.evm_config().clone(), ctx.components.provider().clone(), ctx.components.task_executor().clone(), - ); + ) + .compute_state_root(flashblock_consensus); // enable state root calculation if flashblock_consensus if enabled. let flashblocks_sequence = service.block_sequence_broadcaster().clone(); let received_flashblocks = service.flashblocks_broadcaster().clone(); let in_progress_rx = service.subscribe_in_progress(); - ctx.components.task_executor().spawn(Box::pin(service.run(tx))); + if flashblock_consensus { + info!(target: "reth::cli", "Launching FlashBlockConsensusClient"); + let flashblock_client = FlashBlockConsensusClient::new( + ctx.engine_handle.clone(), + flashblocks_sequence.subscribe(), + )?; + ctx.components.task_executor().spawn(Box::pin(flashblock_client.run())); + } + Some(FlashblocksListeners::new( pending_rx, flashblocks_sequence, diff --git a/examples/custom-node/Cargo.toml b/examples/custom-node/Cargo.toml index d20d9c3abd7..fe1f0006256 100644 --- a/examples/custom-node/Cargo.toml +++ b/examples/custom-node/Cargo.toml @@ -12,6 +12,7 @@ reth-codecs.workspace = true reth-network-peers.workspace = true reth-node-builder.workspace = true reth-optimism-forks.workspace = true +reth-optimism-flashblocks.workspace = true reth-db-api.workspace = true reth-op = { workspace = true, features = ["node", "pool", "rpc"] } reth-payload-builder.workspace = true diff --git a/examples/custom-node/src/engine.rs b/examples/custom-node/src/engine.rs index 0c80e52a661..10b54a9bfb3 100644 --- a/examples/custom-node/src/engine.rs +++ b/examples/custom-node/src/engine.rs @@ -68,6 +68,15 @@ impl ExecutionPayload for CustomExecutionData { } } +impl From<&reth_optimism_flashblocks::FlashBlockCompleteSequence> for CustomExecutionData { + fn from(sequence: &reth_optimism_flashblocks::FlashBlockCompleteSequence) -> Self { + let inner = OpExecutionData::from(sequence); + // Derive extension from sequence data - using gas_used from last flashblock as an example + let extension = sequence.last().diff.gas_used; + Self { inner, extension } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CustomPayloadAttributes { #[serde(flatten)] From dfdd65895e7c4e23ede5115ffd328ecd67e4d466 Mon Sep 17 00:00:00 2001 From: Francis Li Date: Mon, 24 Nov 2025 10:01:46 -0800 Subject: [PATCH 5/6] feat(flashblocks): Cache recent flashblocks (#19786) Co-authored-by: Matthias Seitz --- Cargo.lock | 1 + crates/optimism/flashblocks/Cargo.toml | 3 +- crates/optimism/flashblocks/src/cache.rs | 482 ++++++++++++ crates/optimism/flashblocks/src/consensus.rs | 463 ++++++++---- crates/optimism/flashblocks/src/lib.rs | 5 + crates/optimism/flashblocks/src/sequence.rs | 691 ++++++++++++------ crates/optimism/flashblocks/src/service.rs | 439 ++++------- crates/optimism/flashblocks/src/test_utils.rs | 340 +++++++++ crates/optimism/flashblocks/src/worker.rs | 3 +- crates/optimism/rpc/src/eth/mod.rs | 5 +- examples/custom-node/src/engine.rs | 14 +- 11 files changed, 1769 insertions(+), 677 deletions(-) create mode 100644 crates/optimism/flashblocks/src/cache.rs create mode 100644 crates/optimism/flashblocks/src/test_utils.rs diff --git a/Cargo.lock b/Cargo.lock index 104a0630947..5257e0fbc6d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9754,6 +9754,7 @@ dependencies = [ "eyre", "futures-util", "metrics", + "op-alloy-consensus", "op-alloy-rpc-types-engine", "reth-chain-state", "reth-engine-primitives", diff --git a/crates/optimism/flashblocks/Cargo.toml b/crates/optimism/flashblocks/Cargo.toml index 3bb0038a512..e0754aab95e 100644 --- a/crates/optimism/flashblocks/Cargo.toml +++ b/crates/optimism/flashblocks/Cargo.toml @@ -34,7 +34,7 @@ alloy-rpc-types-engine = { workspace = true, features = ["serde"] } alloy-consensus.workspace = true # op-alloy -op-alloy-rpc-types-engine.workspace = true +op-alloy-rpc-types-engine = { workspace = true, features = ["k256"] } # io tokio.workspace = true @@ -57,3 +57,4 @@ derive_more.workspace = true [dev-dependencies] test-case.workspace = true alloy-consensus.workspace = true +op-alloy-consensus.workspace = true diff --git a/crates/optimism/flashblocks/src/cache.rs b/crates/optimism/flashblocks/src/cache.rs new file mode 100644 index 00000000000..9aeed3435e3 --- /dev/null +++ b/crates/optimism/flashblocks/src/cache.rs @@ -0,0 +1,482 @@ +//! Sequence cache management for flashblocks. +//! +//! The `SequenceManager` maintains a ring buffer of recently completed flashblock sequences +//! and intelligently selects which sequence to build based on the local chain tip. + +use crate::{ + sequence::{FlashBlockPendingSequence, SequenceExecutionOutcome}, + worker::BuildArgs, + FlashBlock, FlashBlockCompleteSequence, PendingFlashBlock, +}; +use alloy_eips::eip2718::WithEncoded; +use alloy_primitives::B256; +use reth_primitives_traits::{NodePrimitives, Recovered, SignedTransaction}; +use reth_revm::cached::CachedReads; +use ringbuffer::{AllocRingBuffer, RingBuffer}; +use tokio::sync::broadcast; +use tracing::*; + +/// Maximum number of cached sequences in the ring buffer. +const CACHE_SIZE: usize = 3; +/// 200 ms flashblock time. +pub(crate) const FLASHBLOCK_BLOCK_TIME: u64 = 200; + +/// Manages flashblock sequences with caching support. +/// +/// This struct handles: +/// - Tracking the current pending sequence +/// - Caching completed sequences in a fixed-size ring buffer +/// - Finding the best sequence to build based on local chain tip +/// - Broadcasting completed sequences to subscribers +#[derive(Debug)] +pub(crate) struct SequenceManager { + /// Current pending sequence being built up from incoming flashblocks + pending: FlashBlockPendingSequence, + /// Cached recovered transactions for the pending sequence + pending_transactions: Vec>>, + /// Ring buffer of recently completed sequences bundled with their decoded transactions (FIFO, + /// size 3) + completed_cache: AllocRingBuffer<(FlashBlockCompleteSequence, Vec>>)>, + /// Broadcast channel for completed sequences + block_broadcaster: broadcast::Sender, + /// Whether to compute state roots when building blocks + compute_state_root: bool, +} + +impl SequenceManager { + /// Creates a new sequence manager. + pub(crate) fn new(compute_state_root: bool) -> Self { + let (block_broadcaster, _) = broadcast::channel(128); + Self { + pending: FlashBlockPendingSequence::new(), + pending_transactions: Vec::new(), + completed_cache: AllocRingBuffer::new(CACHE_SIZE), + block_broadcaster, + compute_state_root, + } + } + + /// Returns the sender half of the flashblock sequence broadcast channel. + pub(crate) const fn block_sequence_broadcaster( + &self, + ) -> &broadcast::Sender { + &self.block_broadcaster + } + + /// Gets a subscriber to the flashblock sequences produced. + pub(crate) fn subscribe_block_sequence(&self) -> crate::FlashBlockCompleteSequenceRx { + self.block_broadcaster.subscribe() + } + + /// Inserts a new flashblock into the pending sequence. + /// + /// When a flashblock with index 0 arrives (indicating a new block), the current + /// pending sequence is finalized, cached, and broadcast immediately. If the sequence + /// is later built on top of local tip, `on_build_complete()` will broadcast again + /// with computed `state_root`. + /// + /// Transactions are recovered once and cached for reuse during block building. + pub(crate) fn insert_flashblock(&mut self, flashblock: FlashBlock) -> eyre::Result<()> { + // If this starts a new block, finalize and cache the previous sequence BEFORE inserting + if flashblock.index == 0 && self.pending.count() > 0 { + let completed = self.pending.finalize()?; + let block_number = completed.block_number(); + let parent_hash = completed.payload_base().parent_hash; + + trace!( + target: "flashblocks", + block_number, + %parent_hash, + cache_size = self.completed_cache.len(), + "Caching completed flashblock sequence" + ); + + // Broadcast immediately to consensus client (even without state_root) + // This ensures sequences are forwarded during catch-up even if not buildable on tip. + // ConsensusClient checks execution_outcome and skips newPayload if state_root is zero. + if self.block_broadcaster.receiver_count() > 0 { + let _ = self.block_broadcaster.send(completed.clone()); + } + + // Bundle completed sequence with its decoded transactions and push to cache + // Ring buffer automatically evicts oldest entry when full + let txs = std::mem::take(&mut self.pending_transactions); + self.completed_cache.push((completed, txs)); + + // ensure cache is wiped on new flashblock + let _ = self.pending.take_cached_reads(); + } + + self.pending_transactions + .extend(flashblock.recover_transactions().collect::, _>>()?); + self.pending.insert(flashblock); + Ok(()) + } + + /// Returns the current pending sequence for inspection. + pub(crate) const fn pending(&self) -> &FlashBlockPendingSequence { + &self.pending + } + + /// Finds the next sequence to build and returns ready-to-use `BuildArgs`. + /// + /// Priority order: + /// 1. Current pending sequence (if parent matches local tip) + /// 2. Cached sequence with exact parent match + /// + /// Returns None if nothing is buildable right now. + pub(crate) fn next_buildable_args( + &mut self, + local_tip_hash: B256, + local_tip_timestamp: u64, + ) -> Option>>>> { + // Try to find a buildable sequence: (base, last_fb, transactions, cached_state, + // source_name) + let (base, last_flashblock, transactions, cached_state, source_name) = + // Priority 1: Try current pending sequence + if let Some(base) = self.pending.payload_base().filter(|b| b.parent_hash == local_tip_hash) { + let cached_state = self.pending.take_cached_reads().map(|r| (base.parent_hash, r)); + let last_fb = self.pending.last_flashblock()?; + let transactions = self.pending_transactions.clone(); + (base, last_fb, transactions, cached_state, "pending") + } + // Priority 2: Try cached sequence with exact parent match + else if let Some((cached, txs)) = self.completed_cache.iter().find(|(c, _)| c.payload_base().parent_hash == local_tip_hash) { + let base = cached.payload_base().clone(); + let last_fb = cached.last(); + let transactions = txs.clone(); + let cached_state = None; + (base, last_fb, transactions, cached_state, "cached") + } else { + return None; + }; + + // Auto-detect when to compute state root: only if the builder didn't provide it (sent + // B256::ZERO) and we're near the expected final flashblock index. + // + // Background: Each block period receives multiple flashblocks at regular intervals. + // The sequencer sends an initial "base" flashblock at index 0 when a new block starts, + // then subsequent flashblocks are produced every FLASHBLOCK_BLOCK_TIME intervals (200ms). + // + // Examples with different block times: + // - Base (2s blocks): expect 2000ms / 200ms = 10 intervals → Flashblocks: index 0 (base) + // + indices 1-10 = potentially 11 total + // + // - Unichain (1s blocks): expect 1000ms / 200ms = 5 intervals → Flashblocks: index 0 (base) + // + indices 1-5 = potentially 6 total + // + // Why compute at N-1 instead of N: + // 1. Timing variance in flashblock producing time may mean only N flashblocks were produced + // instead of N+1 (missing the final one). Computing at N-1 ensures we get the state root + // for most common cases. + // + // 2. The +1 case (index 0 base + N intervals): If all N+1 flashblocks do arrive, we'll + // still calculate state root for flashblock N, which sacrifices a little performance but + // still ensures correctness for common cases. + // + // Note: Pathological cases may result in fewer flashblocks than expected (e.g., builder + // downtime, flashblock execution exceeding timing budget). When this occurs, we won't + // compute the state root, causing FlashblockConsensusClient to lack precomputed state for + // engine_newPayload. This is safe: we still have op-node as backstop to maintain + // chain progression. + let block_time_ms = (base.timestamp - local_tip_timestamp) * 1000; + let expected_final_flashblock = block_time_ms / FLASHBLOCK_BLOCK_TIME; + let compute_state_root = self.compute_state_root && + last_flashblock.diff.state_root.is_zero() && + last_flashblock.index >= expected_final_flashblock.saturating_sub(1); + + trace!( + target: "flashblocks", + block_number = base.block_number, + source = source_name, + flashblock_index = last_flashblock.index, + expected_final_flashblock, + compute_state_root_enabled = self.compute_state_root, + state_root_is_zero = last_flashblock.diff.state_root.is_zero(), + will_compute_state_root = compute_state_root, + "Building from flashblock sequence" + ); + + Some(BuildArgs { + base, + transactions, + cached_state, + last_flashblock_index: last_flashblock.index, + last_flashblock_hash: last_flashblock.diff.block_hash, + compute_state_root, + }) + } + + /// Records the result of building a sequence and re-broadcasts with execution outcome. + /// + /// Updates execution outcome and cached reads. For cached sequences (already broadcast + /// once during finalize), this broadcasts again with the computed `state_root`, allowing + /// the consensus client to submit via `engine_newPayload`. + pub(crate) fn on_build_complete( + &mut self, + parent_hash: B256, + result: Option<(PendingFlashBlock, CachedReads)>, + ) { + let Some((computed_block, cached_reads)) = result else { + return; + }; + + // Extract execution outcome + let execution_outcome = computed_block.computed_state_root().map(|state_root| { + SequenceExecutionOutcome { block_hash: computed_block.block().hash(), state_root } + }); + + // Update pending sequence with execution results + if self.pending.payload_base().is_some_and(|base| base.parent_hash == parent_hash) { + self.pending.set_execution_outcome(execution_outcome); + self.pending.set_cached_reads(cached_reads); + trace!( + target: "flashblocks", + block_number = self.pending.block_number(), + has_computed_state_root = execution_outcome.is_some(), + "Updated pending sequence with build results" + ); + } + // Check if this completed sequence in cache and broadcast with execution outcome + else if let Some((cached, _)) = self + .completed_cache + .iter_mut() + .find(|(c, _)| c.payload_base().parent_hash == parent_hash) + { + // Only re-broadcast if we computed new information (state_root was missing). + // If sequencer already provided state_root, we already broadcast in insert_flashblock, + // so skip re-broadcast to avoid duplicate FCU calls. + let needs_rebroadcast = + execution_outcome.is_some() && cached.execution_outcome().is_none(); + + cached.set_execution_outcome(execution_outcome); + + if needs_rebroadcast && self.block_broadcaster.receiver_count() > 0 { + trace!( + target: "flashblocks", + block_number = cached.block_number(), + "Re-broadcasting sequence with computed state_root" + ); + let _ = self.block_broadcaster.send(cached.clone()); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::TestFlashBlockFactory; + use alloy_primitives::B256; + use op_alloy_consensus::OpTxEnvelope; + + #[test] + fn test_sequence_manager_new() { + let manager: SequenceManager = SequenceManager::new(true); + assert_eq!(manager.pending().count(), 0); + } + + #[test] + fn test_insert_flashblock_creates_pending_sequence() { + let mut manager: SequenceManager = SequenceManager::new(true); + let factory = TestFlashBlockFactory::new(); + + let fb0 = factory.flashblock_at(0).build(); + manager.insert_flashblock(fb0).unwrap(); + + assert_eq!(manager.pending().count(), 1); + assert_eq!(manager.pending().block_number(), Some(100)); + } + + #[test] + fn test_insert_flashblock_caches_completed_sequence() { + let mut manager: SequenceManager = SequenceManager::new(true); + let factory = TestFlashBlockFactory::new(); + + // Build first sequence + let fb0 = factory.flashblock_at(0).build(); + manager.insert_flashblock(fb0.clone()).unwrap(); + + let fb1 = factory.flashblock_after(&fb0).build(); + manager.insert_flashblock(fb1).unwrap(); + + // Insert new base (index 0) which should finalize and cache previous sequence + let fb2 = factory.flashblock_for_next_block(&fb0).build(); + manager.insert_flashblock(fb2).unwrap(); + + // New sequence should be pending + assert_eq!(manager.pending().count(), 1); + assert_eq!(manager.pending().block_number(), Some(101)); + assert_eq!(manager.completed_cache.len(), 1); + let (cached_sequence, _txs) = manager.completed_cache.get(0).unwrap(); + assert_eq!(cached_sequence.block_number(), 100); + } + + #[test] + fn test_next_buildable_args_returns_none_when_empty() { + let mut manager: SequenceManager = SequenceManager::new(true); + let local_tip_hash = B256::random(); + let local_tip_timestamp = 1000; + + let args = manager.next_buildable_args(local_tip_hash, local_tip_timestamp); + assert!(args.is_none()); + } + + #[test] + fn test_next_buildable_args_matches_pending_parent() { + let mut manager: SequenceManager = SequenceManager::new(true); + let factory = TestFlashBlockFactory::new(); + + let fb0 = factory.flashblock_at(0).build(); + let parent_hash = fb0.base.as_ref().unwrap().parent_hash; + manager.insert_flashblock(fb0).unwrap(); + + let args = manager.next_buildable_args(parent_hash, 1000000); + assert!(args.is_some()); + + let build_args = args.unwrap(); + assert_eq!(build_args.last_flashblock_index, 0); + } + + #[test] + fn test_next_buildable_args_returns_none_when_parent_mismatch() { + let mut manager: SequenceManager = SequenceManager::new(true); + let factory = TestFlashBlockFactory::new(); + + let fb0 = factory.flashblock_at(0).build(); + manager.insert_flashblock(fb0).unwrap(); + + // Use different parent hash + let wrong_parent = B256::random(); + let args = manager.next_buildable_args(wrong_parent, 1000000); + assert!(args.is_none()); + } + + #[test] + fn test_next_buildable_args_prefers_pending_over_cached() { + let mut manager: SequenceManager = SequenceManager::new(true); + let factory = TestFlashBlockFactory::new(); + + // Create and finalize first sequence + let fb0 = factory.flashblock_at(0).build(); + manager.insert_flashblock(fb0.clone()).unwrap(); + + // Create new sequence (finalizes previous) + let fb1 = factory.flashblock_for_next_block(&fb0).build(); + let parent_hash = fb1.base.as_ref().unwrap().parent_hash; + manager.insert_flashblock(fb1).unwrap(); + + // Request with first sequence's parent (should find cached) + let args = manager.next_buildable_args(parent_hash, 1000000); + assert!(args.is_some()); + } + + #[test] + fn test_next_buildable_args_finds_cached_sequence() { + let mut manager: SequenceManager = SequenceManager::new(true); + let factory = TestFlashBlockFactory::new(); + + // Build and cache first sequence + let fb0 = factory.flashblock_at(0).build(); + let parent_hash = fb0.base.as_ref().unwrap().parent_hash; + manager.insert_flashblock(fb0.clone()).unwrap(); + + // Start new sequence to finalize first + let fb1 = factory.flashblock_for_next_block(&fb0).build(); + manager.insert_flashblock(fb1.clone()).unwrap(); + + // Clear pending by starting another sequence + let fb2 = factory.flashblock_for_next_block(&fb1).build(); + manager.insert_flashblock(fb2).unwrap(); + + // Request first sequence's parent - should find in cache + let args = manager.next_buildable_args(parent_hash, 1000000); + assert!(args.is_some()); + } + + #[test] + fn test_compute_state_root_logic_near_expected_final() { + let mut manager: SequenceManager = SequenceManager::new(true); + let block_time = 2u64; + let factory = TestFlashBlockFactory::new().with_block_time(block_time); + + // Create sequence with zero state root (needs computation) + let fb0 = factory.flashblock_at(0).state_root(B256::ZERO).build(); + let parent_hash = fb0.base.as_ref().unwrap().parent_hash; + let base_timestamp = fb0.base.as_ref().unwrap().timestamp; + manager.insert_flashblock(fb0.clone()).unwrap(); + + // Add flashblocks up to expected final index (2000ms / 200ms = 10) + for i in 1..=9 { + let fb = factory.flashblock_after(&fb0).index(i).state_root(B256::ZERO).build(); + manager.insert_flashblock(fb).unwrap(); + } + + // Request with proper timing - should compute state root for index 9 + let args = manager.next_buildable_args(parent_hash, base_timestamp - block_time); + assert!(args.is_some()); + assert!(args.unwrap().compute_state_root); + } + + #[test] + fn test_no_compute_state_root_when_provided_by_sequencer() { + let mut manager: SequenceManager = SequenceManager::new(true); + let block_time = 2u64; + let factory = TestFlashBlockFactory::new().with_block_time(block_time); + + // Create sequence with non-zero state root (provided by sequencer) + let fb0 = factory.flashblock_at(0).state_root(B256::random()).build(); + let parent_hash = fb0.base.as_ref().unwrap().parent_hash; + let base_timestamp = fb0.base.as_ref().unwrap().timestamp; + manager.insert_flashblock(fb0).unwrap(); + + let args = manager.next_buildable_args(parent_hash, base_timestamp - block_time); + assert!(args.is_some()); + assert!(!args.unwrap().compute_state_root); + } + + #[test] + fn test_no_compute_state_root_when_disabled() { + let mut manager: SequenceManager = SequenceManager::new(false); + let block_time = 2u64; + let factory = TestFlashBlockFactory::new().with_block_time(block_time); + + // Create sequence with zero state root (needs computation) + let fb0 = factory.flashblock_at(0).state_root(B256::ZERO).build(); + let parent_hash = fb0.base.as_ref().unwrap().parent_hash; + let base_timestamp = fb0.base.as_ref().unwrap().timestamp; + manager.insert_flashblock(fb0.clone()).unwrap(); + + // Add flashblocks up to expected final index (2000ms / 200ms = 10) + for i in 1..=9 { + let fb = factory.flashblock_after(&fb0).index(i).state_root(B256::ZERO).build(); + manager.insert_flashblock(fb).unwrap(); + } + + // Request with proper timing - should compute state root for index 9 + let args = manager.next_buildable_args(parent_hash, base_timestamp - block_time); + assert!(args.is_some()); + assert!(!args.unwrap().compute_state_root); + } + + #[test] + fn test_cache_ring_buffer_evicts_oldest() { + let mut manager: SequenceManager = SequenceManager::new(true); + let factory = TestFlashBlockFactory::new(); + + // Fill cache with 4 sequences (cache size is 3, so oldest should be evicted) + let mut last_fb = factory.flashblock_at(0).build(); + manager.insert_flashblock(last_fb.clone()).unwrap(); + + for _ in 0..3 { + last_fb = factory.flashblock_for_next_block(&last_fb).build(); + manager.insert_flashblock(last_fb.clone()).unwrap(); + } + + // The first sequence should have been evicted, so we can't build it + let first_parent = factory.flashblock_at(0).build().base.unwrap().parent_hash; + let args = manager.next_buildable_args(first_parent, 1000000); + // Should not find it (evicted from ring buffer) + assert!(args.is_none()); + } +} diff --git a/crates/optimism/flashblocks/src/consensus.rs b/crates/optimism/flashblocks/src/consensus.rs index 65926a4d911..0b502c07387 100644 --- a/crates/optimism/flashblocks/src/consensus.rs +++ b/crates/optimism/flashblocks/src/consensus.rs @@ -5,13 +5,13 @@ use op_alloy_rpc_types_engine::OpExecutionData; use reth_engine_primitives::ConsensusEngineHandle; use reth_optimism_payload_builder::OpPayloadTypes; use reth_payload_primitives::{EngineApiMessageVersion, ExecutionPayload, PayloadTypes}; -use ringbuffer::{AllocRingBuffer, RingBuffer}; use tracing::*; -/// Cache entry for block information: (block hash, block number, timestamp). -type BlockCacheEntry = (B256, u64, u64); - -/// Consensus client that sends FCUs and new payloads using blocks from a [`FlashBlockService`] +/// Consensus client that sends FCUs and new payloads using blocks from a [`FlashBlockService`]. +/// +/// This client receives completed flashblock sequences and: +/// - Attempts to submit `engine_newPayload` if `state_root` is available (non-zero) +/// - Always sends `engine_forkChoiceUpdated` to drive chain forward /// /// [`FlashBlockService`]: crate::FlashBlockService #[derive(Debug)] @@ -21,9 +21,8 @@ where { /// Handle to execution client. engine_handle: ConsensusEngineHandle

, + /// Receiver for completed flashblock sequences from `FlashBlockService`. sequence_receiver: FlashBlockCompleteSequenceRx, - /// Caches previous block info for lookup: (block hash, block number, timestamp). - block_hash_buffer: AllocRingBuffer, } impl

FlashBlockConsensusClient

@@ -32,126 +31,30 @@ where P::ExecutionData: for<'a> TryFrom<&'a FlashBlockCompleteSequence, Error: std::fmt::Display>, { /// Create a new `FlashBlockConsensusClient` with the given Op engine and sequence receiver. - pub fn new( + pub const fn new( engine_handle: ConsensusEngineHandle

, sequence_receiver: FlashBlockCompleteSequenceRx, ) -> eyre::Result { - // Buffer size of 768 blocks (64 * 12) supports 1s block time chains like Unichain. - // Oversized for 2s block time chains like Base, but acceptable given minimal memory usage. - let block_hash_buffer = AllocRingBuffer::new(768); - Ok(Self { engine_handle, sequence_receiver, block_hash_buffer }) - } - - /// Return the safe and finalized block hash for FCU calls. - /// - /// Safe blocks are considered 32 L1 blocks (approximately 384s at 12s/block) behind the head, - /// and finalized blocks are 64 L1 blocks (approximately 768s) behind the head. This - /// approximation, while not precisely matching the OP stack's derivation, provides - /// sufficient proximity and enables op-reth to sync the chain independently of an op-node. - /// The offset is dynamically adjusted based on the actual block time detected from the - /// buffer. - fn get_safe_and_finalized_block_hash(&self) -> (B256, B256) { - let cached_blocks_count = self.block_hash_buffer.len(); - - // Not enough blocks to determine safe/finalized yet - if cached_blocks_count < 2 { - return (B256::ZERO, B256::ZERO); - } - - // Calculate average block time using block numbers to handle missing blocks correctly. - // By dividing timestamp difference by block number difference, we get accurate block - // time even when blocks are missing from the buffer. - let (_, latest_block_number, latest_timestamp) = - self.block_hash_buffer.get(cached_blocks_count - 1).unwrap(); - let (_, previous_block_number, previous_timestamp) = - self.block_hash_buffer.get(cached_blocks_count - 2).unwrap(); - let timestamp_delta = latest_timestamp.saturating_sub(*previous_timestamp); - let block_number_delta = latest_block_number.saturating_sub(*previous_block_number).max(1); - let block_time_secs = timestamp_delta / block_number_delta; - - // L1 reference: 32 blocks * 12s = 384s for safe, 64 blocks * 12s = 768s for finalized - const SAFE_TIME_SECS: u64 = 384; - const FINALIZED_TIME_SECS: u64 = 768; - - // Calculate how many L2 blocks correspond to these L1 time periods - let safe_block_offset = - (SAFE_TIME_SECS / block_time_secs).min(cached_blocks_count as u64) as usize; - let finalized_block_offset = - (FINALIZED_TIME_SECS / block_time_secs).min(cached_blocks_count as u64) as usize; - - // Get safe hash: offset from end of buffer - let safe_hash = self - .block_hash_buffer - .get(cached_blocks_count.saturating_sub(safe_block_offset)) - .map(|&(hash, _, _)| hash) - .unwrap(); - - // Get finalized hash: offset from end of buffer - let finalized_hash = self - .block_hash_buffer - .get(cached_blocks_count.saturating_sub(finalized_block_offset)) - .map(|&(hash, _, _)| hash) - .unwrap(); - - (safe_hash, finalized_hash) + Ok(Self { engine_handle, sequence_receiver }) } - /// Receive the next flashblock sequence and cache its block information. + /// Attempts to submit a new payload to the engine. /// - /// Returns `None` if receiving fails (error is already logged). - async fn receive_and_cache_sequence(&mut self) -> Option { - match self.sequence_receiver.recv().await { - Ok(sequence) => { - self.block_hash_buffer.push(( - sequence.payload_base().parent_hash, - sequence.block_number(), - sequence.payload_base().timestamp, - )); - Some(sequence) - } - Err(err) => { - error!( - target: "flashblocks", - %err, - "error while fetching flashblock completed sequence", - ); - None - } - } - } - - /// Convert a flashblock sequence to an execution payload. + /// The `TryFrom` conversion will fail if `execution_outcome.state_root` is `B256::ZERO`, + /// in which case this returns the `parent_hash` instead to drive the chain forward. /// - /// Returns `None` if conversion fails (error is already logged). - fn convert_sequence_to_payload( - &self, - sequence: &FlashBlockCompleteSequence, - ) -> Option { - match P::ExecutionData::try_from(sequence) { - Ok(payload) => Some(payload), + /// Returns the block hash to use for FCU (either the new block or parent). + async fn submit_new_payload(&self, sequence: &FlashBlockCompleteSequence) -> B256 { + let payload = match P::ExecutionData::try_from(sequence) { + Ok(payload) => payload, Err(err) => { - error!( - target: "flashblocks", - %err, - "error while converting to payload from completed sequence", - ); - None + trace!(target: "flashblocks", %err, "Failed payload conversion, using parent hash"); + return sequence.payload_base().parent_hash; } - } - } + }; - /// Submit a new payload to the engine. - /// - /// Returns `Ok(block_hash)` if the payload was accepted, `Err(())` otherwise (errors are - /// logged). - async fn submit_new_payload( - &self, - payload: P::ExecutionData, - sequence: &FlashBlockCompleteSequence, - ) -> Result { let block_number = payload.block_number(); let block_hash = payload.block_hash(); - match self.engine_handle.new_payload(payload).await { Ok(result) => { debug!( @@ -171,10 +74,7 @@ where %validation_error, "Payload validation error", ); - return Err(()); - } - - Ok(block_hash) + }; } Err(err) => { error!( @@ -183,9 +83,10 @@ where block_number, "Failed to submit new payload", ); - Err(()) } } + + block_hash } /// Submit a forkchoice update to the engine. @@ -195,7 +96,8 @@ where sequence: &FlashBlockCompleteSequence, ) { let block_number = sequence.block_number(); - let (safe_hash, finalized_hash) = self.get_safe_and_finalized_block_hash(); + let safe_hash = sequence.payload_base().parent_hash; + let finalized_hash = sequence.payload_base().parent_hash; let fcu_state = alloy_rpc_types_engine::ForkchoiceState { head_block_hash, safe_block_hash: safe_hash, @@ -233,39 +135,324 @@ where } } - /// Spawn the client to start sending FCUs and new payloads by periodically fetching recent - /// blocks. + /// Runs the consensus client loop. + /// + /// Continuously receives completed flashblock sequences and submits them to the execution + /// engine: + /// 1. Attempts `engine_newPayload` (only if `state_root` is available) + /// 2. Always sends `engine_forkChoiceUpdated` to drive chain forward pub async fn run(mut self) { loop { - let Some(sequence) = self.receive_and_cache_sequence().await else { - continue; - }; - - let Some(payload) = self.convert_sequence_to_payload(&sequence) else { + let Ok(sequence) = self.sequence_receiver.recv().await else { continue; }; - let Ok(block_hash) = self.submit_new_payload(payload, &sequence).await else { - continue; - }; + // Returns block_hash for FCU: + // - If state_root is available: submits newPayload and returns the new block's hash + // - If state_root is zero: skips newPayload and returns parent_hash (no progress yet) + let block_hash = self.submit_new_payload(&sequence).await; self.submit_forkchoice_update(block_hash, &sequence).await; } } } -impl From<&FlashBlockCompleteSequence> for OpExecutionData { - fn from(sequence: &FlashBlockCompleteSequence) -> Self { +impl TryFrom<&FlashBlockCompleteSequence> for OpExecutionData { + type Error = &'static str; + + fn try_from(sequence: &FlashBlockCompleteSequence) -> Result { let mut data = Self::from_flashblocks_unchecked(sequence); - // Replace payload's state_root with the calculated one. For flashblocks, there was an - // option to disable state root calculation for blocks, and in that case, the payload's - // state_root will be zero, and we'll need to locally calculate state_root before - // proceeding to call engine_newPayload. + + // If execution outcome is available, use the computed state_root and block_hash. + // FlashBlockService computes these when building sequences on top of the local tip. if let Some(execution_outcome) = sequence.execution_outcome() { let payload = data.payload.as_v1_mut(); payload.state_root = execution_outcome.state_root; payload.block_hash = execution_outcome.block_hash; } - data + + // Only proceed if we have a valid state_root (non-zero). + if data.payload.as_v1_mut().state_root == B256::ZERO { + return Err("No state_root available for payload"); + } + + Ok(data) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{sequence::SequenceExecutionOutcome, test_utils::TestFlashBlockFactory}; + + mod op_execution_data_conversion { + use super::*; + + #[test] + fn test_try_from_fails_with_zero_state_root() { + // When execution_outcome is None, state_root remains zero and conversion fails + let factory = TestFlashBlockFactory::new(); + let fb0 = factory.flashblock_at(0).state_root(B256::ZERO).build(); + + let sequence = FlashBlockCompleteSequence::new(vec![fb0], None).unwrap(); + + let result = OpExecutionData::try_from(&sequence); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "No state_root available for payload"); + } + + #[test] + fn test_try_from_succeeds_with_execution_outcome() { + // When execution_outcome has state_root, conversion succeeds + let factory = TestFlashBlockFactory::new(); + let fb0 = factory.flashblock_at(0).state_root(B256::ZERO).build(); + + let execution_outcome = SequenceExecutionOutcome { + block_hash: B256::random(), + state_root: B256::random(), // Non-zero + }; + + let sequence = + FlashBlockCompleteSequence::new(vec![fb0], Some(execution_outcome)).unwrap(); + + let result = OpExecutionData::try_from(&sequence); + assert!(result.is_ok()); + + let mut data = result.unwrap(); + assert_eq!(data.payload.as_v1_mut().state_root, execution_outcome.state_root); + assert_eq!(data.payload.as_v1_mut().block_hash, execution_outcome.block_hash); + } + + #[test] + fn test_try_from_succeeds_with_provided_state_root() { + // When sequencer provides non-zero state_root, conversion succeeds + let factory = TestFlashBlockFactory::new(); + let provided_state_root = B256::random(); + let fb0 = factory.flashblock_at(0).state_root(provided_state_root).build(); + + let sequence = FlashBlockCompleteSequence::new(vec![fb0], None).unwrap(); + + let result = OpExecutionData::try_from(&sequence); + assert!(result.is_ok()); + + let mut data = result.unwrap(); + assert_eq!(data.payload.as_v1_mut().state_root, provided_state_root); + } + + #[test] + fn test_try_from_execution_outcome_overrides_provided_state_root() { + // execution_outcome takes precedence over sequencer-provided state_root + let factory = TestFlashBlockFactory::new(); + let provided_state_root = B256::random(); + let fb0 = factory.flashblock_at(0).state_root(provided_state_root).build(); + + let execution_outcome = SequenceExecutionOutcome { + block_hash: B256::random(), + state_root: B256::random(), // Different from provided + }; + + let sequence = + FlashBlockCompleteSequence::new(vec![fb0], Some(execution_outcome)).unwrap(); + + let result = OpExecutionData::try_from(&sequence); + assert!(result.is_ok()); + + let mut data = result.unwrap(); + // Should use execution_outcome, not the provided state_root + assert_eq!(data.payload.as_v1_mut().state_root, execution_outcome.state_root); + assert_ne!(data.payload.as_v1_mut().state_root, provided_state_root); + } + + #[test] + fn test_try_from_with_multiple_flashblocks() { + // Test conversion with sequence of multiple flashblocks + let factory = TestFlashBlockFactory::new(); + let fb0 = factory.flashblock_at(0).state_root(B256::ZERO).build(); + let fb1 = factory.flashblock_after(&fb0).state_root(B256::ZERO).build(); + let fb2 = factory.flashblock_after(&fb1).state_root(B256::ZERO).build(); + + let execution_outcome = + SequenceExecutionOutcome { block_hash: B256::random(), state_root: B256::random() }; + + let sequence = + FlashBlockCompleteSequence::new(vec![fb0, fb1, fb2], Some(execution_outcome)) + .unwrap(); + + let result = OpExecutionData::try_from(&sequence); + assert!(result.is_ok()); + + let mut data = result.unwrap(); + assert_eq!(data.payload.as_v1_mut().state_root, execution_outcome.state_root); + assert_eq!(data.payload.as_v1_mut().block_hash, execution_outcome.block_hash); + } + } + + mod consensus_client_creation { + use super::*; + use tokio::sync::broadcast; + + #[test] + fn test_new_creates_client() { + let (engine_tx, _) = tokio::sync::mpsc::unbounded_channel(); + let engine_handle = ConsensusEngineHandle::::new(engine_tx); + + let (_, sequence_rx) = broadcast::channel(1); + + let result = FlashBlockConsensusClient::new(engine_handle, sequence_rx); + assert!(result.is_ok()); + } + } + + mod submit_new_payload_behavior { + use super::*; + + #[test] + fn test_submit_new_payload_returns_parent_hash_when_no_state_root() { + // When conversion fails (no state_root), should return parent_hash + let factory = TestFlashBlockFactory::new(); + let fb0 = factory.flashblock_at(0).state_root(B256::ZERO).build(); + let parent_hash = fb0.base.as_ref().unwrap().parent_hash; + + let sequence = FlashBlockCompleteSequence::new(vec![fb0], None).unwrap(); + + // Verify conversion would fail + let conversion_result = OpExecutionData::try_from(&sequence); + assert!(conversion_result.is_err()); + + // In the actual run loop, submit_new_payload would return parent_hash + assert_eq!(sequence.payload_base().parent_hash, parent_hash); + } + + #[test] + fn test_submit_new_payload_returns_block_hash_when_state_root_available() { + // When conversion succeeds, should return the new block's hash + let factory = TestFlashBlockFactory::new(); + let fb0 = factory.flashblock_at(0).state_root(B256::ZERO).build(); + + let execution_outcome = + SequenceExecutionOutcome { block_hash: B256::random(), state_root: B256::random() }; + + let sequence = + FlashBlockCompleteSequence::new(vec![fb0], Some(execution_outcome)).unwrap(); + + // Verify conversion succeeds + let conversion_result = OpExecutionData::try_from(&sequence); + assert!(conversion_result.is_ok()); + + let mut data = conversion_result.unwrap(); + assert_eq!(data.payload.as_v1_mut().block_hash, execution_outcome.block_hash); + } + } + + mod forkchoice_update_behavior { + use super::*; + + #[test] + fn test_forkchoice_state_uses_parent_hash_for_safe_and_finalized() { + // Both safe_hash and finalized_hash should be set to parent_hash + let factory = TestFlashBlockFactory::new(); + let fb0 = factory.flashblock_at(0).build(); + let parent_hash = fb0.base.as_ref().unwrap().parent_hash; + + let sequence = FlashBlockCompleteSequence::new(vec![fb0], None).unwrap(); + + // Verify the expected forkchoice state + assert_eq!(sequence.payload_base().parent_hash, parent_hash); + } + + #[test] + fn test_forkchoice_update_with_new_block_hash() { + // When newPayload succeeds, FCU should use the new block's hash as head + let factory = TestFlashBlockFactory::new(); + let fb0 = factory.flashblock_at(0).state_root(B256::ZERO).build(); + + let execution_outcome = + SequenceExecutionOutcome { block_hash: B256::random(), state_root: B256::random() }; + + let sequence = + FlashBlockCompleteSequence::new(vec![fb0], Some(execution_outcome)).unwrap(); + + // The head_block_hash for FCU would be execution_outcome.block_hash + assert_eq!( + sequence.execution_outcome().unwrap().block_hash, + execution_outcome.block_hash + ); + } + + #[test] + fn test_forkchoice_update_with_parent_hash_when_no_state_root() { + // When newPayload is skipped (no state_root), FCU should use parent_hash as head + let factory = TestFlashBlockFactory::new(); + let fb0 = factory.flashblock_at(0).state_root(B256::ZERO).build(); + let parent_hash = fb0.base.as_ref().unwrap().parent_hash; + + let sequence = FlashBlockCompleteSequence::new(vec![fb0], None).unwrap(); + + // The head_block_hash for FCU would be parent_hash (fallback) + assert_eq!(sequence.payload_base().parent_hash, parent_hash); + } + } + + mod run_loop_logic { + use super::*; + + #[test] + fn test_run_loop_processes_sequence_with_state_root() { + // Scenario: Sequence with state_root should trigger both newPayload and FCU + let factory = TestFlashBlockFactory::new(); + let fb0 = factory.flashblock_at(0).state_root(B256::ZERO).build(); + + let execution_outcome = + SequenceExecutionOutcome { block_hash: B256::random(), state_root: B256::random() }; + + let sequence = + FlashBlockCompleteSequence::new(vec![fb0], Some(execution_outcome)).unwrap(); + + // Verify sequence is ready for newPayload + let conversion = OpExecutionData::try_from(&sequence); + assert!(conversion.is_ok()); + } + + #[test] + fn test_run_loop_processes_sequence_without_state_root() { + // Scenario: Sequence without state_root should skip newPayload but still do FCU + let factory = TestFlashBlockFactory::new(); + let fb0 = factory.flashblock_at(0).state_root(B256::ZERO).build(); + + let sequence = FlashBlockCompleteSequence::new(vec![fb0], None).unwrap(); + + // Verify sequence cannot be converted (newPayload will be skipped) + let conversion = OpExecutionData::try_from(&sequence); + assert!(conversion.is_err()); + + // But FCU should still happen with parent_hash + assert!(sequence.payload_base().parent_hash != B256::ZERO); + } + + #[test] + fn test_run_loop_handles_multiple_sequences() { + // Multiple sequences should be processed independently + let factory = TestFlashBlockFactory::new(); + + // Sequence 1: With state_root + let fb0_seq1 = factory.flashblock_at(0).state_root(B256::ZERO).build(); + let outcome1 = + SequenceExecutionOutcome { block_hash: B256::random(), state_root: B256::random() }; + let seq1 = + FlashBlockCompleteSequence::new(vec![fb0_seq1.clone()], Some(outcome1)).unwrap(); + + // Sequence 2: Without state_root (for next block) + let fb0_seq2 = factory.flashblock_for_next_block(&fb0_seq1).build(); + let seq2 = FlashBlockCompleteSequence::new(vec![fb0_seq2], None).unwrap(); + + // Both should be valid sequences + assert_eq!(seq1.block_number(), 100); + assert_eq!(seq2.block_number(), 101); + + // seq1 can be converted + assert!(OpExecutionData::try_from(&seq1).is_ok()); + // seq2 cannot be converted + assert!(OpExecutionData::try_from(&seq2).is_err()); + } } } diff --git a/crates/optimism/flashblocks/src/lib.rs b/crates/optimism/flashblocks/src/lib.rs index 74f202aed7a..6c5d9c1e86e 100644 --- a/crates/optimism/flashblocks/src/lib.rs +++ b/crates/optimism/flashblocks/src/lib.rs @@ -28,6 +28,11 @@ pub use service::{FlashBlockBuildInfo, FlashBlockService}; mod worker; +mod cache; + +#[cfg(test)] +mod test_utils; + mod ws; pub use ws::{WsConnect, WsFlashBlockStream}; diff --git a/crates/optimism/flashblocks/src/sequence.rs b/crates/optimism/flashblocks/src/sequence.rs index 409a13f5163..abf9e6d514c 100644 --- a/crates/optimism/flashblocks/src/sequence.rs +++ b/crates/optimism/flashblocks/src/sequence.rs @@ -1,20 +1,19 @@ use crate::{FlashBlock, FlashBlockCompleteSequenceRx}; -use alloy_eips::eip2718::WithEncoded; use alloy_primitives::{Bytes, B256}; use alloy_rpc_types_engine::PayloadId; use core::mem; use eyre::{bail, OptionExt}; use op_alloy_rpc_types_engine::OpFlashblockPayloadBase; -use reth_primitives_traits::{Recovered, SignedTransaction}; +use reth_revm::cached::CachedReads; use std::{collections::BTreeMap, ops::Deref}; use tokio::sync::broadcast; -use tracing::{debug, trace, warn}; +use tracing::*; /// The size of the broadcast channel for completed flashblock sequences. const FLASHBLOCK_SEQUENCE_CHANNEL_SIZE: usize = 128; /// Outcome from executing a flashblock sequence. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct SequenceExecutionOutcome { /// The block hash of the executed pending block pub block_hash: B256, @@ -24,25 +23,32 @@ pub struct SequenceExecutionOutcome { /// An ordered B-tree keeping the track of a sequence of [`FlashBlock`]s by their indices. #[derive(Debug)] -pub struct FlashBlockPendingSequence { +pub struct FlashBlockPendingSequence { /// tracks the individual flashblocks in order - inner: BTreeMap>, + inner: BTreeMap, /// Broadcasts flashblocks to subscribers. block_broadcaster: broadcast::Sender, /// Optional execution outcome from building the current sequence. execution_outcome: Option, + /// Cached state reads for the current block. + /// Current `PendingFlashBlock` is built out of a sequence of `FlashBlocks`, and executed again + /// when fb received on top of the same block. Avoid redundant I/O across multiple + /// executions within the same block. + cached_reads: Option, } -impl FlashBlockPendingSequence -where - T: SignedTransaction, -{ +impl FlashBlockPendingSequence { /// Create a new pending sequence. pub fn new() -> Self { // Note: if the channel is full, send will not block but rather overwrite the oldest // messages. Order is preserved. let (tx, _) = broadcast::channel(FLASHBLOCK_SEQUENCE_CHANNEL_SIZE); - Self { inner: BTreeMap::new(), block_broadcaster: tx, execution_outcome: None } + Self { + inner: BTreeMap::new(), + block_broadcaster: tx, + execution_outcome: None, + cached_reads: None, + } } /// Returns the sender half of the [`FlashBlockCompleteSequence`] channel. @@ -57,49 +63,14 @@ where self.block_broadcaster.subscribe() } - // Clears the state and broadcasts the blocks produced to subscribers. - fn clear_and_broadcast_blocks(&mut self) { - if self.inner.is_empty() { - return; - } - - let flashblocks = mem::take(&mut self.inner); - let execution_outcome = mem::take(&mut self.execution_outcome); - - // If there are any subscribers, send the flashblocks to them. - if self.block_broadcaster.receiver_count() > 0 { - let flashblocks = match FlashBlockCompleteSequence::new( - flashblocks.into_iter().map(|block| block.1.into()).collect(), - execution_outcome, - ) { - Ok(flashblocks) => flashblocks, - Err(err) => { - debug!(target: "flashblocks", error = ?err, "Failed to create full flashblock complete sequence"); - return; - } - }; - - // Note: this should only ever fail if there are no receivers. This can happen if - // there is a race condition between the clause right above and this - // one. We can simply warn the user and continue. - if let Err(err) = self.block_broadcaster.send(flashblocks) { - warn!(target: "flashblocks", error = ?err, "Failed to send flashblocks to subscribers"); - } - } - } - /// Inserts a new block into the sequence. /// /// A [`FlashBlock`] with index 0 resets the set. - pub fn insert(&mut self, flashblock: FlashBlock) -> eyre::Result<()> { + pub fn insert(&mut self, flashblock: FlashBlock) { if flashblock.index == 0 { trace!(target: "flashblocks", number=%flashblock.block_number(), "Tracking new flashblock sequence"); - - // Flash block at index zero resets the whole state. - self.clear_and_broadcast_blocks(); - - self.inner.insert(flashblock.index, PreparedFlashBlock::new(flashblock)?); - return Ok(()) + self.inner.insert(flashblock.index, flashblock); + return; } // only insert if we previously received the same block and payload, assume we received @@ -109,12 +80,10 @@ where if same_block && same_payload { trace!(target: "flashblocks", number=%flashblock.block_number(), index = %flashblock.index, block_count = self.inner.len() ,"Received followup flashblock"); - self.inner.insert(flashblock.index, PreparedFlashBlock::new(flashblock)?); + self.inner.insert(flashblock.index, flashblock); } else { trace!(target: "flashblocks", number=%flashblock.block_number(), index = %flashblock.index, current=?self.block_number() ,"Ignoring untracked flashblock following"); } - - Ok(()) } /// Set execution outcome from building the flashblock sequence @@ -125,31 +94,24 @@ where self.execution_outcome = execution_outcome; } - /// Iterator over sequence of executable transactions. - /// - /// A flashblocks is not ready if there's missing previous flashblocks, i.e. there's a gap in - /// the sequence - /// - /// Note: flashblocks start at `index 0`. - pub fn ready_transactions(&self) -> impl Iterator>> + '_ { - self.inner - .values() - .enumerate() - .take_while(|(idx, block)| { - // flashblock index 0 is the first flashblock - block.block().index == *idx as u64 - }) - .flat_map(|(_, block)| block.txs.clone()) + /// Set cached reads for this sequence + pub fn set_cached_reads(&mut self, cached_reads: CachedReads) { + self.cached_reads = Some(cached_reads); + } + + /// Removes the cached reads for this sequence + pub const fn take_cached_reads(&mut self) -> Option { + self.cached_reads.take() } /// Returns the first block number pub fn block_number(&self) -> Option { - Some(self.inner.values().next()?.block().block_number()) + Some(self.inner.values().next()?.block_number()) } /// Returns the payload base of the first tracked flashblock. pub fn payload_base(&self) -> Option { - self.inner.values().next()?.block().base.clone() + self.inner.values().next()?.base.clone() } /// Returns the number of tracked flashblocks. @@ -159,23 +121,40 @@ where /// Returns the reference to the last flashblock. pub fn last_flashblock(&self) -> Option<&FlashBlock> { - self.inner.last_key_value().map(|(_, b)| &b.block) + self.inner.last_key_value().map(|(_, b)| b) } /// Returns the current/latest flashblock index in the sequence pub fn index(&self) -> Option { - Some(self.inner.values().last()?.block().index) + Some(self.inner.values().last()?.index) } /// Returns the payload id of the first tracked flashblock in the current sequence. pub fn payload_id(&self) -> Option { - Some(self.inner.values().next()?.block().payload_id) + Some(self.inner.values().next()?.payload_id) + } + + /// Finalizes the current pending sequence and returns it as a complete sequence. + /// + /// Clears the internal state and returns an error if the sequence is empty or validation fails. + pub fn finalize(&mut self) -> eyre::Result { + if self.inner.is_empty() { + bail!("Cannot finalize empty flashblock sequence"); + } + + let flashblocks = mem::take(&mut self.inner); + let execution_outcome = mem::take(&mut self.execution_outcome); + self.cached_reads = None; + + FlashBlockCompleteSequence::new(flashblocks.into_values().collect(), execution_outcome) + } + + /// Returns an iterator over all flashblocks in the sequence. + pub fn flashblocks(&self) -> impl Iterator { + self.inner.values() } } -impl Default for FlashBlockPendingSequence -where - T: SignedTransaction, -{ +impl Default for FlashBlockPendingSequence { fn default() -> Self { Self::new() } @@ -245,6 +224,14 @@ impl FlashBlockCompleteSequence { self.execution_outcome } + /// Updates execution outcome of the sequence. + pub const fn set_execution_outcome( + &mut self, + execution_outcome: Option, + ) { + self.execution_outcome = execution_outcome; + } + /// Returns all transactions from all flashblocks in the sequence pub fn all_transactions(&self) -> Vec { self.inner.iter().flat_map(|fb| fb.diff.transactions.iter().cloned()).collect() @@ -259,171 +246,437 @@ impl Deref for FlashBlockCompleteSequence { } } -impl TryFrom> for FlashBlockCompleteSequence { +impl TryFrom for FlashBlockCompleteSequence { type Error = eyre::Error; - fn try_from(sequence: FlashBlockPendingSequence) -> Result { - Self::new( - sequence.inner.into_values().map(|block| block.block().clone()).collect::>(), - sequence.execution_outcome, - ) + fn try_from(sequence: FlashBlockPendingSequence) -> Result { + Self::new(sequence.inner.into_values().collect(), sequence.execution_outcome) } } -#[derive(Debug)] -struct PreparedFlashBlock { - /// The prepared transactions, ready for execution - txs: Vec>>, - /// The tracked flashblock - block: FlashBlock, -} +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::TestFlashBlockFactory; -impl PreparedFlashBlock { - const fn block(&self) -> &FlashBlock { - &self.block - } -} + mod pending_sequence_insert { + use super::*; -impl From> for FlashBlock { - fn from(val: PreparedFlashBlock) -> Self { - val.block - } -} + #[test] + fn test_insert_index_zero_creates_new_sequence() { + let mut sequence = FlashBlockPendingSequence::new(); + let factory = TestFlashBlockFactory::new(); + let fb0 = factory.flashblock_at(0).build(); + let payload_id = fb0.payload_id; -impl PreparedFlashBlock -where - T: SignedTransaction, -{ - /// Creates a flashblock that is ready for execution by preparing all transactions - /// - /// Returns an error if decoding or signer recovery fails. - fn new(block: FlashBlock) -> eyre::Result { - let mut txs = Vec::with_capacity(block.diff.transactions.len()); - for encoded in block.diff.transactions.iter().cloned() { - let tx = T::decode_2718_exact(encoded.as_ref())?; - let signer = tx.try_recover()?; - let tx = WithEncoded::new(encoded, tx.with_signer(signer)); - txs.push(tx); + sequence.insert(fb0); + + assert_eq!(sequence.count(), 1); + assert_eq!(sequence.block_number(), Some(100)); + assert_eq!(sequence.payload_id(), Some(payload_id)); + } + + #[test] + fn test_insert_followup_same_block_and_payload() { + let mut sequence = FlashBlockPendingSequence::new(); + let factory = TestFlashBlockFactory::new(); + + let fb0 = factory.flashblock_at(0).build(); + sequence.insert(fb0.clone()); + + let fb1 = factory.flashblock_after(&fb0).build(); + sequence.insert(fb1.clone()); + + let fb2 = factory.flashblock_after(&fb1).build(); + sequence.insert(fb2); + + assert_eq!(sequence.count(), 3); + assert_eq!(sequence.index(), Some(2)); + } + + #[test] + fn test_insert_ignores_different_block_number() { + let mut sequence = FlashBlockPendingSequence::new(); + let factory = TestFlashBlockFactory::new(); + + let fb0 = factory.flashblock_at(0).build(); + sequence.insert(fb0.clone()); + + // Try to insert followup with different block number + let fb1 = factory.flashblock_after(&fb0).block_number(101).build(); + sequence.insert(fb1); + + assert_eq!(sequence.count(), 1); + assert_eq!(sequence.block_number(), Some(100)); + } + + #[test] + fn test_insert_ignores_different_payload_id() { + let mut sequence = FlashBlockPendingSequence::new(); + let factory = TestFlashBlockFactory::new(); + + let fb0 = factory.flashblock_at(0).build(); + let payload_id1 = fb0.payload_id; + sequence.insert(fb0.clone()); + + // Try to insert followup with different payload_id + let payload_id2 = alloy_rpc_types_engine::PayloadId::new([2u8; 8]); + let fb1 = factory.flashblock_after(&fb0).payload_id(payload_id2).build(); + sequence.insert(fb1); + + assert_eq!(sequence.count(), 1); + assert_eq!(sequence.payload_id(), Some(payload_id1)); } - Ok(Self { txs, block }) + #[test] + fn test_insert_maintains_btree_order() { + let mut sequence = FlashBlockPendingSequence::new(); + let factory = TestFlashBlockFactory::new(); + + let fb0 = factory.flashblock_at(0).build(); + sequence.insert(fb0.clone()); + + let fb2 = factory.flashblock_after(&fb0).index(2).build(); + sequence.insert(fb2); + + let fb1 = factory.flashblock_after(&fb0).build(); + sequence.insert(fb1); + + let indices: Vec = sequence.flashblocks().map(|fb| fb.index).collect(); + assert_eq!(indices, vec![0, 1, 2]); + } } -} -impl Deref for PreparedFlashBlock { - type Target = FlashBlock; + mod pending_sequence_finalize { + use super::*; - fn deref(&self) -> &Self::Target { - &self.block + #[test] + fn test_finalize_empty_sequence_fails() { + let mut sequence = FlashBlockPendingSequence::new(); + let result = sequence.finalize(); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().to_string(), + "Cannot finalize empty flashblock sequence" + ); + } + + #[test] + fn test_finalize_clears_pending_state() { + let mut sequence = FlashBlockPendingSequence::new(); + let factory = TestFlashBlockFactory::new(); + + let fb0 = factory.flashblock_at(0).build(); + sequence.insert(fb0); + + assert_eq!(sequence.count(), 1); + + let _complete = sequence.finalize().unwrap(); + + // After finalize, sequence should be empty + assert_eq!(sequence.count(), 0); + assert_eq!(sequence.block_number(), None); + } + + #[test] + fn test_finalize_preserves_execution_outcome() { + let mut sequence = FlashBlockPendingSequence::new(); + let factory = TestFlashBlockFactory::new(); + + let fb0 = factory.flashblock_at(0).build(); + sequence.insert(fb0); + + let outcome = + SequenceExecutionOutcome { block_hash: B256::random(), state_root: B256::random() }; + sequence.set_execution_outcome(Some(outcome)); + + let complete = sequence.finalize().unwrap(); + + assert_eq!(complete.execution_outcome(), Some(outcome)); + } + + #[test] + fn test_finalize_clears_cached_reads() { + let mut sequence = FlashBlockPendingSequence::new(); + let factory = TestFlashBlockFactory::new(); + + let fb0 = factory.flashblock_at(0).build(); + sequence.insert(fb0); + + let cached_reads = CachedReads::default(); + sequence.set_cached_reads(cached_reads); + assert!(sequence.take_cached_reads().is_some()); + + let _complete = sequence.finalize().unwrap(); + + // Cached reads should be cleared + assert!(sequence.take_cached_reads().is_none()); + } + + #[test] + fn test_finalize_multiple_times_after_refill() { + let mut sequence = FlashBlockPendingSequence::new(); + let factory = TestFlashBlockFactory::new(); + + // First sequence + let fb0 = factory.flashblock_at(0).build(); + sequence.insert(fb0); + + let complete1 = sequence.finalize().unwrap(); + assert_eq!(complete1.count(), 1); + + // Add new sequence for next block + let fb1 = factory.flashblock_for_next_block(&complete1.last().clone()).build(); + sequence.insert(fb1); + + let complete2 = sequence.finalize().unwrap(); + assert_eq!(complete2.count(), 1); + assert_eq!(complete2.block_number(), 101); + } } -} -#[cfg(test)] -mod tests { - use super::*; - use alloy_consensus::{ - transaction::SignerRecoverable, EthereumTxEnvelope, EthereumTypedTransaction, TxEip1559, - }; - use alloy_eips::Encodable2718; - use alloy_primitives::{hex, Signature, TxKind, U256}; - use op_alloy_rpc_types_engine::{ - OpFlashblockPayload, OpFlashblockPayloadBase, OpFlashblockPayloadDelta, - }; - - #[test] - fn test_sequence_stops_before_gap() { - let mut sequence = FlashBlockPendingSequence::new(); - let tx = EthereumTxEnvelope::new_unhashed( - EthereumTypedTransaction::::Eip1559(TxEip1559 { - chain_id: 4, - nonce: 26u64, - max_priority_fee_per_gas: 1500000000, - max_fee_per_gas: 1500000013, - gas_limit: 21_000u64, - to: TxKind::Call(hex!("61815774383099e24810ab832a5b2a5425c154d5").into()), - value: U256::from(3000000000000000000u64), - input: Default::default(), - access_list: Default::default(), - }), - Signature::new( - U256::from_be_bytes(hex!( - "59e6b67f48fb32e7e570dfb11e042b5ad2e55e3ce3ce9cd989c7e06e07feeafd" - )), - U256::from_be_bytes(hex!( - "016b83f4f980694ed2eee4d10667242b1f40dc406901b34125b008d334d47469" - )), - true, - ), - ); - let tx = Recovered::new_unchecked(tx.clone(), tx.recover_signer_unchecked().unwrap()); - - sequence - .insert(OpFlashblockPayload { - payload_id: Default::default(), - index: 0, - base: None, - diff: OpFlashblockPayloadDelta { - transactions: vec![tx.encoded_2718().into()], - ..Default::default() - }, - metadata: Default::default(), - }) - .unwrap(); - - sequence - .insert(OpFlashblockPayload { - payload_id: Default::default(), - index: 2, - base: None, - diff: Default::default(), - metadata: Default::default(), - }) - .unwrap(); - - let actual_txs: Vec<_> = sequence.ready_transactions().collect(); - let expected_txs = vec![WithEncoded::new(tx.encoded_2718().into(), tx)]; - - assert_eq!(actual_txs, expected_txs); + mod complete_sequence_invariants { + use super::*; + + #[test] + fn test_new_empty_sequence_fails() { + let result = FlashBlockCompleteSequence::new(vec![], None); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().to_string(), "No flashblocks in sequence"); + } + + #[test] + fn test_new_requires_base_at_index_zero() { + let factory = TestFlashBlockFactory::new(); + // Use builder() with index 1 first to create a flashblock, then change its index to 0 + // to bypass the auto-base creation logic + let mut fb0_no_base = factory.flashblock_at(1).build(); + fb0_no_base.index = 0; + fb0_no_base.base = None; + + let result = FlashBlockCompleteSequence::new(vec![fb0_no_base], None); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().to_string(), "Flashblock at index 0 has no base"); + } + + #[test] + fn test_new_validates_successive_indices() { + let factory = TestFlashBlockFactory::new(); + + let fb0 = factory.flashblock_at(0).build(); + // Skip index 1, go straight to 2 + let fb2 = factory.flashblock_after(&fb0).index(2).build(); + + let result = FlashBlockCompleteSequence::new(vec![fb0, fb2], None); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().to_string(), + "Flashblock inconsistencies detected in sequence" + ); + } + + #[test] + fn test_new_validates_same_block_number() { + let factory = TestFlashBlockFactory::new(); + + let fb0 = factory.flashblock_at(0).build(); + let fb1 = factory.flashblock_after(&fb0).block_number(101).build(); + + let result = FlashBlockCompleteSequence::new(vec![fb0, fb1], None); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().to_string(), + "Flashblock inconsistencies detected in sequence" + ); + } + + #[test] + fn test_new_validates_same_payload_id() { + let factory = TestFlashBlockFactory::new(); + + let fb0 = factory.flashblock_at(0).build(); + let payload_id2 = alloy_rpc_types_engine::PayloadId::new([2u8; 8]); + let fb1 = factory.flashblock_after(&fb0).payload_id(payload_id2).build(); + + let result = FlashBlockCompleteSequence::new(vec![fb0, fb1], None); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().to_string(), + "Flashblock inconsistencies detected in sequence" + ); + } + + #[test] + fn test_new_valid_single_flashblock() { + let factory = TestFlashBlockFactory::new(); + let fb0 = factory.flashblock_at(0).build(); + + let result = FlashBlockCompleteSequence::new(vec![fb0], None); + assert!(result.is_ok()); + + let complete = result.unwrap(); + assert_eq!(complete.count(), 1); + assert_eq!(complete.block_number(), 100); + } + + #[test] + fn test_new_valid_multiple_flashblocks() { + let factory = TestFlashBlockFactory::new(); + + let fb0 = factory.flashblock_at(0).build(); + let fb1 = factory.flashblock_after(&fb0).build(); + let fb2 = factory.flashblock_after(&fb1).build(); + + let result = FlashBlockCompleteSequence::new(vec![fb0, fb1, fb2], None); + assert!(result.is_ok()); + + let complete = result.unwrap(); + assert_eq!(complete.count(), 3); + assert_eq!(complete.last().index, 2); + } + + #[test] + fn test_all_transactions_aggregates_correctly() { + let factory = TestFlashBlockFactory::new(); + + let fb0 = factory + .flashblock_at(0) + .transactions(vec![Bytes::from_static(&[1, 2, 3]), Bytes::from_static(&[4, 5, 6])]) + .build(); + + let fb1 = factory + .flashblock_after(&fb0) + .transactions(vec![Bytes::from_static(&[7, 8, 9])]) + .build(); + + let complete = FlashBlockCompleteSequence::new(vec![fb0, fb1], None).unwrap(); + let all_txs = complete.all_transactions(); + + assert_eq!(all_txs.len(), 3); + assert_eq!(all_txs[0], Bytes::from_static(&[1, 2, 3])); + assert_eq!(all_txs[1], Bytes::from_static(&[4, 5, 6])); + assert_eq!(all_txs[2], Bytes::from_static(&[7, 8, 9])); + } + + #[test] + fn test_payload_base_returns_first_block_base() { + let factory = TestFlashBlockFactory::new(); + + let fb0 = factory.flashblock_at(0).build(); + let fb1 = factory.flashblock_after(&fb0).build(); + + let complete = FlashBlockCompleteSequence::new(vec![fb0.clone(), fb1], None).unwrap(); + + assert_eq!(complete.payload_base().block_number, fb0.base.unwrap().block_number); + } + + #[test] + fn test_execution_outcome_mutation() { + let factory = TestFlashBlockFactory::new(); + let fb0 = factory.flashblock_at(0).build(); + + let mut complete = FlashBlockCompleteSequence::new(vec![fb0], None).unwrap(); + assert!(complete.execution_outcome().is_none()); + + let outcome = + SequenceExecutionOutcome { block_hash: B256::random(), state_root: B256::random() }; + complete.set_execution_outcome(Some(outcome)); + + assert_eq!(complete.execution_outcome(), Some(outcome)); + } + + #[test] + fn test_deref_provides_vec_access() { + let factory = TestFlashBlockFactory::new(); + + let fb0 = factory.flashblock_at(0).build(); + let fb1 = factory.flashblock_after(&fb0).build(); + + let complete = FlashBlockCompleteSequence::new(vec![fb0, fb1], None).unwrap(); + + // Use deref to access Vec methods + assert_eq!(complete.len(), 2); + assert!(!complete.is_empty()); + } } - #[test] - fn test_sequence_sends_flashblocks_to_subscribers() { - let mut sequence = FlashBlockPendingSequence::>::new(); - let mut subscriber = sequence.subscribe_block_sequence(); - - for idx in 0..10 { - sequence - .insert(OpFlashblockPayload { - payload_id: Default::default(), - index: idx, - base: Some(OpFlashblockPayloadBase::default()), - diff: Default::default(), - metadata: Default::default(), - }) - .unwrap(); + mod sequence_conversion { + use super::*; + + #[test] + fn test_try_from_pending_to_complete_valid() { + let mut pending = FlashBlockPendingSequence::new(); + let factory = TestFlashBlockFactory::new(); + + let fb0 = factory.flashblock_at(0).build(); + pending.insert(fb0); + + let complete: Result = pending.try_into(); + assert!(complete.is_ok()); + assert_eq!(complete.unwrap().count(), 1); + } + + #[test] + fn test_try_from_pending_to_complete_empty_fails() { + let pending = FlashBlockPendingSequence::new(); + + let complete: Result = pending.try_into(); + assert!(complete.is_err()); + } + + #[test] + fn test_try_from_preserves_execution_outcome() { + let mut pending = FlashBlockPendingSequence::new(); + let factory = TestFlashBlockFactory::new(); + + let fb0 = factory.flashblock_at(0).build(); + pending.insert(fb0); + + let outcome = + SequenceExecutionOutcome { block_hash: B256::random(), state_root: B256::random() }; + pending.set_execution_outcome(Some(outcome)); + + let complete: FlashBlockCompleteSequence = pending.try_into().unwrap(); + assert_eq!(complete.execution_outcome(), Some(outcome)); } + } - assert_eq!(sequence.count(), 10); + mod pending_sequence_helpers { + use super::*; - // Then we don't receive anything until we insert a new flashblock - let no_flashblock = subscriber.try_recv(); - assert!(no_flashblock.is_err()); + #[test] + fn test_last_flashblock_returns_highest_index() { + let mut sequence = FlashBlockPendingSequence::new(); + let factory = TestFlashBlockFactory::new(); - // Let's insert a new flashblock with index 0 - sequence - .insert(OpFlashblockPayload { - payload_id: Default::default(), - index: 0, - base: Some(OpFlashblockPayloadBase::default()), - diff: Default::default(), - metadata: Default::default(), - }) - .unwrap(); + let fb0 = factory.flashblock_at(0).build(); + sequence.insert(fb0.clone()); - let flashblocks = subscriber.try_recv().unwrap(); - assert_eq!(flashblocks.count(), 10); + let fb1 = factory.flashblock_after(&fb0).build(); + sequence.insert(fb1); + + let last = sequence.last_flashblock().unwrap(); + assert_eq!(last.index, 1); + } - for (idx, block) in flashblocks.iter().enumerate() { - assert_eq!(block.index, idx as u64); + #[test] + fn test_subscribe_block_sequence_channel() { + let sequence = FlashBlockPendingSequence::new(); + let mut rx = sequence.subscribe_block_sequence(); + + // Spawn a task that sends a complete sequence + let tx = sequence.block_sequence_broadcaster().clone(); + std::thread::spawn(move || { + let factory = TestFlashBlockFactory::new(); + let fb0 = factory.flashblock_at(0).build(); + let complete = FlashBlockCompleteSequence::new(vec![fb0], None).unwrap(); + let _ = tx.send(complete); + }); + + // Should receive the broadcast + let received = rx.blocking_recv(); + assert!(received.is_ok()); + assert_eq!(received.unwrap().count(), 1); } } } diff --git a/crates/optimism/flashblocks/src/service.rs b/crates/optimism/flashblocks/src/service.rs index 82a62c9e015..4eed74683f7 100644 --- a/crates/optimism/flashblocks/src/service.rs +++ b/crates/optimism/flashblocks/src/service.rs @@ -1,37 +1,20 @@ use crate::{ - sequence::{FlashBlockPendingSequence, SequenceExecutionOutcome}, - worker::{BuildArgs, FlashBlockBuilder}, - FlashBlock, FlashBlockCompleteSequence, FlashBlockCompleteSequenceRx, InProgressFlashBlockRx, - PendingFlashBlock, + cache::SequenceManager, worker::FlashBlockBuilder, FlashBlock, FlashBlockCompleteSequence, + FlashBlockCompleteSequenceRx, InProgressFlashBlockRx, PendingFlashBlock, }; -use alloy_eips::eip2718::WithEncoded; use alloy_primitives::B256; use futures_util::{FutureExt, Stream, StreamExt}; use metrics::{Gauge, Histogram}; use op_alloy_rpc_types_engine::OpFlashblockPayloadBase; -use reth_chain_state::{CanonStateNotification, CanonStateNotifications, CanonStateSubscriptions}; use reth_evm::ConfigureEvm; use reth_metrics::Metrics; -use reth_primitives_traits::{ - AlloyBlockHeader, BlockTy, HeaderTy, NodePrimitives, ReceiptTy, Recovered, -}; +use reth_primitives_traits::{AlloyBlockHeader, BlockTy, HeaderTy, NodePrimitives, ReceiptTy}; use reth_revm::cached::CachedReads; use reth_storage_api::{BlockReaderIdExt, StateProviderFactory}; use reth_tasks::TaskExecutor; -use std::{ - pin::Pin, - sync::Arc, - task::{ready, Context, Poll}, - time::Instant, -}; -use tokio::{ - pin, - sync::{oneshot, watch}, -}; -use tracing::{debug, trace, warn}; - -/// 200 ms flashblock time. -pub(crate) const FLASHBLOCK_BLOCK_TIME: u64 = 200; +use std::{sync::Arc, time::Instant}; +use tokio::sync::{oneshot, watch}; +use tracing::*; /// The `FlashBlockService` maintains an in-memory [`PendingFlashBlock`] built out of a sequence of /// [`FlashBlock`]s. @@ -42,27 +25,24 @@ pub struct FlashBlockService< EvmConfig: ConfigureEvm + Unpin>, Provider, > { - rx: S, - current: Option>, - blocks: FlashBlockPendingSequence, + /// Incoming flashblock stream. + incoming_flashblock_rx: S, + /// Signals when a block build is in progress. + in_progress_tx: watch::Sender>, /// Broadcast channel to forward received flashblocks from the subscription. received_flashblocks_tx: tokio::sync::broadcast::Sender>, - rebuild: bool, + + /// Executes flashblock sequences to build pending blocks. builder: FlashBlockBuilder, - canon_receiver: CanonStateNotifications, + /// Task executor for spawning block build jobs. spawner: TaskExecutor, + /// Currently running block build job with start time and result receiver. job: Option>, - /// Cached state reads for the current block. - /// Current `PendingFlashBlock` is built out of a sequence of `FlashBlocks`, and executed again - /// when fb received on top of the same block. Avoid redundant I/O across multiple - /// executions within the same block. - cached_state: Option<(B256, CachedReads)>, - /// Signals when a block build is in progress - in_progress_tx: watch::Sender>, + /// Manages flashblock sequences with caching and intelligent build selection. + sequences: SequenceManager, + /// `FlashBlock` service's metrics metrics: FlashBlockServiceMetrics, - /// Enable state root calculation - compute_state_root: bool, } impl FlashBlockService @@ -73,7 +53,6 @@ where + Clone + 'static, Provider: StateProviderFactory - + CanonStateSubscriptions + BlockReaderIdExt< Header = HeaderTy, Block = BlockTy, @@ -84,32 +63,27 @@ where + 'static, { /// Constructs a new `FlashBlockService` that receives [`FlashBlock`]s from `rx` stream. - pub fn new(rx: S, evm_config: EvmConfig, provider: Provider, spawner: TaskExecutor) -> Self { + pub fn new( + incoming_flashblock_rx: S, + evm_config: EvmConfig, + provider: Provider, + spawner: TaskExecutor, + compute_state_root: bool, + ) -> Self { let (in_progress_tx, _) = watch::channel(None); let (received_flashblocks_tx, _) = tokio::sync::broadcast::channel(128); Self { - rx, - current: None, - blocks: FlashBlockPendingSequence::new(), + incoming_flashblock_rx, + in_progress_tx, received_flashblocks_tx, - canon_receiver: provider.subscribe_to_canonical_state(), builder: FlashBlockBuilder::new(evm_config, provider), - rebuild: false, spawner, job: None, - cached_state: None, - in_progress_tx, + sequences: SequenceManager::new(compute_state_root), metrics: FlashBlockServiceMetrics::default(), - compute_state_root: false, } } - /// Enable state root calculation from flashblock - pub const fn compute_state_root(mut self, enable_state_root: bool) -> Self { - self.compute_state_root = enable_state_root; - self - } - /// Returns the sender half to the received flashblocks. pub const fn flashblocks_broadcaster( &self, @@ -121,12 +95,12 @@ where pub const fn block_sequence_broadcaster( &self, ) -> &tokio::sync::broadcast::Sender { - self.blocks.block_sequence_broadcaster() + self.sequences.block_sequence_broadcaster() } /// Returns a subscriber to the flashblock sequence. pub fn subscribe_block_sequence(&self) -> FlashBlockCompleteSequenceRx { - self.blocks.subscribe_block_sequence() + self.sequences.subscribe_block_sequence() } /// Returns a receiver that signals when a flashblock is being built. @@ -134,282 +108,129 @@ where self.in_progress_tx.subscribe() } - /// Drives the services and sends new blocks to the receiver + /// Drives the service and sends new blocks to the receiver. /// - /// Note: this should be spawned - pub async fn run(mut self, tx: tokio::sync::watch::Sender>>) { - while let Some(block) = self.next().await { - if let Ok(block) = block.inspect_err(|e| tracing::error!(target: "flashblocks", "{e}")) - { - let _ = - tx.send(block).inspect_err(|e| tracing::error!(target: "flashblocks", "{e}")); - } - } - - warn!(target: "flashblocks", "Flashblock service has stopped"); - } - - /// Notifies all subscribers about the received flashblock - fn notify_received_flashblock(&self, flashblock: &FlashBlock) { - if self.received_flashblocks_tx.receiver_count() > 0 { - let _ = self.received_flashblocks_tx.send(Arc::new(flashblock.clone())); - } - } - - /// Returns the [`BuildArgs`] made purely out of [`FlashBlock`]s that were received earlier. + /// This loop: + /// 1. Checks if any build job has completed and processes results + /// 2. Receives and batches all immediately available flashblocks + /// 3. Attempts to build a block from the complete sequence /// - /// Returns `None` if the flashblock have no `base` or the base is not a child block of latest. - fn build_args( - &mut self, - ) -> Option< - BuildArgs< - impl IntoIterator>> - + use, - >, - > { - let Some(base) = self.blocks.payload_base() else { - trace!( - target: "flashblocks", - flashblock_number = ?self.blocks.block_number(), - count = %self.blocks.count(), - "Missing flashblock payload base" - ); - - return None - }; - - let Some(latest) = self.builder.provider().latest_header().ok().flatten() else { - trace!(target: "flashblocks", "No latest header found"); - return None - }; - - // attempt an initial consecutive check - if latest.hash() != base.parent_hash { - trace!(target: "flashblocks", flashblock_parent=?base.parent_hash, flashblock_number=base.block_number, local_latest=?latest.num_hash(), "Skipping non consecutive build attempt"); - return None - } - - let Some(last_flashblock) = self.blocks.last_flashblock() else { - trace!(target: "flashblocks", flashblock_number = ?self.blocks.block_number(), count = %self.blocks.count(), "Missing last flashblock"); - return None - }; - - // Auto-detect when to compute state root: only if the builder didn't provide it (sent - // B256::ZERO) and we're near the expected final flashblock index. - // - // Background: Each block period receives multiple flashblocks at regular intervals. - // The sequencer sends an initial "base" flashblock at index 0 when a new block starts, - // then subsequent flashblocks are produced every FLASHBLOCK_BLOCK_TIME intervals (200ms). - // - // Examples with different block times: - // - Base (2s blocks): expect 2000ms / 200ms = 10 intervals → Flashblocks: index 0 (base) - // + indices 1-10 = potentially 11 total - // - // - Unichain (1s blocks): expect 1000ms / 200ms = 5 intervals → Flashblocks: index 0 (base) - // + indices 1-5 = potentially 6 total - // - // Why compute at N-1 instead of N: - // 1. Timing variance in flashblock producing time may mean only N flashblocks were produced - // instead of N+1 (missing the final one). Computing at N-1 ensures we get the state root - // for most common cases. - // - // 2. The +1 case (index 0 base + N intervals): If all N+1 flashblocks do arrive, we'll - // still calculate state root for flashblock N, which sacrifices a little performance but - // still ensures correctness for common cases. - // - // Note: Pathological cases may result in fewer flashblocks than expected (e.g., builder - // downtime, flashblock execution exceeding timing budget). When this occurs, we won't - // compute the state root, causing FlashblockConsensusClient to lack precomputed state for - // engine_newPayload. This is safe: we still have op-node as backstop to maintain - // chain progression. - let block_time_ms = (base.timestamp - latest.timestamp()) * 1000; - let expected_final_flashblock = block_time_ms / FLASHBLOCK_BLOCK_TIME; - let compute_state_root = self.compute_state_root && - last_flashblock.diff.state_root.is_zero() && - self.blocks.index() >= Some(expected_final_flashblock.saturating_sub(1)); - - Some(BuildArgs { - base, - transactions: self.blocks.ready_transactions().collect::>(), - cached_state: self.cached_state.take(), - last_flashblock_index: last_flashblock.index, - last_flashblock_hash: last_flashblock.diff.block_hash, - compute_state_root, - }) - } - - /// Takes out `current` [`PendingFlashBlock`] if `state` is not preceding it. - fn on_new_tip(&mut self, state: CanonStateNotification) -> Option> { - let tip = state.tip_checked()?; - let tip_hash = tip.hash(); - let current = self.current.take_if(|current| current.parent_hash() != tip_hash); - - // Prefill the cache with state from the new canonical tip, similar to payload/basic - let mut cached = CachedReads::default(); - let committed = state.committed(); - let new_execution_outcome = committed.execution_outcome(); - for (addr, acc) in new_execution_outcome.bundle_accounts_iter() { - if let Some(info) = acc.info.clone() { - // Pre-cache existing accounts and their storage (only changed accounts/storage) - let storage = - acc.storage.iter().map(|(key, slot)| (*key, slot.present_value)).collect(); - cached.insert_account(addr, info, storage); - } - } - self.cached_state = Some((tip_hash, cached)); - - current - } -} - -impl Stream for FlashBlockService -where - N: NodePrimitives, - S: Stream> + Unpin + 'static, - EvmConfig: ConfigureEvm + Unpin> - + Clone - + 'static, - Provider: StateProviderFactory - + CanonStateSubscriptions - + BlockReaderIdExt< - Header = HeaderTy, - Block = BlockTy, - Transaction = N::SignedTx, - Receipt = ReceiptTy, - > + Unpin - + Clone - + 'static, -{ - type Item = eyre::Result>>; - - fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let this = self.get_mut(); - + /// Note: this should be spawned + pub async fn run(mut self, tx: watch::Sender>>) { loop { - // drive pending build job to completion - let result = match this.job.as_mut() { - Some((now, rx)) => { - let result = ready!(rx.poll_unpin(cx)); - result.ok().map(|res| (*now, res)) - } - None => None, - }; - // reset job - this.job.take(); - // No build in progress - let _ = this.in_progress_tx.send(None); - - if let Some((now, result)) = result { - match result { - Ok(Some((new_pending, cached_reads))) => { - // update execution outcome of the current sequence - let execution_outcome = - new_pending.computed_state_root().map(|state_root| { - SequenceExecutionOutcome { - block_hash: new_pending.block().hash(), - state_root, - } - }); - this.blocks.set_execution_outcome(execution_outcome); - - // built a new pending block - this.current = Some(new_pending.clone()); - // cache reads - this.cached_state = Some((new_pending.parent_hash(), cached_reads)); - this.rebuild = false; + tokio::select! { + // Event 1: job exists, listen to job results + Some(result) = async { + match self.job.as_mut() { + Some((_, rx)) => rx.await.ok(), + None => std::future::pending().await, + } + } => { + let (start_time, _) = self.job.take().unwrap(); + let _ = self.in_progress_tx.send(None); - let elapsed = now.elapsed(); - this.metrics.execution_duration.record(elapsed.as_secs_f64()); + match result { + Ok(Some((pending, cached_reads))) => { + let parent_hash = pending.parent_hash(); + self.sequences + .on_build_complete(parent_hash, Some((pending.clone(), cached_reads))); - trace!( - target: "flashblocks", - parent_hash = %new_pending.block().parent_hash(), - block_number = new_pending.block().number(), - flash_blocks = this.blocks.count(), - ?elapsed, - "Built new block with flashblocks" - ); + let elapsed = start_time.elapsed(); + self.metrics.execution_duration.record(elapsed.as_secs_f64()); - return Poll::Ready(Some(Ok(Some(new_pending)))); - } - Ok(None) => { - // nothing to do because tracked flashblock doesn't attach to latest - } - Err(err) => { - // we can ignore this error - debug!(target: "flashblocks", %err, "failed to execute flashblock"); + let _ = tx.send(Some(pending)); + } + Ok(None) => { + trace!(target: "flashblocks", "Build job returned None"); + } + Err(err) => { + warn!(target: "flashblocks", %err, "Build job failed"); + } } } - } - // consume new flashblocks while they're ready - while let Poll::Ready(Some(result)) = this.rx.poll_next_unpin(cx) { - match result { - Ok(flashblock) => { - this.notify_received_flashblock(&flashblock); - if flashblock.index == 0 { - this.metrics.last_flashblock_length.record(this.blocks.count() as f64); - } - match this.blocks.insert(flashblock) { - Ok(_) => this.rebuild = true, - Err(err) => { - debug!(target: "flashblocks", %err, "Failed to prepare flashblock") + // Event 2: New flashblock arrives (batch process all ready flashblocks) + result = self.incoming_flashblock_rx.next() => { + match result { + Some(Ok(flashblock)) => { + // Process first flashblock + self.process_flashblock(flashblock); + + // Batch process all other immediately available flashblocks + while let Some(result) = self.incoming_flashblock_rx.next().now_or_never().flatten() { + match result { + Ok(fb) => self.process_flashblock(fb), + Err(err) => warn!(target: "flashblocks", %err, "Error receiving flashblock"), + } } + + self.try_start_build_job(); + } + Some(Err(err)) => { + warn!(target: "flashblocks", %err, "Error receiving flashblock"); + } + None => { + warn!(target: "flashblocks", "Flashblock stream ended"); + break; } } - Err(err) => return Poll::Ready(Some(Err(err))), } } + } + } - // update on new head block - if let Poll::Ready(Ok(state)) = { - let fut = this.canon_receiver.recv(); - pin!(fut); - fut.poll_unpin(cx) - } && let Some(current) = this.on_new_tip(state) - { - trace!( - target: "flashblocks", - parent_hash = %current.block().parent_hash(), - block_number = current.block().number(), - "Clearing current flashblock on new canonical block" - ); + /// Processes a single flashblock: notifies subscribers, records metrics, and inserts into + /// sequence. + fn process_flashblock(&mut self, flashblock: FlashBlock) { + self.notify_received_flashblock(&flashblock); - return Poll::Ready(Some(Ok(None))) - } + if flashblock.index == 0 { + self.metrics.last_flashblock_length.record(self.sequences.pending().count() as f64); + } - if !this.rebuild && this.current.is_some() { - return Poll::Pending - } + if let Err(err) = self.sequences.insert_flashblock(flashblock) { + trace!(target: "flashblocks", %err, "Failed to insert flashblock"); + } + } - // try to build a block on top of latest - if let Some(args) = this.build_args() { - let now = Instant::now(); + /// Notifies all subscribers about the received flashblock. + fn notify_received_flashblock(&self, flashblock: &FlashBlock) { + if self.received_flashblocks_tx.receiver_count() > 0 { + let _ = self.received_flashblocks_tx.send(Arc::new(flashblock.clone())); + } + } - let fb_info = FlashBlockBuildInfo { - parent_hash: args.base.parent_hash, - index: args.last_flashblock_index, - block_number: args.base.block_number, - }; - // Record current block and index metrics - this.metrics.current_block_height.set(fb_info.block_number as f64); - this.metrics.current_index.set(fb_info.index as f64); - // Signal that a flashblock build has started with build metadata - let _ = this.in_progress_tx.send(Some(fb_info)); - let (tx, rx) = oneshot::channel(); - let builder = this.builder.clone(); + /// Attempts to build a block if no job is currently running and a buildable sequence exists. + fn try_start_build_job(&mut self) { + if self.job.is_some() { + return; // Already building + } - this.spawner.spawn_blocking(async move { - let _ = tx.send(builder.execute(args)); - }); - this.job.replace((now, rx)); + let Some(latest) = self.builder.provider().latest_header().ok().flatten() else { + return; + }; - // continue and poll the spawned job - continue - } + let Some(args) = self.sequences.next_buildable_args(latest.hash(), latest.timestamp()) + else { + return; // Nothing buildable + }; - return Poll::Pending - } + // Spawn build job + let fb_info = FlashBlockBuildInfo { + parent_hash: args.base.parent_hash, + index: args.last_flashblock_index, + block_number: args.base.block_number, + }; + self.metrics.current_block_height.set(fb_info.block_number as f64); + self.metrics.current_index.set(fb_info.index as f64); + let _ = self.in_progress_tx.send(Some(fb_info)); + + let (tx, rx) = oneshot::channel(); + let builder = self.builder.clone(); + self.spawner.spawn_blocking(Box::pin(async move { + let _ = tx.send(builder.execute(args)); + })); + self.job = Some((Instant::now(), rx)); } } diff --git a/crates/optimism/flashblocks/src/test_utils.rs b/crates/optimism/flashblocks/src/test_utils.rs new file mode 100644 index 00000000000..1c2da1f7c80 --- /dev/null +++ b/crates/optimism/flashblocks/src/test_utils.rs @@ -0,0 +1,340 @@ +//! Test utilities for flashblocks. +//! +//! Provides a factory for creating test flashblocks with automatic timestamp management. +//! +//! # Examples +//! +//! ## Simple: Create a flashblock sequence for the same block +//! +//! ```ignore +//! let factory = FlashBlockTestFactory::new(2); // 2 second block time +//! let fb0 = factory.flashblock_at(0).build(); +//! let fb1 = factory.flashblock_after(&fb0).build(); +//! let fb2 = factory.flashblock_after(&fb1).build(); +//! ``` +//! +//! ## Create flashblocks with transactions +//! +//! ```ignore +//! let factory = FlashBlockTestFactory::new(2); +//! let fb0 = factory.flashblock_at(0).build(); +//! let txs = vec![Bytes::from_static(&[1, 2, 3])]; +//! let fb1 = factory.flashblock_after(&fb0).transactions(txs).build(); +//! ``` +//! +//! ## Test across multiple blocks (timestamps auto-increment) +//! +//! ```ignore +//! let factory = FlashBlockTestFactory::new(2); // 2 second blocks +//! +//! // Block 100 at timestamp 1000000 +//! let fb0 = factory.flashblock_at(0).build(); +//! let fb1 = factory.flashblock_after(&fb0).build(); +//! +//! // Block 101 at timestamp 1000002 (auto-incremented by block_time) +//! let fb2 = factory.flashblock_for_next_block(&fb1).build(); +//! let fb3 = factory.flashblock_after(&fb2).build(); +//! ``` +//! +//! ## Full control with builder +//! +//! ```ignore +//! let factory = FlashBlockTestFactory::new(1); +//! let fb = factory.custom() +//! .block_number(100) +//! .parent_hash(specific_hash) +//! .state_root(computed_root) +//! .transactions(txs) +//! .build(); +//! ``` + +use crate::FlashBlock; +use alloy_primitives::{Address, Bloom, Bytes, B256, U256}; +use alloy_rpc_types_engine::PayloadId; +use op_alloy_rpc_types_engine::{ + OpFlashblockPayloadBase, OpFlashblockPayloadDelta, OpFlashblockPayloadMetadata, +}; + +/// Factory for creating test flashblocks with automatic timestamp management. +/// +/// Tracks `block_time` to automatically increment timestamps when creating new blocks. +/// Returns builders that can be further customized before calling `build()`. +/// +/// # Examples +/// +/// ```ignore +/// let factory = TestFlashBlockFactory::new(2); // 2 second block time +/// let fb0 = factory.flashblock_at(0).build(); +/// let fb1 = factory.flashblock_after(&fb0).build(); +/// let fb2 = factory.flashblock_for_next_block(&fb1).build(); // timestamp auto-increments +/// ``` +#[derive(Debug)] +pub(crate) struct TestFlashBlockFactory { + /// Block time in seconds (used to auto-increment timestamps) + block_time: u64, + /// Starting timestamp for the first block + base_timestamp: u64, + /// Current block number being tracked + current_block_number: u64, +} + +impl TestFlashBlockFactory { + /// Creates a new builder with the specified block time in seconds. + /// + /// # Arguments + /// + /// * `block_time` - Time between blocks in seconds (e.g., 2 for 2-second blocks) + /// + /// # Examples + /// + /// ```ignore + /// let factory = TestFlashBlockFactory::new(2); // 2 second blocks + /// let factory_fast = TestFlashBlockFactory::new(1); // 1 second blocks + /// ``` + pub(crate) fn new() -> Self { + Self { block_time: 2, base_timestamp: 1_000_000, current_block_number: 100 } + } + + pub(crate) fn with_block_time(mut self, block_time: u64) -> Self { + self.block_time = block_time; + self + } + + /// Creates a builder for a flashblock at the specified index (within the current block). + /// + /// Returns a builder with index set, allowing further customization before building. + /// + /// # Examples + /// + /// ```ignore + /// let factory = TestFlashBlockFactory::new(2); + /// let fb0 = factory.flashblock_at(0).build(); // Simple usage + /// let fb1 = factory.flashblock_at(1).state_root(specific_root).build(); // Customize + /// ``` + pub(crate) fn flashblock_at(&self, index: u64) -> TestFlashBlockBuilder { + self.builder().index(index).block_number(self.current_block_number) + } + + /// Creates a builder for a flashblock following the previous one in the same sequence. + /// + /// Automatically increments the index and maintains `block_number` and `payload_id`. + /// Returns a builder allowing further customization. + /// + /// # Examples + /// + /// ```ignore + /// let factory = TestFlashBlockFactory::new(2); + /// let fb0 = factory.flashblock_at(0).build(); + /// let fb1 = factory.flashblock_after(&fb0).build(); // Simple + /// let fb2 = factory.flashblock_after(&fb1).transactions(txs).build(); // With txs + /// ``` + pub(crate) fn flashblock_after(&self, previous: &FlashBlock) -> TestFlashBlockBuilder { + let parent_hash = + previous.base.as_ref().map(|b| b.parent_hash).unwrap_or(previous.diff.block_hash); + + self.builder() + .index(previous.index + 1) + .block_number(previous.metadata.block_number) + .payload_id(previous.payload_id) + .parent_hash(parent_hash) + .timestamp(previous.base.as_ref().map(|b| b.timestamp).unwrap_or(self.base_timestamp)) + } + + /// Creates a builder for a flashblock for the next block, starting a new sequence at index 0. + /// + /// Increments block number, uses previous `block_hash` as `parent_hash`, generates new + /// `payload_id`, and automatically increments the timestamp by `block_time`. + /// Returns a builder allowing further customization. + /// + /// # Examples + /// + /// ```ignore + /// let factory = TestFlashBlockFactory::new(2); // 2 second blocks + /// let fb0 = factory.flashblock_at(0).build(); // Block 100, timestamp 1000000 + /// let fb1 = factory.flashblock_for_next_block(&fb0).build(); // Block 101, timestamp 1000002 + /// let fb2 = factory.flashblock_for_next_block(&fb1).transactions(txs).build(); // Customize + /// ``` + pub(crate) fn flashblock_for_next_block(&self, previous: &FlashBlock) -> TestFlashBlockBuilder { + let prev_timestamp = + previous.base.as_ref().map(|b| b.timestamp).unwrap_or(self.base_timestamp); + + self.builder() + .index(0) + .block_number(previous.metadata.block_number + 1) + .payload_id(PayloadId::new(B256::random().0[0..8].try_into().unwrap())) + .parent_hash(previous.diff.block_hash) + .timestamp(prev_timestamp + self.block_time) + } + + /// Returns a custom builder for full control over flashblock creation. + /// + /// Use this when the convenience methods don't provide enough control. + /// + /// # Examples + /// + /// ```ignore + /// let factory = TestFlashBlockFactory::new(2); + /// let fb = factory.builder() + /// .index(5) + /// .block_number(200) + /// .parent_hash(specific_hash) + /// .state_root(computed_root) + /// .build(); + /// ``` + pub(crate) fn builder(&self) -> TestFlashBlockBuilder { + TestFlashBlockBuilder { + index: 0, + block_number: self.current_block_number, + payload_id: PayloadId::new([1u8; 8]), + parent_hash: B256::random(), + timestamp: self.base_timestamp, + base: None, + block_hash: B256::random(), + state_root: B256::ZERO, + receipts_root: B256::ZERO, + logs_bloom: Bloom::default(), + gas_used: 0, + transactions: vec![], + withdrawals: vec![], + withdrawals_root: B256::ZERO, + blob_gas_used: None, + } + } +} + +/// Custom builder for creating test flashblocks with full control. +/// +/// Created via [`TestFlashBlockFactory::builder()`]. +#[derive(Debug)] +pub(crate) struct TestFlashBlockBuilder { + index: u64, + block_number: u64, + payload_id: PayloadId, + parent_hash: B256, + timestamp: u64, + base: Option, + block_hash: B256, + state_root: B256, + receipts_root: B256, + logs_bloom: Bloom, + gas_used: u64, + transactions: Vec, + withdrawals: Vec, + withdrawals_root: B256, + blob_gas_used: Option, +} + +impl TestFlashBlockBuilder { + /// Sets the flashblock index. + pub(crate) fn index(mut self, index: u64) -> Self { + self.index = index; + self + } + + /// Sets the block number. + pub(crate) fn block_number(mut self, block_number: u64) -> Self { + self.block_number = block_number; + self + } + + /// Sets the payload ID. + pub(crate) fn payload_id(mut self, payload_id: PayloadId) -> Self { + self.payload_id = payload_id; + self + } + + /// Sets the parent hash. + pub(crate) fn parent_hash(mut self, parent_hash: B256) -> Self { + self.parent_hash = parent_hash; + self + } + + /// Sets the timestamp. + pub(crate) fn timestamp(mut self, timestamp: u64) -> Self { + self.timestamp = timestamp; + self + } + + /// Sets the base payload. Automatically created for index 0 if not set. + #[allow(dead_code)] + pub(crate) fn base(mut self, base: OpFlashblockPayloadBase) -> Self { + self.base = Some(base); + self + } + + /// Sets the block hash in the diff. + #[allow(dead_code)] + pub(crate) fn block_hash(mut self, block_hash: B256) -> Self { + self.block_hash = block_hash; + self + } + + /// Sets the state root in the diff. + #[allow(dead_code)] + pub(crate) fn state_root(mut self, state_root: B256) -> Self { + self.state_root = state_root; + self + } + + /// Sets the receipts root in the diff. + #[allow(dead_code)] + pub(crate) fn receipts_root(mut self, receipts_root: B256) -> Self { + self.receipts_root = receipts_root; + self + } + + /// Sets the transactions in the diff. + pub(crate) fn transactions(mut self, transactions: Vec) -> Self { + self.transactions = transactions; + self + } + + /// Sets the gas used in the diff. + #[allow(dead_code)] + pub(crate) fn gas_used(mut self, gas_used: u64) -> Self { + self.gas_used = gas_used; + self + } + + /// Builds the flashblock. + /// + /// If index is 0 and no base was explicitly set, creates a default base. + pub(crate) fn build(mut self) -> FlashBlock { + // Auto-create base for index 0 if not set + if self.index == 0 && self.base.is_none() { + self.base = Some(OpFlashblockPayloadBase { + parent_hash: self.parent_hash, + parent_beacon_block_root: B256::random(), + fee_recipient: Address::default(), + prev_randao: B256::random(), + block_number: self.block_number, + gas_limit: 30_000_000, + timestamp: self.timestamp, + extra_data: Default::default(), + base_fee_per_gas: U256::from(1_000_000_000u64), + }); + } + + FlashBlock { + index: self.index, + payload_id: self.payload_id, + base: self.base, + diff: OpFlashblockPayloadDelta { + block_hash: self.block_hash, + state_root: self.state_root, + receipts_root: self.receipts_root, + logs_bloom: self.logs_bloom, + gas_used: self.gas_used, + transactions: self.transactions, + withdrawals: self.withdrawals, + withdrawals_root: self.withdrawals_root, + blob_gas_used: self.blob_gas_used, + }, + metadata: OpFlashblockPayloadMetadata { + block_number: self.block_number, + receipts: Default::default(), + new_account_balances: Default::default(), + }, + } + } +} diff --git a/crates/optimism/flashblocks/src/worker.rs b/crates/optimism/flashblocks/src/worker.rs index 16ae67a09ab..f929bdb47fd 100644 --- a/crates/optimism/flashblocks/src/worker.rs +++ b/crates/optimism/flashblocks/src/worker.rs @@ -2,7 +2,7 @@ use crate::PendingFlashBlock; use alloy_eips::{eip2718::WithEncoded, BlockNumberOrTag}; use alloy_primitives::B256; use op_alloy_rpc_types_engine::OpFlashblockPayloadBase; -use reth_chain_state::{CanonStateSubscriptions, ExecutedBlock}; +use reth_chain_state::ExecutedBlock; use reth_errors::RethError; use reth_evm::{ execute::{BlockBuilder, BlockBuilderOutcome}, @@ -52,7 +52,6 @@ where N: NodePrimitives, EvmConfig: ConfigureEvm + Unpin>, Provider: StateProviderFactory - + CanonStateSubscriptions + BlockReaderIdExt< Header = HeaderTy, Block = BlockTy, diff --git a/crates/optimism/rpc/src/eth/mod.rs b/crates/optimism/rpc/src/eth/mod.rs index e5313c0cb18..8e101b9203e 100644 --- a/crates/optimism/rpc/src/eth/mod.rs +++ b/crates/optimism/rpc/src/eth/mod.rs @@ -528,8 +528,9 @@ where ctx.components.evm_config().clone(), ctx.components.provider().clone(), ctx.components.task_executor().clone(), - ) - .compute_state_root(flashblock_consensus); // enable state root calculation if flashblock_consensus if enabled. + // enable state root calculation if flashblock_consensus is enabled. + flashblock_consensus, + ); let flashblocks_sequence = service.block_sequence_broadcaster().clone(); let received_flashblocks = service.flashblocks_broadcaster().clone(); diff --git a/examples/custom-node/src/engine.rs b/examples/custom-node/src/engine.rs index 10b54a9bfb3..53a3dca84c6 100644 --- a/examples/custom-node/src/engine.rs +++ b/examples/custom-node/src/engine.rs @@ -68,12 +68,14 @@ impl ExecutionPayload for CustomExecutionData { } } -impl From<&reth_optimism_flashblocks::FlashBlockCompleteSequence> for CustomExecutionData { - fn from(sequence: &reth_optimism_flashblocks::FlashBlockCompleteSequence) -> Self { - let inner = OpExecutionData::from(sequence); - // Derive extension from sequence data - using gas_used from last flashblock as an example - let extension = sequence.last().diff.gas_used; - Self { inner, extension } +impl TryFrom<&reth_optimism_flashblocks::FlashBlockCompleteSequence> for CustomExecutionData { + type Error = &'static str; + + fn try_from( + sequence: &reth_optimism_flashblocks::FlashBlockCompleteSequence, + ) -> Result { + let inner = OpExecutionData::try_from(sequence)?; + Ok(Self { inner, extension: sequence.last().diff.gas_used }) } } From fa3c7a9968edb5b9d4814dec7f6e666aaa4494d8 Mon Sep 17 00:00:00 2001 From: Francis Li Date: Tue, 25 Nov 2025 00:31:24 -0800 Subject: [PATCH 6/6] feat(flashblock): Enable eth_getTransactionByHash support for flashblock (#19954) Co-authored-by: Matthias Seitz --- crates/optimism/rpc/src/eth/transaction.rs | 57 ++++++++++++++++-- .../rpc-eth-api/src/helpers/transaction.rs | 60 ++++++++----------- 2 files changed, 79 insertions(+), 38 deletions(-) diff --git a/crates/optimism/rpc/src/eth/transaction.rs b/crates/optimism/rpc/src/eth/transaction.rs index 93487e43cef..f59ce63b957 100644 --- a/crates/optimism/rpc/src/eth/transaction.rs +++ b/crates/optimism/rpc/src/eth/transaction.rs @@ -7,14 +7,16 @@ use futures::StreamExt; use op_alloy_consensus::{transaction::OpTransactionInfo, OpTransaction}; use reth_chain_state::CanonStateSubscriptions; use reth_optimism_primitives::DepositReceipt; -use reth_primitives_traits::{BlockBody, SignedTransaction}; +use reth_primitives_traits::{ + BlockBody, Recovered, SignedTransaction, SignerRecoverable, WithEncoded, +}; use reth_rpc_eth_api::{ - helpers::{spec::SignersForRpc, EthTransactions, LoadReceipt, LoadTransaction}, + helpers::{spec::SignersForRpc, EthTransactions, LoadReceipt, LoadTransaction, SpawnBlocking}, try_into_op_tx_info, EthApiTypes as _, FromEthApiError, FromEvmError, RpcConvert, RpcNodeCore, RpcReceipt, TxInfoMapper, }; -use reth_rpc_eth_types::{utils::recover_raw_transaction, EthApiError}; -use reth_storage_api::{errors::ProviderError, ReceiptProvider}; +use reth_rpc_eth_types::{EthApiError, TransactionSource}; +use reth_storage_api::{errors::ProviderError, ProviderTx, ReceiptProvider, TransactionsProvider}; use reth_transaction_pool::{ AddedTransactionOutcome, PoolTransaction, TransactionOrigin, TransactionPool, }; @@ -196,6 +198,53 @@ where OpEthApiError: FromEvmError, Rpc: RpcConvert, { + async fn transaction_by_hash( + &self, + hash: B256, + ) -> Result>>, Self::Error> { + // 1. Try to find the transaction on disk (historical blocks) + if let Some((tx, meta)) = self + .spawn_blocking_io(move |this| { + this.provider() + .transaction_by_hash_with_meta(hash) + .map_err(Self::Error::from_eth_err) + }) + .await? + { + let transaction = tx + .try_into_recovered_unchecked() + .map_err(|_| EthApiError::InvalidTransactionSignature)?; + + return Ok(Some(TransactionSource::Block { + transaction, + index: meta.index, + block_hash: meta.block_hash, + block_number: meta.block_number, + base_fee: meta.base_fee, + })); + } + + // 2. check flashblocks (sequencer preconfirmations) + if let Ok(Some(pending_block)) = self.pending_flashblock().await && + let Some(indexed_tx) = pending_block.block().find_indexed(hash) + { + let meta = indexed_tx.meta(); + return Ok(Some(TransactionSource::Block { + transaction: indexed_tx.recovered_tx().cloned(), + index: meta.index, + block_hash: meta.block_hash, + block_number: meta.block_number, + base_fee: meta.base_fee, + })); + } + + // 3. check local pool + if let Some(tx) = self.pool().get(&hash).map(|tx| tx.transaction.clone_into_consensus()) { + return Ok(Some(TransactionSource::Pool(tx))); + } + + Ok(None) + } } impl OpEthApi diff --git a/crates/rpc/rpc-eth-api/src/helpers/transaction.rs b/crates/rpc/rpc-eth-api/src/helpers/transaction.rs index 8a49208cd8c..fa09027d601 100644 --- a/crates/rpc/rpc-eth-api/src/helpers/transaction.rs +++ b/crates/rpc/rpc-eth-api/src/helpers/transaction.rs @@ -597,45 +597,37 @@ pub trait LoadTransaction: SpawnBlocking + FullEthApiTypes + RpcNodeCoreExt { > + Send { async move { // Try to find the transaction on disk - let mut resp = self + if let Some((tx, meta)) = self .spawn_blocking_io(move |this| { - match this - .provider() + this.provider() .transaction_by_hash_with_meta(hash) - .map_err(Self::Error::from_eth_err)? - { - None => Ok(None), - Some((tx, meta)) => { - // Note: we assume this transaction is valid, because it's mined (or - // part of pending block) and already. We don't need to - // check for pre EIP-2 because this transaction could be pre-EIP-2. - let transaction = tx - .try_into_recovered_unchecked() - .map_err(|_| EthApiError::InvalidTransactionSignature)?; - - let tx = TransactionSource::Block { - transaction, - index: meta.index, - block_hash: meta.block_hash, - block_number: meta.block_number, - base_fee: meta.base_fee, - }; - Ok(Some(tx)) - } - } + .map_err(Self::Error::from_eth_err) }) - .await?; - - if resp.is_none() { - // tx not found on disk, check pool - if let Some(tx) = - self.pool().get(&hash).map(|tx| tx.transaction.clone().into_consensus()) - { - resp = Some(TransactionSource::Pool(tx.into())); - } + .await? + { + // Note: we assume this transaction is valid, because it's mined (or + // part of pending block) and already. We don't need to + // check for pre EIP-2 because this transaction could be pre-EIP-2. + let transaction = tx + .try_into_recovered_unchecked() + .map_err(|_| EthApiError::InvalidTransactionSignature)?; + + return Ok(Some(TransactionSource::Block { + transaction, + index: meta.index, + block_hash: meta.block_hash, + block_number: meta.block_number, + base_fee: meta.base_fee, + })); } - Ok(resp) + // tx not found on disk, check pool + if let Some(tx) = self.pool().get(&hash).map(|tx| tx.transaction.clone_into_consensus()) + { + return Ok(Some(TransactionSource::Pool(tx.into()))); + } + + Ok(None) } }