diff --git a/Cargo.lock b/Cargo.lock index e069e31..36b9105 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -680,7 +680,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b367dcccada5b28987c2296717ee04b9a5637aacd78eacb1726ef211678b5212" dependencies = [ "alloy-json-rpc", + "alloy-rpc-types-engine", "alloy-transport", + "http-body-util", + "hyper 1.5.0", + "hyper-util", + "jsonwebtoken", "reqwest", "serde_json", "tower 0.5.1", @@ -3575,7 +3580,7 @@ dependencies = [ "iana-time-zone-haiku", "js-sys", "wasm-bindgen", - "windows-core 0.52.0", + "windows-core 0.51.1", ] [[package]] @@ -5625,6 +5630,20 @@ dependencies = [ "serde", ] +[[package]] +name = "op-alloy-provider" +version = "0.4.0" +source = "git+https://github.com/alloy-rs/op-alloy?branch=main#9fd8a65e9b8c535f09fdde6f3997cc94637bd292" +dependencies = [ + "alloy-network", + "alloy-primitives", + "alloy-provider", + "alloy-rpc-types-engine", + "alloy-transport", + "async-trait", + "op-alloy-rpc-types-engine 0.4.0", +] + [[package]] name = "op-alloy-rpc-jsonrpsee" version = "0.4.0" @@ -9143,14 +9162,17 @@ dependencies = [ "alloy-network", "alloy-primitives", "alloy-provider", + "alloy-rpc-client", "alloy-rpc-types", "alloy-rpc-types-engine", "alloy-transport", + "alloy-transport-http", "async-trait", "clap", "eyre", "futures", "hashbrown 0.15.0", + "http-body-util", "kona-derive", "kona-providers", "kona-providers-alloy", @@ -9158,6 +9180,7 @@ dependencies = [ "metrics-exporter-prometheus 0.16.0", "op-alloy-genesis 0.4.0", "op-alloy-protocol 0.4.0", + "op-alloy-provider", "op-alloy-rpc-types-engine 0.4.0", "reqwest", "reth", @@ -9167,6 +9190,7 @@ dependencies = [ "serde_json", "superchain", "tokio", + "tower 0.5.1", "tracing", "tracing-subscriber", "url", @@ -11172,15 +11196,6 @@ dependencies = [ "windows-targets 0.48.5", ] -[[package]] -name = "windows-core" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-core" version = "0.57.0" diff --git a/Cargo.toml b/Cargo.toml index d2671c9..c62e48f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,12 +30,13 @@ op-alloy-genesis = { git = "https://github.com/alloy-rs/op-alloy", branch = "mai op-alloy-rpc-types = { git = "https://github.com/alloy-rs/op-alloy", branch = "main" } op-alloy-rpc-types-engine = { git = "https://github.com/alloy-rs/op-alloy", branch = "main" } op-alloy-rpc-jsonrpsee = { git = "https://github.com/alloy-rs/op-alloy", branch = "main" } +op-alloy-provider = { git = "https://github.com/alloy-rs/op-alloy", branch = "main" } [workspace.dependencies] # Workspace +ser = { path = "crates/ser" } op-net = { path = "crates/net" } rollup = { path = "crates/rollup" } -ser = { path = "crates/ser" } kona-providers-local = { path = "crates/providers-local" } # Kona @@ -58,10 +59,13 @@ alloy-transport = { version = "0.4.2", default-features = false } alloy-rpc-types = { version = "0.4.2", default-features = false } alloy-consensus = { version = "0.4.2", default-features = false } alloy-primitives = { version = "0.8.8", default-features = false } +alloy-rpc-client = { version = "0.4.2", default-features = false } +alloy-transport-http = { version = "0.4.2", default-features = false } alloy-rpc-types-engine = { version = "0.4.2", default-features = false } # Op Alloy op-alloy-genesis = { version = "0.4.0", default-features = false } +op-alloy-provider = { version = "0.4.0", default-features = false } op-alloy-protocol = { version = "0.4.0", default-features = false } op-alloy-consensus = { version = "0.4.0", default-features = false } op-alloy-rpc-types = { version = "0.4.0", default-features = false } @@ -70,21 +74,21 @@ op-alloy-rpc-types-engine = { version = "0.4.0", default-features = false } # Reth reth = { git = "https://github.com/paradigmxyz/reth", rev = "a846cbd" } -reth-chainspec = { git = "https://github.com/paradigmxyz/reth", rev = "a846cbd" } +reth-evm = { git = "https://github.com/paradigmxyz/reth", rev = "a846cbd" } +reth-revm = { git = "https://github.com/paradigmxyz/reth", rev = "a846cbd" } +reth-exex = { git = "https://github.com/paradigmxyz/reth", rev = "a846cbd" } reth-discv5 = { git = "https://github.com/paradigmxyz/reth", rev = "a846cbd" } -reth-execution-errors = { git = "https://github.com/paradigmxyz/reth", rev = "a846cbd" } -reth-execution-types = { git = "https://github.com/paradigmxyz/reth", rev = "a846cbd" } -reth-exex = { git = "https://github.com/paradigmxyz/reth", features = ["serde"], rev = "a846cbd" } -reth-network-peers = { git = "https://github.com/paradigmxyz/reth", rev = "a846cbd" } +reth-tracing = { git = "https://github.com/paradigmxyz/reth", rev = "a846cbd" } +reth-provider = { git = "https://github.com/paradigmxyz/reth", rev = "a846cbd" } reth-node-api = { git = "https://github.com/paradigmxyz/reth", rev = "a846cbd" } -reth-node-ethereum = { git = "https://github.com/paradigmxyz/reth", rev = "a846cbd" } +reth-chainspec = { git = "https://github.com/paradigmxyz/reth", rev = "a846cbd" } reth-primitives = { git = "https://github.com/paradigmxyz/reth", rev = "a846cbd" } -reth-provider = { git = "https://github.com/paradigmxyz/reth", rev = "a846cbd" } -reth-revm = { git = "https://github.com/paradigmxyz/reth", rev = "a846cbd" } -reth-evm = { git = "https://github.com/paradigmxyz/reth", rev = "a846cbd" } -reth-tracing = { git = "https://github.com/paradigmxyz/reth", rev = "a846cbd" } reth-rpc-eth-api = { git = "https://github.com/paradigmxyz/reth", rev = "a846cbd" } reth-rpc-eth-types = { git = "https://github.com/paradigmxyz/reth", rev = "a846cbd" } +reth-network-peers = { git = "https://github.com/paradigmxyz/reth", rev = "a846cbd" } +reth-node-ethereum = { git = "https://github.com/paradigmxyz/reth", rev = "a846cbd" } +reth-execution-types = { git = "https://github.com/paradigmxyz/reth", rev = "a846cbd" } +reth-execution-errors = { git = "https://github.com/paradigmxyz/reth", rev = "a846cbd" } # Tokio tokio = { version = "1.21", default-features = false } @@ -121,6 +125,8 @@ async-trait = "0.1.83" hashbrown = "0.15.0" parking_lot = "0.12.3" unsigned-varint = "0.8.0" +tower = "0.5" +http-body-util = "0.1.2" tracing-subscriber = "0.3.18" rand = { version = "0.8.5", default-features = false } diff --git a/crates/rollup/Cargo.toml b/crates/rollup/Cargo.toml index 4d652e6..ce337c5 100644 --- a/crates/rollup/Cargo.toml +++ b/crates/rollup/Cargo.toml @@ -21,24 +21,27 @@ alloy-eips.workspace = true alloy-primitives.workspace = true alloy-provider = { workspace = true, features = ["ipc"] } alloy-transport.workspace = true +alloy-transport-http = { workspace = true, features = ["jwt-auth"] } alloy-network.workspace = true alloy-consensus.workspace = true alloy-rpc-types = { workspace = true, features = ["ssz"] } +alloy-rpc-client.workspace = true alloy-rpc-types-engine.workspace = true # Op Alloy op-alloy-genesis.workspace = true op-alloy-protocol.workspace = true op-alloy-rpc-types-engine.workspace = true +op-alloy-provider.workspace = true # Superchain superchain = { workspace = true, default-features = false } # Reth reth.workspace = true -reth-exex.workspace = true reth-node-api.workspace = true reth-execution-types.workspace = true +reth-exex = { workspace = true, features = ["serde"] } # Telemetry tracing-subscriber = { version = "0.3.18", features = ["env-filter", "fmt"] } @@ -53,5 +56,9 @@ async-trait.workspace = true tokio.workspace = true futures.workspace = true hashbrown.workspace = true +tower.workspace = true +http-body-util.workspace = true clap = { workspace = true, features = ["derive", "env"] } + +[dev-dependencies] reqwest = { workspace = true, features = ["rustls-tls-native-roots"] } diff --git a/crates/rollup/src/cli.rs b/crates/rollup/src/cli.rs index 17d3df8..6e60172 100644 --- a/crates/rollup/src/cli.rs +++ b/crates/rollup/src/cli.rs @@ -61,22 +61,17 @@ pub struct HeraArgsExt { /// same block and comparing the results. /// - Engine API: use a local or remote engine API of an L2 execution client. Validation /// happens by sending the `new_payload` to the API and expecting a VALID response. - #[clap( - long = "hera.validation-mode", - default_value = "trusted", - requires_ifs([("engine-api", "l2_engine_api_url"), ("engine-api", "l2_engine_jwt_secret")]), - )] + #[clap(long = "hera.validation-mode", default_value = "engine-api")] pub validation_mode: ValidationMode, - /// If the mode is "engine api", we also need an URL for the engine API endpoint of - /// the execution client to validate the payload. - #[clap(long = "hera.l2-engine-api-url")] - pub l2_engine_api_url: Option, + /// URL of the engine API endpoint of an L2 execution client. + #[clap(long = "hera.l2-engine-api-url", env = "L2_ENGINE_API_URL")] + pub l2_engine_api_url: Url, - /// If the mode is "engine api", we also need a JWT secret for the auth-rpc. + /// JWT secret for the auth-rpc endpoint of the execution client. /// This MUST be a valid path to a file containing the hex-encoded JWT secret. - #[clap(long = "hera.l2-engine-jwt-secret")] - pub l2_engine_jwt_secret: Option, + #[clap(long = "hera.l2-engine-jwt-secret", env = "L2_ENGINE_JWT_SECRET")] + pub l2_engine_jwt_secret: PathBuf, /// The maximum **number of blocks** to keep cached in the chain provider. /// diff --git a/crates/rollup/src/driver/mod.rs b/crates/rollup/src/driver/mod.rs index cd44d8d..4fc63fb 100644 --- a/crates/rollup/src/driver/mod.rs +++ b/crates/rollup/src/driver/mod.rs @@ -1,6 +1,7 @@ //! Rollup Node Driver use alloy_eips::eip1898::BlockNumHash; +use reth::rpc::types::engine::JwtSecret; use std::{fmt::Debug, sync::Arc}; use eyre::{bail, eyre, Result}; @@ -14,16 +15,13 @@ use kona_providers_alloy::{AlloyChainProvider, AlloyL2ChainProvider, OnlineBlobP use kona_providers_local::{DurableBlobProvider, InMemoryChainProvider, LayeredBlobProvider}; use op_alloy_genesis::RollupConfig; use op_alloy_protocol::{BlockInfo, L2BlockInfo}; -use reth::rpc::types::engine::JwtSecret; use reth_exex::ExExContext; use reth_node_api::FullNodeComponents; use tracing::{debug, error, info, trace, warn}; use crate::{ - cli::ValidationMode, - new_rollup_pipeline, - validator::{EngineApiValidator, TrustedValidator}, - AttributesValidator, HeraArgsExt, RollupPipeline, + cli::ValidationMode, new_rollup_pipeline, validator::TrustedPayloadValidator, Engine, + HeraArgsExt, RollupPipeline, }; mod context; @@ -48,7 +46,9 @@ pub struct Driver { /// Cursor to keep track of the L2 tip cursor: SyncCursor, /// The validator to verify newly derived L2 attributes - validator: Box, + trusted_validator: Option, + /// The engine API handler + engine: Engine, } impl Driver, InMemoryChainProvider, LayeredBlobProvider> { @@ -77,7 +77,7 @@ impl Driver { .with_primary(args.l1_beacon_client_url.as_str().trim_end_matches('/').to_string()) .with_fallback( args.l1_blob_archiver_url - .clone() + .as_ref() .map(|url| url.as_str().trim_end_matches('/').to_string()), ) .build(); @@ -105,23 +105,29 @@ where l1_chain_provider: CP, blob_provider: BP, ) -> Self { - let cursor = SyncCursor::new(cfg.channel_timeout); - let validator: Box = match args.validation_mode { - ValidationMode::Trusted => Box::new(TrustedValidator::new_http( - args.l2_rpc_url.clone(), - cfg.canyon_time.unwrap_or(0), - )), - ValidationMode::EngineApi => Box::new(EngineApiValidator::new_http( - args.l2_engine_api_url.expect("Missing L2 engine API URL"), - match args.l2_engine_jwt_secret.as_ref() { - Some(fpath) => JwtSecret::from_file(fpath).expect("Invalid L2 JWT secret file"), - None => panic!("Missing L2 engine JWT secret"), - }, - )), + let trusted_validator = if matches!(args.validation_mode, ValidationMode::Trusted) { + let canyon_activation = cfg.canyon_time.unwrap_or(0); + Some(TrustedPayloadValidator::new_http(args.l2_rpc_url.clone(), canyon_activation)) + } else { + None }; + + let l2_jwt_secret = JwtSecret::from_file(&args.l2_engine_jwt_secret).expect("jwt secret"); + let engine = Engine::new_http(args.l2_engine_api_url, l2_jwt_secret); + + let cursor = SyncCursor::new(cfg.channel_timeout); let l2_chain_provider = AlloyL2ChainProvider::new_http(args.l2_rpc_url, cfg.clone()); - Self { cfg, ctx, l1_chain_provider, blob_provider, l2_chain_provider, cursor, validator } + Self { + cfg, + ctx, + l1_chain_provider, + l2_chain_provider, + blob_provider, + cursor, + trusted_validator, + engine, + } } /// Wait for the L2 genesis' corresponding L1 block to be available in the L1 chain. @@ -184,11 +190,17 @@ where }, } - let derived_attributes = if let Some(attributes) = pipeline.peek() { - match self.validator.validate(attributes).await { + let Some(derived_attributes) = pipeline.peek() else { + debug!("No attributes available to validate"); + return false; + }; + let derived_block_number = derived_attributes.parent.block_info.number + 1; + + if let Some(trusted_validator) = &self.trusted_validator { + match trusted_validator.validate_payload(derived_attributes).await { Ok(true) => { trace!("Validated payload attributes"); - pipeline.next().expect("Peeked attributes must be available") + pipeline.next().expect("Peeked attributes must be available"); } Ok(false) => { error!("Failed payload attributes validation"); @@ -202,12 +214,14 @@ where } } } else { - debug!("No attributes available to validate"); - return false; - }; + if let Err(err) = self.engine.validate_payload_fcu(derived_attributes).await { + error!("Failed to validate payload attributes: {:?}", err); + return false; + } + pipeline.next().expect("Peeked attributes must be available"); + } - let derived = derived_attributes.parent.block_info.number + 1; - let (new_l1_origin, new_l2_tip) = match self.fetch_new_tip(derived).await { + let (new_l1_origin, new_l2_tip) = match self.fetch_new_tip(derived_block_number).await { Ok(tip_info) => tip_info, Err(err) => { // TODO: add a retry mechanism? @@ -217,14 +231,17 @@ where }; // Perform a sanity check on the new tip - if new_l2_tip.block_info.number != derived { - error!("Expected L2 block number {} but got {}", derived, new_l2_tip.block_info.number); + if new_l2_tip.block_info.number != derived_block_number { + error!( + "Expected L2 block {} but got {}", + derived_block_number, new_l2_tip.block_info.number + ); return false; } // Advance the cursor to the new L2 block self.cursor.advance(new_l1_origin, new_l2_tip); - info!("Advanced derivation pipeline to L2 block: {}", derived); + info!("Advanced derivation pipeline to L2 block: {}", derived_block_number); true } diff --git a/crates/rollup/src/engine.rs b/crates/rollup/src/engine.rs new file mode 100644 index 0000000..5900d67 --- /dev/null +++ b/crates/rollup/src/engine.rs @@ -0,0 +1,75 @@ +use std::ops::Deref; + +use alloy_network::AnyNetwork; +use alloy_primitives::Bytes; +use alloy_provider::RootProvider; +use alloy_rpc_client::RpcClient; +use alloy_transport_http::{ + hyper_util::{ + client::legacy::{connect::HttpConnector, Client}, + rt::TokioExecutor, + }, + AuthLayer, AuthService, Http, HyperClient, +}; +use eyre::Result; +use http_body_util::Full; +use op_alloy_provider::ext::engine::OpEngineApi; +use op_alloy_rpc_types_engine::OpAttributesWithParent; +use reth::rpc::types::engine::{ForkchoiceState, JwtSecret}; +use tower::ServiceBuilder; +use tracing::warn; +use url::Url; + +/// A Hyper HTTP client with a JWT authentication layer. +type HyperAuthClient> = HyperClient>>; + +/// The [`Engine`] is responsible for interacting with an L2 Engine API server. +#[derive(Debug, Clone)] +pub struct Engine { + provider: RootProvider, AnyNetwork>, +} + +impl Engine { + /// Creates a new [`Engine`] from the provided [Url] and [JwtSecret]. + pub fn new_http(url: Url, jwt: JwtSecret) -> Self { + let hyper_client = Client::builder(TokioExecutor::new()).build_http::>(); + + let auth_layer = AuthLayer::new(jwt); + let service = ServiceBuilder::new().layer(auth_layer).service(hyper_client); + + let layer_transport = HyperClient::with_service(service); + let http_hyper = Http::with_client(layer_transport, url); + let rpc_client = RpcClient::new(http_hyper, true); + let provider = RootProvider::<_, AnyNetwork>::new(rpc_client); + + Self { provider } + } + + /// Validates the payload using the Fork Choice Update API. + pub async fn validate_payload_fcu(&self, attributes: &OpAttributesWithParent) -> Result { + // TODO: use the correct values + let fork_choice_state = ForkchoiceState { + head_block_hash: attributes.parent.block_info.hash, + finalized_block_hash: attributes.parent.block_info.hash, + safe_block_hash: attributes.parent.block_info.hash, + }; + + let attributes = Some(attributes.attributes.clone()); + let fcu = self.provider.fork_choice_updated_v2(fork_choice_state, attributes).await?; + + if fcu.is_valid() { + Ok(true) + } else { + warn!(status = %fcu.payload_status, "Engine API returned invalid fork choice update"); + Ok(false) + } + } +} + +impl Deref for Engine { + type Target = RootProvider, AnyNetwork>; + + fn deref(&self) -> &Self::Target { + &self.provider + } +} diff --git a/crates/rollup/src/lib.rs b/crates/rollup/src/lib.rs index a28078d..400baaf 100644 --- a/crates/rollup/src/lib.rs +++ b/crates/rollup/src/lib.rs @@ -10,8 +10,11 @@ pub use driver::Driver; mod cli; pub use cli::HeraArgsExt; +mod engine; +pub use engine::Engine; + mod validator; -pub use validator::AttributesValidator; +pub use validator::TrustedPayloadValidator; mod pipeline; pub use pipeline::{new_rollup_pipeline, RollupPipeline}; diff --git a/crates/rollup/src/validator.rs b/crates/rollup/src/validator.rs index fa73520..3ab3f7b 100644 --- a/crates/rollup/src/validator.rs +++ b/crates/rollup/src/validator.rs @@ -7,50 +7,29 @@ use alloy_primitives::Bytes; use alloy_provider::{network::primitives::BlockTransactionsKind, Provider, ReqwestProvider}; use alloy_rpc_types_engine::PayloadAttributes; -use async_trait::async_trait; use eyre::{bail, eyre, Result}; use op_alloy_rpc_types_engine::{OpAttributesWithParent, OpPayloadAttributes}; -use reqwest::{ - header::{AUTHORIZATION, CONTENT_TYPE}, - Client, StatusCode, -}; -use reth::rpc::types::{ - engine::{Claims, JwtSecret}, - Header, -}; +use reth::rpc::types::Header; use tracing::error; use url::Url; -/// AttributesValidator -/// -/// A trait that defines the interface for validating newly derived L2 attributes. -#[async_trait] -pub trait AttributesValidator: Debug + Send { - /// Validates the given [`OpAttributesWithParent`] and returns true - /// if the attributes are valid, false otherwise. - async fn validate(&self, attributes: &OpAttributesWithParent) -> Result; -} - -/// TrustedValidator -/// -/// Validates the [`OpAttributesWithParent`] by fetching the associated L2 block from -/// a trusted L2 RPC and constructing the L2 Attributes from the block. +/// Trusted node client that validates the [`OpAttributesWithParent`] by fetching the associated L2 +/// block from a trusted L2 RPC and constructing the L2 Attributes from the block. #[derive(Debug, Clone)] -pub struct TrustedValidator { +pub struct TrustedPayloadValidator { /// The L2 provider. provider: ReqwestProvider, /// The canyon activation timestamp. canyon_activation: u64, } -impl TrustedValidator { - /// Creates a new [`TrustedValidator`]. +impl TrustedPayloadValidator { + /// Creates a new [`TrustedPayloadValidator`]. pub fn new(provider: ReqwestProvider, canyon_activation: u64) -> Self { Self { provider, canyon_activation } } - /// Creates a new [`TrustedValidator`] from the provided [Url]. - #[allow(unused)] + /// Creates a new [`TrustedPayloadValidator`] from the provided [Url]. pub fn new_http(url: Url, canyon_activation: u64) -> Self { let inner = ReqwestProvider::new_http(url); Self::new(inner, canyon_activation) @@ -108,11 +87,10 @@ impl TrustedValidator { eip_1559_params: None, // TODO: fix this }) } -} -#[async_trait] -impl AttributesValidator for TrustedValidator { - async fn validate(&self, attributes: &OpAttributesWithParent) -> Result { + /// Validates the [`OpAttributesWithParent`] by fetching the associated L2 block from + /// a trusted L2 RPC and constructing the L2 Attributes from the block. + pub async fn validate_payload(&self, attributes: &OpAttributesWithParent) -> Result { let expected = attributes.parent.block_info.number + 1; let tag = BlockNumberOrTag::from(expected); @@ -125,62 +103,3 @@ impl AttributesValidator for TrustedValidator { } } } - -/// EngineApiValidator -/// -/// Validates the [`OpAttributesWithParent`] by sending the attributes to an L2 engine API. -/// The engine API will return a `VALID` or `INVALID` response. -#[derive(Debug, Clone)] -pub struct EngineApiValidator { - /// The engine API URL. - url: Url, - /// The reqwest client. - client: Client, - /// The JWT secret token for the engine API. - jwt_secret: JwtSecret, -} - -impl EngineApiValidator { - /// Creates a new [`EngineApiValidator`] from the provided [Url] and [JwtSecret]. - #[allow(unused)] - pub fn new_http(url: Url, jwt: JwtSecret) -> Self { - Self { url, client: Client::new(), jwt_secret: jwt } - } -} - -#[async_trait] -impl AttributesValidator for EngineApiValidator { - async fn validate(&self, attributes: &OpAttributesWithParent) -> Result { - let request_body = serde_json::json!({ - "id": 1, - "jsonrpc": "2.0", - "method": "engine_newPayloadV2", - "params": [attributes.attributes] - }); - - let claims = Claims::default(); - let jwt = self.jwt_secret.encode(&claims)?; - - let response = self - .client - .post(self.url.clone()) - .header(CONTENT_TYPE, "application/json") - .header(AUTHORIZATION, format!("Bearer {}", jwt)) - .json(&request_body) - .send() - .await?; - - let status = response.status(); - let body = response.json::().await?; - match status { - StatusCode::OK => Ok(body - .pointer("/result/status") - .and_then(|status| status.as_str()) - .map_or(false, |status| status == "VALID")), - _ => { - error!(?body, "Engine API returned status: {}", status); - bail!("Engine API returned status: {} and body: {:#?}", status, body); - } - } - } -}