diff --git a/Cargo.toml b/Cargo.toml index 14ea5b4..0306141 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,8 +6,122 @@ license = "MIT OR Apache-2.0" exclude = [".github/"] [workspace] -members = [ "crates/network", +members = [ + "crates/engine", + "crates/network", "crates/scroll-wire" ] resolver = "2" + +[workspace.lints] +rust.missing_debug_implementations = "warn" +rust.missing_docs = "warn" +rust.rust_2018_idioms = { level = "deny", priority = -1 } +rust.unreachable_pub = "warn" +rust.unused_must_use = "deny" +rustdoc.all = "warn" + +[workspace.lints.clippy] +borrow_as_ptr = "warn" +branches_sharing_code = "warn" +clear_with_drain = "warn" +cloned_instead_of_copied = "warn" +collection_is_never_read = "warn" +dbg_macro = "warn" +derive_partial_eq_without_eq = "warn" +doc_markdown = "warn" +empty_line_after_doc_comments = "warn" +empty_line_after_outer_attr = "warn" +enum_glob_use = "warn" +equatable_if_let = "warn" +explicit_into_iter_loop = "warn" +explicit_iter_loop = "warn" +flat_map_option = "warn" +from_iter_instead_of_collect = "warn" +if_not_else = "warn" +if_then_some_else_none = "warn" +implicit_clone = "warn" +imprecise_flops = "warn" +iter_on_empty_collections = "warn" +iter_on_single_items = "warn" +iter_with_drain = "warn" +iter_without_into_iter = "warn" +large_stack_frames = "warn" +manual_assert = "warn" +manual_clamp = "warn" +manual_is_variant_and = "warn" +manual_string_new = "warn" +match_same_arms = "warn" +missing_const_for_fn = "warn" +mutex_integer = "warn" +naive_bytecount = "warn" +needless_bitwise_bool = "warn" +needless_continue = "warn" +needless_for_each = "warn" +needless_pass_by_ref_mut = "warn" +nonstandard_macro_braces = "warn" +option_as_ref_cloned = "warn" +or_fun_call = "warn" +path_buf_push_overwrite = "warn" +read_zero_byte_vec = "warn" +redundant_clone = "warn" +redundant_else = "warn" +single_char_pattern = "warn" +string_lit_as_bytes = "warn" +string_lit_chars_any = "warn" +suboptimal_flops = "warn" +suspicious_operation_groupings = "warn" +trailing_empty_array = "warn" +trait_duplication_in_bounds = "warn" +transmute_undefined_repr = "warn" +trivial_regex = "warn" +tuple_array_conversions = "warn" +type_repetition_in_bounds = "warn" +uninhabited_references = "warn" +unnecessary_self_imports = "warn" +unnecessary_struct_initialization = "warn" +unnested_or_patterns = "warn" +unused_peekable = "warn" +unused_rounding = "warn" +use_self = "warn" +useless_let_if_seq = "warn" +while_float = "warn" +zero_sized_map_values = "warn" + +as_ptr_cast_mut = "allow" +cognitive_complexity = "allow" +debug_assert_with_mut_call = "allow" +fallible_impl_from = "allow" +future_not_send = "allow" +needless_collect = "allow" +non_send_fields_in_send_ty = "allow" +redundant_pub_crate = "allow" +significant_drop_in_scrutinee = "allow" +significant_drop_tightening = "allow" +too_long_first_doc_paragraph = "allow" + +[workspace.dependencies] +# alloy +alloy-primitives = { version = "0.8.15", default-features = false } + +# reth +reth-primitives = { git = "https://github.com/scroll-tech/reth.git", default-features = false } + +# reth-scroll +reth-scroll-primitives = { git = "https://github.com/scroll-tech/reth.git", default-features = false } + +# misc +eyre = "0.6" +serde = { version = "1.0", default-features = false } +tokio = { version = "1.39", default-features = false } +tracing = "0.1.0" + +[patch.crates-io] +revm = { git = "https://github.com/scroll-tech/revm.git", branch = "scroll-evm-executor/reth/v53" } +revm-primitives = { git = "https://github.com/scroll-tech/revm.git", branch = "scroll-evm-executor/reth/v53" } +revm-interpreter = { git = "https://github.com/scroll-tech/revm.git", branch = "scroll-evm-executor/reth/v53" } + +ff = { git = "https://github.com/scroll-tech/ff", branch = "feat/sp1" } + +alloy-eip2930 = { git = "https://github.com/scroll-tech/alloy-eips", branch = "v0.3.2" } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml new file mode 100644 index 0000000..8eacdc2 --- /dev/null +++ b/crates/engine/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "engine" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +exclude.workspace = true + +[lints] +workspace = true + +[dependencies] +# alloy +alloy-eips = { version = "0.9.2", default-features = false } +alloy-primitives.workspace = true +alloy-rpc-types-engine = { version = "0.9.2", default-features = false } + +# reth +reth-engine-primitives = { git = "https://github.com/scroll-tech/reth.git", default-features = false, features = ["scroll"] } +reth-primitives = { workspace = true, features = ["scroll"] } +reth-rpc-api = { git = "https://github.com/scroll-tech/reth.git", default-features = false, features = ["client"] } + +# reth-scroll +reth-scroll-primitives = { workspace = true, features = ["serde"] } + +# misc +eyre.workspace = true +serde = { workspace = true, features = ["derive"] } +tokio.workspace = true +tracing.workspace = true + +# test-utils +arbitrary = { version = "1.3", optional = true } + +[dev-dependencies] +arbitrary = "1.3" +reth-testing-utils = { git = "https://github.com/scroll-tech/reth.git" } + +[features] +arbitrary = [ + "alloy-primitives/arbitrary", + "reth-primitives/arbitrary" +] +test_utils = [ + "arbitrary", + "dep:arbitrary" +] diff --git a/crates/engine/src/block_info.rs b/crates/engine/src/block_info.rs new file mode 100644 index 0000000..f4e72ea --- /dev/null +++ b/crates/engine/src/block_info.rs @@ -0,0 +1,23 @@ +use alloy_primitives::B256; +use alloy_rpc_types_engine::ExecutionPayload; + +/// Information about a block. +#[derive(Debug, Copy, Clone)] +pub struct BlockInfo { + /// The block number. + pub number: u64, + /// The block hash. + pub hash: B256, +} + +impl From for BlockInfo { + fn from(value: ExecutionPayload) -> Self { + (&value).into() + } +} + +impl From<&ExecutionPayload> for BlockInfo { + fn from(value: &ExecutionPayload) -> Self { + Self { number: value.block_number(), hash: value.block_hash() } + } +} diff --git a/crates/engine/src/engine.rs b/crates/engine/src/engine.rs new file mode 100644 index 0000000..a230821 --- /dev/null +++ b/crates/engine/src/engine.rs @@ -0,0 +1,239 @@ +use crate::{ + block_info::BlockInfo, + payload::{matching_payloads, ScrollPayloadAttributes}, +}; + +use crate::ExecutionPayloadProvider; +use alloy_rpc_types_engine::{ + ExecutionPayload, ExecutionPayloadV1, ForkchoiceState, PayloadId, PayloadStatusEnum, +}; +use eyre::{bail, eyre, Result}; +use reth_engine_primitives::EngineTypes; +use reth_rpc_api::EngineApiClient; +use tokio::time::Duration; +use tracing::{debug, error, info, instrument, trace, warn}; + +const ENGINE_BACKOFF_INTERVAL: Duration = Duration::from_secs(1); + +/// The main interface to the Engine API of the EN. +/// Internally maintains the fork state of the chain. +#[derive(Debug, Clone)] +pub struct EngineDriver { + /// The engine API client. + client: EC, + /// The execution payload provider + execution_payload_provider: P, + /// The unsafe L2 block info. + unsafe_block_info: BlockInfo, + /// The safe L2 block info. + safe_block_info: BlockInfo, + /// The finalized L2 block info. + finalized_block_info: BlockInfo, + /// Marker + _types: std::marker::PhantomData, +} + +impl EngineDriver +where + EC: EngineApiClient + Sync, + ET: EngineTypes< + PayloadAttributes = ScrollPayloadAttributes, + ExecutionPayloadEnvelopeV1 = ExecutionPayloadV1, + >, + P: ExecutionPayloadProvider, +{ + /// Initialize the driver and wait for the Engine server to be ready. + pub async fn init_and_wait_for_engine( + client: EC, + execution_payload_provider: P, + unsafe_head: BlockInfo, + safe_head: BlockInfo, + finalized_head: BlockInfo, + ) -> Self { + let fcs = ForkchoiceState { + head_block_hash: unsafe_head.hash, + safe_block_hash: safe_head.hash, + finalized_block_hash: finalized_head.hash, + }; + + // wait on engine + loop { + match client.fork_choice_updated_v1(fcs, None).await { + Err(err) => { + debug!(target: "engine::driver", ?err, "waiting on engine client"); + tokio::time::sleep(ENGINE_BACKOFF_INTERVAL).await; + } + Ok(status) => { + info!(target: "engine::driver", payload_status = ?status.payload_status.status, "engine ready"); + break + } + } + } + + Self { + client, + execution_payload_provider, + unsafe_block_info: unsafe_head, + safe_block_info: safe_head, + finalized_block_info: finalized_head, + _types: std::marker::PhantomData, + } + } + + /// Set the finalized L2 block info. + pub fn set_finalized_block_info(&mut self, finalized_info: BlockInfo) { + self.finalized_block_info = finalized_info; + } + + /// Set the safe L2 block info. + pub fn set_safe_block_info(&mut self, safe_info: BlockInfo) { + self.safe_block_info = safe_info; + } + + /// Set the unsafe L2 block info. + pub fn set_unsafe_block_info(&mut self, unsafe_info: BlockInfo) { + self.unsafe_block_info = unsafe_info; + } + + /// Returns a [`ForkchoiceState`] from the current state of the [`EngineDriver`]. + const fn forkchoice_state(&self) -> ForkchoiceState { + ForkchoiceState { + head_block_hash: self.unsafe_block_info.hash, + safe_block_hash: self.safe_block_info.hash, + finalized_block_hash: self.finalized_block_info.hash, + } + } + + /// Handles an execution payload: + /// - Sends the payload to the EL via `engine_newPayloadV1`. + /// - Sets the current fork choice for the EL via `engine_forkchoiceUpdatedV1`. + #[instrument(skip_all, level = "trace", fields(head = %self.unsafe_block_info.hash, safe = %self.safe_block_info.hash, finalized = %self.safe_block_info.hash, payload_block_hash = %execution_payload.block_hash(), payload_block_num = %execution_payload.block_number()))] + pub async fn handle_execution_payload( + &mut self, + execution_payload: ExecutionPayload, + ) -> Result<()> { + let unsafe_block_info = (&execution_payload).into(); + let execution_payload = execution_payload.into_v1(); + self.new_payload(execution_payload).await?; + self.set_unsafe_block_info(unsafe_block_info); + self.forkchoice_updated(None).await?; + + Ok(()) + } + + /// Handles a payload attributes: + /// - Retrieves the execution payload for block at safe head + 1. + /// - If the payload is missing or doesn't match the attributes: + /// - Starts payload building task on the EL via `engine_forkchoiceUpdatedV1`, passing + /// the provided payload attributes. + /// - Retrieve the payload with `engine_getPayloadV1`. + /// - Sends the constructed payload to the EL via `engine_newPayloadV1`. + /// - Sets the current fork choice for the EL via `engine_forkchoiceUpdatedV1`. + /// - If the execution payload matches the attributes: + /// - Sets the current fork choice for the EL via `engine_forkchoiceUpdatedV1`, + /// advancing the safe head by one. + #[instrument(skip_all, level = "trace", fields(head = %self.unsafe_block_info.hash, safe = %self.safe_block_info.hash, finalized = %self.safe_block_info.hash))] + pub async fn handle_payload_attributes( + &mut self, + mut payload_attributes: ScrollPayloadAttributes, + ) -> Result<()> { + let maybe_execution_payload = self + .execution_payload_provider + .execution_payload_by_block((self.safe_block_info.number + 1).into()) + .await?; + let payload_attributes_already_inserted_in_chain = + maybe_execution_payload.as_ref().is_some_and(|ep| { + matching_payloads(&payload_attributes, ep, self.safe_block_info.hash) + }); + + if payload_attributes_already_inserted_in_chain { + // if the payload attributes match the execution payload at block safe + 1, + // this payload has already been passed to the EN in the form of a P2P gossiped + // execution payload. We can advance the safe head by one by issuing a + // forkchoiceUpdated. + self.set_safe_block_info(maybe_execution_payload.expect("exists").into()); + self.forkchoice_updated(None).await?; + } else { + // Otherwise, we construct a block from the payload attributes on top of the current + // safe head. + self.set_unsafe_block_info(self.safe_block_info); + + // start payload building with `no_tx_pool = true`. + payload_attributes.no_tx_pool = true; + let id = self + .forkchoice_updated(Some(payload_attributes)) + .await? + .ok_or_else(|| eyre!("missing payload id"))?; + + // retrieve the execution payload + let execution_payload = self.get_payload(id).await?; + + // issue the execution payload to the EL and set the new forkchoice + let safe_block_info = (&execution_payload).into(); + self.new_payload(execution_payload.into_v1()).await?; + + self.set_safe_block_info(safe_block_info); + self.set_unsafe_block_info(safe_block_info); + self.forkchoice_updated(None).await?; + } + + Ok(()) + } + + /// Calls `engine_newPayloadV1` and logs the result. + async fn new_payload(&self, execution_payload: ExecutionPayloadV1) -> Result<()> { + // TODO: should never enter the `Syncing`, `Accepted` or `Invalid` variants when called from + // `handle_payload_attributes`. + match self.client.new_payload_v1(execution_payload).await?.status { + PayloadStatusEnum::Invalid { validation_error } => { + error!(target: "engine::driver", ?validation_error, "failed to issue new execution payload"); + bail!("invalid payload: {validation_error}") + } + PayloadStatusEnum::Syncing => { + debug!(target: "engine::driver", "EN syncing"); + } + PayloadStatusEnum::Accepted => { + warn!(target: "engine::driver", "execution payload part of side chain"); + } + PayloadStatusEnum::Valid => { + trace!(target: "engine::driver", "execution payload valid"); + } + } + + Ok(()) + } + + /// Calls `engine_forkchoiceUpdatedV1` and logs the result. + async fn forkchoice_updated( + &self, + attributes: Option, + ) -> Result> { + let fc = self.forkchoice_state(); + let forkchoice_updated = self.client.fork_choice_updated_v1(fc, attributes).await?; + + // TODO: should never enter the `Syncing`, `Accepted` or `Invalid` variants when called from + // `handle_payload_attributes`. + match &forkchoice_updated.payload_status.status { + PayloadStatusEnum::Invalid { validation_error } => { + error!(target: "engine::driver", ?validation_error, "failed to issue forkchoice"); + bail!("invalid fork choice: {validation_error}") + } + PayloadStatusEnum::Syncing => { + debug!(target: "engine::driver", "EN syncing"); + } + PayloadStatusEnum::Accepted => { + warn!(target: "engine::driver", "payload attributes part of side chain"); + } + PayloadStatusEnum::Valid => { + trace!(target: "engine::driver", "execution payload valid"); + } + } + + Ok(forkchoice_updated.payload_id) + } + + /// Calls `engine_getPayloadV1`. + async fn get_payload(&self, id: PayloadId) -> Result { + Ok(self.client.get_payload_v1(id).await?.into()) + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs new file mode 100644 index 0000000..67be3ed --- /dev/null +++ b/crates/engine/src/lib.rs @@ -0,0 +1,14 @@ +//! Engine Driver for the Scroll Rollup Node. The [`EngineDriver`] exposes the main interface for +//! the Rollup Node to the Engine API. + +mod block_info; +pub use block_info::BlockInfo; + +mod engine; +pub use engine::EngineDriver; + +mod payload; +pub use payload::{ExecutionPayloadProvider, ScrollPayloadAttributes}; + +#[cfg(any(test, feature = "test_utils"))] +mod test_utils; diff --git a/crates/engine/src/payload.rs b/crates/engine/src/payload.rs new file mode 100644 index 0000000..4d4383b --- /dev/null +++ b/crates/engine/src/payload.rs @@ -0,0 +1,165 @@ +use alloy_eips::BlockId; +use alloy_primitives::{Bytes, B256}; +use alloy_rpc_types_engine::{ExecutionPayload, PayloadAttributes}; +use eyre::Result; +use serde::{Deserialize, Serialize}; +use std::future::Future; +use tracing::debug; + +/// The payload attributes for block building tailored for Scroll. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ScrollPayloadAttributes { + /// The payload attributes. + pub(crate) payload_attributes: PayloadAttributes, + /// An optional array of transaction to be forced included in the block (includes l1 messages). + pub(crate) transactions: Option>, + /// Indicates whether the payload building job should happen with or without pool transactions. + pub(crate) no_tx_pool: bool, +} + +/// Returns true if the [`ScrollPayloadAttributes`] matches the [`ExecutionPayload`]: +/// - provided parent hash matches the parent hash of the [`ExecutionPayload`] +/// - all transactions match +/// - timestamps are equal +/// - `prev_randaos` are equal +pub(crate) fn matching_payloads( + attributes: &ScrollPayloadAttributes, + payload: &ExecutionPayload, + parent_hash: B256, +) -> bool { + if payload.parent_hash() != parent_hash { + debug!( + target: "engine::driver", + expected = ?parent_hash, + got = ?payload.parent_hash(), + "mismatch in parent hash" + ); + return false + } + + let payload_transactions = &payload.as_v1().transactions; + let matching_transactions = + attributes.transactions.as_ref().is_some_and(|v| v == payload_transactions); + + if !matching_transactions { + debug!( + target: "engine::driver", + expected = ?attributes.transactions, + got = ?payload_transactions, + "mismatch in transactions" + ); + return false + } + + if payload.timestamp() != attributes.payload_attributes.timestamp { + debug!( + target: "engine::driver", + expected = ?attributes.payload_attributes.timestamp, + got = ?payload.timestamp(), + "mismatch in timestamp" + ); + return false + } + + if payload.prev_randao() != attributes.payload_attributes.prev_randao { + debug!( + target: "engine::driver", + expected = ?attributes.payload_attributes.prev_randao, + got = ?payload.prev_randao(), + "mismatch in prev_randao" + ); + return false + } + + true +} + +/// Implementers of the trait can provide the L2 execution payload for a block id. +pub trait ExecutionPayloadProvider { + /// Returns the [`ExecutionPayload`] for the provided [`BlockId`], or [None]. + fn execution_payload_by_block( + &self, + block_id: BlockId, + ) -> impl Future>> + Send; +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_rpc_types_engine::ExecutionPayloadV1; + use arbitrary::{Arbitrary, Unstructured}; + use reth_testing_utils::{generators, generators::Rng}; + + fn default_execution_payload_v1() -> ExecutionPayloadV1 { + ExecutionPayloadV1 { + parent_hash: Default::default(), + fee_recipient: Default::default(), + state_root: Default::default(), + receipts_root: Default::default(), + logs_bloom: Default::default(), + prev_randao: Default::default(), + block_number: 0, + gas_limit: 0, + gas_used: 0, + timestamp: 0, + extra_data: Default::default(), + base_fee_per_gas: Default::default(), + block_hash: Default::default(), + transactions: vec![], + } + } + + #[test] + fn test_matching_payloads() -> Result<()> { + let mut bytes = [0u8; 1024]; + generators::rng().fill(bytes.as_mut_slice()); + let mut unstructured = Unstructured::new(&bytes); + + let parent_hash = B256::arbitrary(&mut unstructured)?; + let transactions = Vec::::arbitrary(&mut unstructured)?; + let prev_randao = B256::arbitrary(&mut unstructured)?; + let timestamp = u64::arbitrary(&mut unstructured)?; + + let mut attributes = ScrollPayloadAttributes::arbitrary(&mut unstructured)?; + attributes.transactions = Some(transactions.clone()); + attributes.payload_attributes.timestamp = timestamp; + attributes.payload_attributes.prev_randao = prev_randao; + + let payload = ExecutionPayload::V1(ExecutionPayloadV1 { + prev_randao, + timestamp, + transactions, + parent_hash, + ..default_execution_payload_v1() + }); + + assert!(matching_payloads(&attributes, &payload, parent_hash)); + + Ok(()) + } + + #[test] + fn test_mismatched_payloads() -> Result<()> { + let mut bytes = [0u8; 1024]; + generators::rng().fill(bytes.as_mut_slice()); + let mut unstructured = Unstructured::new(&bytes); + + let parent_hash = B256::arbitrary(&mut unstructured)?; + let transactions = Vec::::arbitrary(&mut unstructured)?; + let prev_randao = B256::arbitrary(&mut unstructured)?; + let timestamp = u64::arbitrary(&mut unstructured)?; + + let attributes = ScrollPayloadAttributes::arbitrary(&mut unstructured)?; + let payload = ExecutionPayload::V1(ExecutionPayloadV1 { + prev_randao, + timestamp, + transactions, + parent_hash, + ..default_execution_payload_v1() + }); + + assert!(!matching_payloads(&attributes, &payload, parent_hash)); + + Ok(()) + } +} diff --git a/crates/engine/src/test_utils.rs b/crates/engine/src/test_utils.rs new file mode 100644 index 0000000..a233d4c --- /dev/null +++ b/crates/engine/src/test_utils.rs @@ -0,0 +1,20 @@ +use crate::ScrollPayloadAttributes; +use alloy_primitives::{Address, B256}; +use alloy_rpc_types_engine::PayloadAttributes; +use arbitrary::Unstructured; + +impl<'a> arbitrary::Arbitrary<'a> for ScrollPayloadAttributes { + fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result { + Ok(Self { + payload_attributes: PayloadAttributes { + timestamp: u64::arbitrary(u)?, + prev_randao: B256::arbitrary(u)?, + suggested_fee_recipient: Address::arbitrary(u)?, + withdrawals: Some(Vec::arbitrary(u)?), + parent_beacon_block_root: Some(B256::arbitrary(u)?), + }, + transactions: Some(Vec::arbitrary(u)?), + no_tx_pool: bool::arbitrary(u)?, + }) + } +} diff --git a/crates/scroll-wire/Cargo.toml b/crates/scroll-wire/Cargo.toml index 5745f7d..6784689 100644 --- a/crates/scroll-wire/Cargo.toml +++ b/crates/scroll-wire/Cargo.toml @@ -6,6 +6,9 @@ rust-version.workspace = true license.workspace = true exclude.workspace = true +[lints] +workspace = true + [dependencies] reth-eth-wire = { path = "../../../reth/crates/net/eth-wire" } reth-primitives = { path = "../../../reth/crates/primitives" } @@ -13,7 +16,7 @@ alloy-primitives = { version = "0.8.15", default-features = false, features = [ "map-foldhash", ] } alloy-rlp = { version = "0.3.10", default-features = false } -tokio = {version = "1.39", default-features = false, features = ["full"] } +tokio = { version = "1.39", default-features = false, features = ["full"] } tokio-stream = "0.1" futures = "0.3" reth-network = { path = "../../../reth/crates/net/network" } diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..68c3c93 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,11 @@ +reorder_imports = true +imports_granularity = "Crate" +use_small_heuristics = "Max" +comment_width = 100 +wrap_comments = true +binop_separator = "Back" +trailing_comma = "Vertical" +trailing_semicolon = false +use_field_init_shorthand = true +format_code_in_doc_comments = true +doc_comment_code_block_width = 100