diff --git a/Cargo.toml b/Cargo.toml index 49087fb..ad7c7c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "relay-api-types", "relay-client", "relay-server", + "rustic-builder", "searcher-api-types", "searcher-client", "common" @@ -18,7 +19,7 @@ members = [ async-trait = "0.1" axum = { version = "0.8", features = ["ws"] } bytes = "1.6" -eth2 = { git = "https://github.com/sigp/lighthouse.git", rev = "574b204bdb39fbfd7939c901a595647794b89274" } +eth2 = { git = "https://github.com/sigp/lighthouse.git", rev = "a0a6b9300f11b4875e18d42bc4ce1f2787f4448b" } ethereum_serde_utils = "0.7" ethereum_ssz = "0.8" ethereum_ssz_derive = "0.8" @@ -33,5 +34,5 @@ superstruct = "0.8" tokio = { version = "1", default-features = false, features = ["signal", "rt-multi-thread", "macros"] } tokio-tungstenite = "0.24.0" tracing = { version = "0.1", features = ["attributes"] } -types = { git = "https://github.com/sigp/lighthouse.git", rev = "574b204bdb39fbfd7939c901a595647794b89274" } +types = { git = "https://github.com/sigp/lighthouse.git", rev = "a0a6b9300f11b4875e18d42bc4ce1f2787f4448b" } rand = "0.8" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4af0ca6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +FROM rust:1.85-slim as builder + +WORKDIR /usr/src/workspace + +# Install build dependencies +RUN apt-get update && apt-get install -y \ + build-essential \ + pkg-config \ + libssl-dev \ + perl \ + cmake \ + libclang-dev \ + protobuf-compiler \ + && rm -rf /var/lib/apt/lists/* + +COPY . . + +# Build only the rustic-builder binary from the workspace +RUN cargo build --release --bin rustic-builder + +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + libssl3 \ + && rm -rf /var/lib/apt/lists/* + +# Copy the specific binary from the workspace target directory +COPY --from=builder /usr/src/workspace/target/release/rustic-builder /usr/local/bin/ + +EXPOSE 8560 + +ENTRYPOINT ["rustic-builder"] \ No newline at end of file diff --git a/common/Cargo.toml b/common/Cargo.toml index 2e81d0e..f7292dd 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -16,3 +16,4 @@ serde.workspace = true serde_json.workspace = true tracing.workspace = true beacon-api-types = { path = "../beacon-api-types" } +types.workspace = true diff --git a/common/src/lib.rs b/common/src/lib.rs index 4f6cd2f..fdeb09b 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -3,10 +3,7 @@ use axum::{ extract::{FromRequest, Request}, response::{IntoResponse, Response}, }; -use beacon_api_types::{ - fork_versioned_response::EmptyMetadata, ForkName, ForkVersionDecode, ForkVersionDeserialize, - ForkVersionedResponse, -}; +use beacon_api_types::{ForkName, ForkVersionDecode, ForkVersionedResponse}; use bytes::Bytes; use flate2::read::GzDecoder; use http::header::CONTENT_ENCODING; @@ -16,6 +13,7 @@ use serde::{Deserialize, Serialize}; use ssz::Encode; use std::{fmt, io::Read, str::FromStr}; use tracing::error; +use types::{beacon_response::EmptyMetadata, ContextDeserialize}; pub const CONSENSUS_VERSION_HEADER: &str = "Eth-Consensus-Version"; @@ -53,7 +51,7 @@ where let body_content = match content_type { ContentType::Json => { let body = ForkVersionedResponse { - version: Some(fork_name), + version: fork_name, metadata: EmptyMetadata {}, data: body, }; @@ -192,7 +190,10 @@ pub struct JsonOrSszWithFork(pub T); impl FromRequest for JsonOrSszWithFork where - T: serde::de::DeserializeOwned + ForkVersionDecode + 'static, + T: serde::de::DeserializeOwned + + ForkVersionDecode + + for<'de> ContextDeserialize<'de, ForkName> + + 'static, S: Send + Sync, { type Rejection = Response; @@ -204,7 +205,8 @@ where .and_then(|value| value.to_str().ok()); let fork_name = headers .get(CONSENSUS_VERSION_HEADER) - .and_then(|value| ForkName::from_str(value.to_str().unwrap()).ok()); + .and_then(|value| ForkName::from_str(value.to_str().unwrap()).ok()) + .ok_or(StatusCode::BAD_REQUEST.into_response())?; let bytes = Bytes::from_request(req, _state) .await @@ -212,13 +214,14 @@ where if let Some(content_type) = content_type { if content_type.starts_with(&ContentType::Json.to_string()) { - let payload: T = serde_json::from_slice(&bytes) + let mut de = serde_json::Deserializer::from_slice(&bytes); + let payload = T::context_deserialize(&mut de, fork_name) .map_err(|_| StatusCode::BAD_REQUEST.into_response())?; return Ok(Self(payload)); } if content_type.starts_with(&ContentType::Ssz.to_string()) { - let payload = T::from_ssz_bytes_by_fork(&bytes, fork_name.unwrap()) + let payload = T::from_ssz_bytes_by_fork(&bytes, fork_name) .map_err(|_| StatusCode::BAD_REQUEST.into_response())?; return Ok(Self(payload)); } @@ -390,39 +393,6 @@ pub fn custom_internal_err(message: String) -> ErrorResponse { } } -#[must_use] -#[derive(Debug, Clone, Copy, Default)] -pub struct JsonConsensusVersionHeader(pub T); - -impl FromRequest for JsonConsensusVersionHeader -where - T: ForkVersionDeserialize + 'static, - S: Send + Sync, -{ - type Rejection = Response; - - async fn from_request(req: Request, _state: &S) -> Result { - let headers = req.headers().clone(); - let fork_name = headers - .get(CONSENSUS_VERSION_HEADER) - .and_then(|value| value.to_str().ok()) - .and_then(|s| s.parse().ok()) - .ok_or(StatusCode::BAD_REQUEST.into_response())?; - - let bytes = Bytes::from_request(req, _state) - .await - .map_err(IntoResponse::into_response)?; - - let result = ForkVersionDeserialize::deserialize_by_fork::( - serde_json::de::from_slice(&bytes) - .map_err(|_| StatusCode::BAD_REQUEST.into_response())?, - fork_name, - ) - .map_err(|_| StatusCode::BAD_REQUEST.into_response())?; - Ok(Self(result)) - } -} - #[derive(Debug, Clone, Copy, PartialEq)] pub enum Accept { Json, diff --git a/rustic-builder/.gitignore b/rustic-builder/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/rustic-builder/.gitignore @@ -0,0 +1 @@ +/target diff --git a/rustic-builder/Cargo.toml b/rustic-builder/Cargo.toml new file mode 100644 index 0000000..cb3c8c6 --- /dev/null +++ b/rustic-builder/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "rustic-builder" +version = "0.1.0" +edition = "2021" + +[dependencies] +clap = {version = "4.5", features = ["derive"]} +builder-server = { path = "../builder-server" } +ethereum-apis-common = { path = "../common" } +async-trait.workspace = true +tokio.workspace = true +tracing.workspace = true +tracing-subscriber = {version = "0.3.8", features = ["env-filter"]} +tracing-error = "0.2.0" +tracing-core = "0.1.21" +async-channel = "1.9.0" +futures-channel = "0.3.21" +axum.workspace = true +hex = "0.4.3" +types.workspace = true +eth2.workspace = true +execution_layer = { git = "https://github.com/sigp/lighthouse", rev = "a0a6b9300f11b4875e18d42bc4ce1f2787f4448b" } +task_executor = { git = "https://github.com/sigp/lighthouse", rev = "a0a6b9300f11b4875e18d42bc4ce1f2787f4448b" } +sensitive_url = { git = "https://github.com/sigp/lighthouse", rev = "a0a6b9300f11b4875e18d42bc4ce1f2787f4448b" } +eth2_network_config = { git = "https://github.com/sigp/lighthouse", rev = "a0a6b9300f11b4875e18d42bc4ce1f2787f4448b" } diff --git a/rustic-builder/README.md b/rustic-builder/README.md new file mode 100644 index 0000000..62ee7ab --- /dev/null +++ b/rustic-builder/README.md @@ -0,0 +1,45 @@ +# Rustic Builder + +## Overview + +A simple mock builder implementation that serves local mempool transactions from an Ethereum node through the Builder API flow. +It works as a wrapper over Lighthouse's [mock-builder](https://github.com/sigp/lighthouse/blob/unstable/beacon_node/execution_layer/src/test_utils/mock_builder.rs) which is used for lighthouse tests. This means that as Lighthouse implements support for new forks, the builder automatically gets support for the fork by just pointing it to the right lighthouse commit. + +The name references both its implementation language (Rust) and its rustic nature - serving farm-to-table payloads from your local execution client. + +Note: This currently does not support updating the gas limit at runtime based on the validator registrations. It is meant to use in a controlled [kurtosis](https://github.com/ethpandaops/ethereum-package) like setup where the gas limit does not change over the duration of the testnet. + +## Installation + +### From Source +``` +cargo build --release +``` + +### Using Docker +```bash +docker build -t rustic-builder . +``` + +## Usage + +Needs a fully synced ethereum node (Beacon node + Execution client) + +### Running from Binary +``` +./target/release/rustic-builder --execution-endpoint http://localhost:8551 --beacon-node http://localhost:5052 --jwt-secret jwt.hex --port 8560 +``` + +### Running with Docker +```bash +docker run -p 8560:8560 \ + -v /path/to/jwt.hex:/jwt.hex \ + rustic-builder \ + --execution-endpoint http://execution-client:8551 \ + --beacon-node http://beacon-node:5052 +``` + +Note: When running with Docker, make sure to: +- Mount your JWT secret file +- Use appropriate network settings (--network host if running nodes locally) +- Adjust the execution/beacon endpoints to match your setup diff --git a/rustic-builder/src/builder_impl.rs b/rustic-builder/src/builder_impl.rs new file mode 100644 index 0000000..019346a --- /dev/null +++ b/rustic-builder/src/builder_impl.rs @@ -0,0 +1,87 @@ +use std::{ops::Deref, sync::Arc}; + +use async_trait::async_trait; +use builder_server::{builder::Builder, FullPayloadContents}; +use ethereum_apis_common::{custom_internal_err, ErrorResponse}; +use execution_layer::test_utils::MockBuilder; +use types::{ + builder_bid::SignedBuilderBid, ChainSpec, EthSpec, ExecutionBlockHash, ForkName, + PublicKeyBytes, SignedBlindedBeaconBlock, SignedValidatorRegistrationData, Slot, +}; + +#[derive(Clone)] +pub struct RusticBuilder { + builder: MockBuilder, + spec: Arc, +} + +impl RusticBuilder { + pub fn new(builder: MockBuilder, spec: Arc) -> Self { + Self { builder, spec } + } +} + +impl Deref for RusticBuilder { + type Target = MockBuilder; + fn deref(&self) -> &Self::Target { + &self.builder + } +} + +impl AsRef> for RusticBuilder { + fn as_ref(&self) -> &RusticBuilder { + self + } +} + +#[async_trait] +impl Builder for RusticBuilder { + fn fork_name_at_slot(&self, slot: Slot) -> ForkName { + self.spec.fork_name_at_slot::(slot) + } + + async fn register_validators( + &self, + registrations: Vec, + ) -> Result<(), ErrorResponse> { + tracing::info!("Registering validators, count: {}", registrations.len()); + self.builder + .register_validators(registrations) + .await + .map_err(custom_internal_err) + } + + async fn get_header( + &self, + slot: Slot, + parent_hash: ExecutionBlockHash, + pubkey: PublicKeyBytes, + ) -> Result, ErrorResponse> { + tracing::info!( + "Getting header for slot {}, parent_hash: {}, pubkey: {:?}", + slot, + parent_hash, + pubkey + ); + self.builder + .get_header(slot, parent_hash, pubkey) + .await + .map_err(custom_internal_err) + } + + async fn submit_blinded_block( + &self, + signed_block: SignedBlindedBeaconBlock, + ) -> Result, ErrorResponse> { + tracing::info!( + "Submitting signed blinded block to builder, slot: {}, root: {}, fork: {}", + signed_block.message().slot(), + signed_block.canonical_root(), + signed_block.fork_name_unchecked(), + ); + self.builder + .submit_blinded_block(signed_block) + .await + .map_err(custom_internal_err) + } +} diff --git a/rustic-builder/src/lib.rs b/rustic-builder/src/lib.rs new file mode 100644 index 0000000..96714cc --- /dev/null +++ b/rustic-builder/src/lib.rs @@ -0,0 +1 @@ +pub mod builder_impl; diff --git a/rustic-builder/src/main.rs b/rustic-builder/src/main.rs new file mode 100644 index 0000000..1826fee --- /dev/null +++ b/rustic-builder/src/main.rs @@ -0,0 +1,187 @@ +use clap::Parser; +use eth2::Timeouts; +use execution_layer::Config; +use rustic_builder::builder_impl::RusticBuilder; +use sensitive_url::SensitiveUrl; +use std::ops::Deref; +use std::path::PathBuf; +use std::time::Duration; +use std::{net::Ipv4Addr, sync::Arc}; +use task_executor::ShutdownReason; +use tracing::{instrument, Level}; +use tracing_core::LevelFilter; +use tracing_error::ErrorLayer; +use tracing_subscriber::prelude::*; +use tracing_subscriber::EnvFilter; +use types::{Address, ChainSpec, MainnetEthSpec}; + +#[derive(Parser)] +#[clap(about = "Rustic Builder", version = "0.1.0", author = "@pawanjay176")] +struct BuilderConfig { + #[clap( + long, + help = "URL of the execution engine", + default_value = "http://localhost:8551" + )] + execution_endpoint: String, + #[clap( + long, + help = "URL of the beacon node", + default_value = "http://localhost:5052" + )] + beacon_node: String, + #[clap( + long, + help = "File path which contain the corresponding hex-encoded JWT secrets for the provided \ + execution endpoint." + )] + jwt_secret: PathBuf, + #[clap(long, help = "Address to listen on", default_value = "127.0.0.1")] + address: Ipv4Addr, + #[clap(long, help = "Port to listen on", default_value_t = 8650)] + port: u16, + #[clap(long, short = 'l', help = "Set the log level", default_value = "info")] + log_level: Level, + #[clap( + long, + help = "Fee recipient to use in case of missing registration.", + requires("empty-payloads") + )] + default_fee_recipient: Option
, + #[clap( + long, + help = "Builder always returns a bid with value set to max", + default_value_t = false + )] + set_max_bid_value: bool, + #[clap( + long, + help = "If set to true, builder will return a bid even for non registered validators", + default_value_t = false + )] + allow_unregistered_validators: bool, + #[clap( + long, + help = "Hex-encoded secret key for the builder. If provided, it will be used as the secret key for the builder." + )] + builder_secret_key: Option, +} + +#[instrument] +#[tokio::main] +async fn main() -> Result<(), String> { + let builder_config: BuilderConfig = BuilderConfig::parse(); + let log_level: LevelFilter = builder_config.log_level.into(); + + // Initialize logging + let filter = EnvFilter::builder() + .with_default_directive(LevelFilter::OFF.into()) + .parse(format!( + "rustic_builder={},execution_layer={}", + log_level, log_level + )) + .unwrap(); + + // Set up the tracing subscriber with the filter + tracing_subscriber::Registry::default() + .with( + tracing_subscriber::fmt::layer() + .with_ansi(false) + .with_filter(filter), + ) + .with(ErrorLayer::default()) + .init(); + + tracing::info!("Starting mock relay"); + + let beacon_url = SensitiveUrl::parse(builder_config.beacon_node.as_str()) + .map_err(|e| format!("Failed to parse beacon URL: {:?}", e))?; + let beacon_client = + eth2::BeaconNodeHttpClient::new(beacon_url, Timeouts::set_all(Duration::from_secs(12))); + let config = beacon_client + .get_config_spec::() + .await + .map_err(|e| format!("Failed to get config spec: {:?}", e))?; + let spec = ChainSpec::from_config::(config.data.config()) + .ok_or_else(|| String::from("Unable to parse chain spec from config"))?; + let builder_secret_key = builder_config + .builder_secret_key + .as_ref() + .map(|key| { + hex::decode(key).map_err(|e| format!("Failed to decode builder secret key: {:?}", e)) + }) + .transpose()?; + let url = SensitiveUrl::parse(builder_config.execution_endpoint.as_str()) + .map_err(|e| format!("Failed to parse execution endpoint URL: {:?}", e))?; + + let (shutdown_tx, _shutdown_rx) = futures_channel::mpsc::channel::(1); + let (_signal, exit) = async_channel::bounded(1); + let task_executor = task_executor::TaskExecutor::new( + tokio::runtime::Handle::current(), + exit, + shutdown_tx, + "rustic-builder".to_string(), + ); + + let config = Config { + execution_endpoint: Some(url), + secret_file: Some(builder_config.jwt_secret), + suggested_fee_recipient: builder_config.default_fee_recipient, + ..Default::default() + }; + + let el = execution_layer::ExecutionLayer::::from_config( + config, + task_executor.clone(), + ) + .map_err(|e| format!("Failed to create execution layer: {:?}", e))?; + + let spec = Arc::new(spec); + let mock_builder = execution_layer::test_utils::MockBuilder::new( + el, + beacon_client.clone(), + builder_config.allow_unregistered_validators, + false, + builder_config.set_max_bid_value, + spec.clone(), + builder_secret_key.as_deref(), + ); + let rustic_builder = Arc::new(RusticBuilder::new(mock_builder, spec)); + tracing::info!("Initialized mock builder"); + + let pubkey = rustic_builder.deref().public_key(); + tracing::info!("Builder pubkey: {pubkey:?}"); + + let listener = tokio::net::TcpListener::bind(format!( + "{}:{}", + builder_config.address, builder_config.port + )) + .await + .unwrap(); + + let builder_preparer = rustic_builder.clone(); + task_executor.spawn( + { + async move { + tracing::info!("Starting preparation service"); + let _result = builder_preparer.prepare_execution_layer().await; + tracing::error!("Preparation service stopped"); + } + }, + "preparation service", + ); + tracing::info!("Listening on {:?}", listener.local_addr()); + let app = builder_server::server::new(rustic_builder); + task_executor.spawn( + async { + tracing::info!("Starting builder server"); + axum::serve(listener, app).await.expect("server failed"); + }, + "rustic_server", + ); + + task_executor.exit().await; + tracing::info!("Shutdown complete."); + + Ok(()) +}