From 736f294e7d272191340dbc58c04470761854fe3a Mon Sep 17 00:00:00 2001 From: Pawan Dhananjay Date: Thu, 26 Jun 2025 11:24:56 -0700 Subject: [PATCH 1/3] Add rustic-builder as a workspace module --- Cargo.toml | 5 +- rustic-builder/.gitignore | 1 + rustic-builder/Cargo.toml | 25 ++++ rustic-builder/Dockerfile | 30 +++++ rustic-builder/README.md | 45 +++++++ rustic-builder/src/builder_impl.rs | 87 ++++++++++++++ rustic-builder/src/lib.rs | 1 + rustic-builder/src/main.rs | 187 +++++++++++++++++++++++++++++ 8 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 rustic-builder/.gitignore create mode 100644 rustic-builder/Cargo.toml create mode 100644 rustic-builder/Dockerfile create mode 100644 rustic-builder/README.md create mode 100644 rustic-builder/src/builder_impl.rs create mode 100644 rustic-builder/src/lib.rs create mode 100644 rustic-builder/src/main.rs 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/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/Dockerfile b/rustic-builder/Dockerfile new file mode 100644 index 0000000..9827a56 --- /dev/null +++ b/rustic-builder/Dockerfile @@ -0,0 +1,30 @@ +FROM rust:1.85-slim as builder + +WORKDIR /usr/src/rustic-builder + +# 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 . . +RUN cargo build --release + +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 --from=builder /usr/src/rustic-builder/target/release/rustic-builder /usr/local/bin/ + +EXPOSE 8560 + +ENTRYPOINT ["rustic-builder"] 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(()) +} From 7cc5e1d3efab395c90fb3c064be53596934b094e Mon Sep 17 00:00:00 2001 From: Pawan Dhananjay Date: Thu, 26 Jun 2025 11:44:57 -0700 Subject: [PATCH 2/3] Fix compile; move dockerfile --- rustic-builder/Dockerfile => Dockerfile | 11 ++++--- common/Cargo.toml | 1 + common/src/lib.rs | 41 ++----------------------- 3 files changed, 11 insertions(+), 42 deletions(-) rename rustic-builder/Dockerfile => Dockerfile (59%) diff --git a/rustic-builder/Dockerfile b/Dockerfile similarity index 59% rename from rustic-builder/Dockerfile rename to Dockerfile index 9827a56..4af0ca6 100644 --- a/rustic-builder/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM rust:1.85-slim as builder -WORKDIR /usr/src/rustic-builder +WORKDIR /usr/src/workspace # Install build dependencies RUN apt-get update && apt-get install -y \ @@ -14,7 +14,9 @@ RUN apt-get update && apt-get install -y \ && rm -rf /var/lib/apt/lists/* COPY . . -RUN cargo build --release + +# Build only the rustic-builder binary from the workspace +RUN cargo build --release --bin rustic-builder FROM debian:bookworm-slim @@ -23,8 +25,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libssl3 \ && rm -rf /var/lib/apt/lists/* -COPY --from=builder /usr/src/rustic-builder/target/release/rustic-builder /usr/local/bin/ +# 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"] +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..0d2a0d1 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; 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, }; @@ -390,39 +388,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, From d3ebf2379de9b142f5112dba2e9a0262f35a11c2 Mon Sep 17 00:00:00 2001 From: Pawan Dhananjay Date: Mon, 30 Jun 2025 16:12:17 -0700 Subject: [PATCH 3/3] Fix deserialization --- common/src/lib.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/common/src/lib.rs b/common/src/lib.rs index 0d2a0d1..fdeb09b 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; use ssz::Encode; use std::{fmt, io::Read, str::FromStr}; use tracing::error; -use types::beacon_response::EmptyMetadata; +use types::{beacon_response::EmptyMetadata, ContextDeserialize}; pub const CONSENSUS_VERSION_HEADER: &str = "Eth-Consensus-Version"; @@ -190,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; @@ -202,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 @@ -210,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)); }