Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: engine #119

Merged
merged 3 commits into from
Nov 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
320 changes: 241 additions & 79 deletions Cargo.lock

Large diffs are not rendered by default.

15 changes: 9 additions & 6 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,11 @@ alloy-rpc-types-engine = { version = "0.6.4", default-features = false }
# op-alloy-consensus = { version = "0.6.7", default-features = false }
# op-alloy-rpc-types-engine = { version = "0.6.7", default-features = false }

op-alloy-genesis = { git = "https://github.com/alloy-rs/op-alloy", branch = "rf/chore/rm-hardcoded-configs", default-features = false }
op-alloy-protocol = { git = "https://github.com/alloy-rs/op-alloy", branch = "rf/chore/rm-hardcoded-configs", default-features = false }
op-alloy-registry = { git = "https://github.com/alloy-rs/op-alloy", branch = "rf/chore/rm-hardcoded-configs", default-features = false }
op-alloy-consensus = { git = "https://github.com/alloy-rs/op-alloy", branch = "rf/chore/rm-hardcoded-configs", default-features = false }
op-alloy-rpc-types-engine = { git = "https://github.com/alloy-rs/op-alloy", branch = "rf/chore/rm-hardcoded-configs", default-features = false }
op-alloy-genesis = { git = "https://github.com/alloy-rs/op-alloy", branch = "main", default-features = false }
op-alloy-protocol = { git = "https://github.com/alloy-rs/op-alloy", branch = "main", default-features = false }
op-alloy-registry = { git = "https://github.com/alloy-rs/op-alloy", branch = "main", default-features = false }
op-alloy-consensus = { git = "https://github.com/alloy-rs/op-alloy", branch = "main", default-features = false }
op-alloy-rpc-types-engine = { git = "https://github.com/alloy-rs/op-alloy", branch = "main", default-features = false }


# Reth
Expand Down Expand Up @@ -109,12 +109,15 @@ arbitrary = "1"
url = "2.5.2"
lru = "0.12.5"
eyre = "0.6.12"
again = "0.1.2"
clap = "4.5.20"
tokio = "1.41.0"
parking_lot = "0.12.3"
futures = "0.3.31"
reqwest = "0.12.9"
parking_lot = "0.12.3"
async-trait = "0.1.83"
futures-timer = "3.0.3"
unsigned-varint = "0.8.0"
thiserror = { version = "2.0", default-features = false }
derive_more = { version = "1.0.0", default-features = false }
lazy_static = { version = "1.5.0", default-features = false }
29 changes: 29 additions & 0 deletions crates/engine/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
[package]
name = "hilo-engine"
description = "Engine Controller Peripheral to the Driver"

version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
homepage.workspace = true
keywords.workspace = true
categories.workspace = true
repository.workspace = true
rust-version.workspace = true

[dependencies]
# Alloy
alloy-rpc-types-engine = { workspace = true, features = ["jwt", "serde"] }

# Misc
again.workspace = true
serde.workspace = true
futures.workspace = true
reqwest = { workspace = true, features = ["json"] }
thiserror.workspace = true
serde_json.workspace = true
async-trait.workspace = true

[dev-dependencies]
tokio.workspace = true
3 changes: 3 additions & 0 deletions crates/engine/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# `hilo-engine`

An Engine Controller for hilo.
302 changes: 302 additions & 0 deletions crates/engine/src/api.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
//! Contains the engine api client.

use std::{collections::HashMap, time::Duration};

use again::RetryPolicy;
use futures::future::TryFutureExt;
use reqwest::{header, Client};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde_json::Value;

use alloy_rpc_types_engine::{
Claims, ExecutionPayloadEnvelopeV3, ExecutionPayloadV3, ForkchoiceState, ForkchoiceUpdated,
JwtSecret, PayloadAttributes, PayloadId, PayloadStatus,
};

use crate::{
Engine, DEFAULT_AUTH_PORT, ENGINE_FORKCHOICE_UPDATED_V2, ENGINE_GET_PAYLOAD_V2,
ENGINE_NEW_PAYLOAD_V2, JSONRPC_VERSION, STATIC_ID,
};

/// An external op-geth engine api client
#[derive(Debug, Clone)]
pub struct EngineApi {
/// Base request url
pub base_url: String,
/// The url port
pub port: u16,
/// HTTP Client
pub client: Option<Client>,
/// A JWT secret used to authenticate with the engine api
secret: JwtSecret,
}

/// Generic Engine API response
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EngineApiResponse<P> {
/// JSON RPC version
jsonrpc: String,
/// Request ID
id: u64,
/// JSON RPC payload
result: Option<P>,
/// JSON RPC error payload
error: Option<EngineApiErrorPayload>,
}

/// Engine API error payload
#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq, Serialize, Deserialize)]
pub struct EngineApiErrorPayload {
/// The error code
pub code: i64,
/// The error message
pub message: String,
/// Optional additional error data
pub data: Option<Value>,
}

impl std::fmt::Display for EngineApiErrorPayload {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Engine API Error: code: {}, message: {}", self.code, self.message)
}
}

/// An engine api error
#[derive(Debug, thiserror::Error)]
pub enum EngineApiError {
/// An error converting the raw value to json.
#[error("Error converting value to json")]
SerdeError(#[from] serde_json::Error),
/// Missing http client
#[error("Missing http client")]
MissingHttpClient,
/// Failed to encode the JWT Claims
#[error("Failed to encode JWT Claims")]
JwtEncode,
/// A reqwest error
#[error("Reqwest error: {0}")]
ReqwestError(#[from] reqwest::Error),
/// An [EngineApiErrorPayload] returned by the engine api
#[error("Engine API error")]
EngineApiPayload(Option<EngineApiErrorPayload>),
}

impl EngineApi {
/// Creates a new [`EngineApi`] with a base url and secret.
pub fn new(base_url: &str, secret_str: &str) -> Self {
let secret = JwtSecret::from_hex(secret_str).unwrap();

// Gracefully parse the port from the base url
let parts: Vec<&str> = base_url.split(':').collect();
let port = parts[parts.len() - 1].parse::<u16>().unwrap_or(DEFAULT_AUTH_PORT);
let base_url = if parts.len() <= 2 { parts[0].to_string() } else { parts.join(":") };

let client = reqwest::Client::builder()
.default_headers({
header::HeaderMap::from_iter([(
header::CONTENT_TYPE,
header::HeaderValue::from_static("application/json"),
)])
})
.timeout(Duration::from_secs(5))
.build()
.expect("reqwest::Client could not be built, TLS backend could not be initialized");

Self { base_url, port, client: Some(client), secret }
}

/// Constructs the base engine api url for the given address
pub fn auth_url_from_addr(addr: &str, port: Option<u16>) -> String {
let stripped = addr.strip_prefix("http://").unwrap_or(addr);
let stripped = addr.strip_prefix("https://").unwrap_or(stripped);
let port = port.unwrap_or(DEFAULT_AUTH_PORT);
format!("http://{stripped}:{port}")
}

/// Returns if the provided secret matches the secret used to authenticate with the engine api.
pub fn check_secret(&self, secret: &str) -> bool {
self.secret.validate(secret).is_ok()
}

/// Creates an engine api from environment variables
pub fn from_env() -> Self {
let base_url = std::env::var("ENGINE_API_URL").unwrap_or_else(|_| {
panic!(
"ENGINE_API_URL environment variable not set. \
Please set this to the base url of the engine api"
)
});
let secret_key = std::env::var("JWT_SECRET").unwrap_or_else(|_| {
panic!(
"JWT_SECRET environment variable not set. \
Please set this to the 256 bit hex-encoded secret key used to authenticate with the engine api. \
This should be the same as set in the `--auth.secret` flag when executing go-ethereum."
)
});
let base_url = EngineApi::auth_url_from_addr(&base_url, None);
Self::new(&base_url, &secret_key)
}

/// Construct base body
pub fn base_body(&self) -> HashMap<String, Value> {
let mut map = HashMap::new();
map.insert("jsonrpc".to_string(), Value::String(JSONRPC_VERSION.to_string()));
map.insert("id".to_string(), Value::Number(STATIC_ID.into()));
map
}

/// Helper to construct a post request through the client
async fn post<P>(&self, method: &str, params: Vec<Value>) -> Result<P, EngineApiError>
where
P: DeserializeOwned,
{
// Construct the request params
let mut body = self.base_body();
body.insert("method".to_string(), Value::String(method.to_string()));
body.insert("params".to_string(), Value::Array(params));

// Send the client request
let client = self.client.as_ref().ok_or(EngineApiError::MissingHttpClient)?;

// Clone the secret so we can use it in the retry policy.
let secret_clone = self.secret;

let policy = RetryPolicy::fixed(Duration::ZERO).with_max_retries(5);

// Send the request
let res = policy
.retry(|| async {
// Construct the JWT Authorization Token
let claims = Claims::with_current_timestamp();
let jwt = secret_clone.encode(&claims).map_err(|_| EngineApiError::JwtEncode)?;

// Send the request
client
.post(&self.base_url)
.header(header::AUTHORIZATION, format!("Bearer {}", jwt))
.json(&body)
.send()
.map_err(EngineApiError::ReqwestError)
// .timeout(Duration::from_secs(2))
.await?
.json::<EngineApiResponse<P>>()
.map_err(EngineApiError::ReqwestError)
// .timeout(Duration::from_secs(2))
// .map_err(|e| EngineApiError::ReqwestError(e))
.await
})
.await?;

if let Some(res) = res.result {
return Ok(res);
}

Err(EngineApiError::EngineApiPayload(res.error))
}

/// Calls the engine to verify it's available to receive requests
pub async fn is_available(&self) -> bool {
self.post::<Value>("eth_chainId", vec![]).await.is_ok()
}
}

#[async_trait::async_trait]
impl Engine for EngineApi {
type Error = EngineApiError;

/// Sends an `engine_forkchoiceUpdatedV2` (V3 post Ecotone) message to the engine.
async fn forkchoice_updated(
&self,
forkchoice_state: ForkchoiceState,
payload_attributes: Option<PayloadAttributes>,
) -> Result<ForkchoiceUpdated, Self::Error> {
let payload_attributes_param = match payload_attributes {
Some(payload_attributes) => serde_json::to_value(payload_attributes)?,
None => Value::Null,
};
let forkchoice_state_param = serde_json::to_value(forkchoice_state)?;
let params = vec![forkchoice_state_param, payload_attributes_param];
let res = self.post(ENGINE_FORKCHOICE_UPDATED_V2, params).await?;
Ok(res)
}

/// Sends an `engine_newPayloadV2` (V3 post Ecotone) message to the engine.
async fn new_payload(
&self,
execution_payload: ExecutionPayloadV3,
) -> Result<PayloadStatus, Self::Error> {
let params = vec![serde_json::to_value(execution_payload)?];
let res = self.post(ENGINE_NEW_PAYLOAD_V2, params).await?;
Ok(res)
}

/// Sends an `engine_getPayloadV2` (V3 post Ecotone) message to the engine.
async fn get_payload(&self, payload_id: PayloadId) -> Result<ExecutionPayloadV3, Self::Error> {
let encoded = format!("{:x}", payload_id.0);
let padded = format!("0x{:0>16}", encoded);
let params = vec![Value::String(padded)];
let res = self.post::<ExecutionPayloadEnvelopeV3>(ENGINE_GET_PAYLOAD_V2, params).await?;
Ok(res.execution_payload)
}
}

#[cfg(test)]
mod tests {
use alloy_rpc_types_engine::Claims;
use std::time::SystemTime;

// use std::str::FromStr;
// use ethers_core::types::H256;

use super::*;

const AUTH_ADDR: &str = "0.0.0.0";
const SECRET: &str = "f79ae8046bc11c9927afe911db7143c51a806c4a537cc08e0d37140b0192f430";

#[tokio::test]
async fn test_engine_get_payload() {
// Construct the engine api client
let base_url = EngineApi::auth_url_from_addr(AUTH_ADDR, Some(8551));
assert_eq!(base_url, "http://0.0.0.0:8551");
let engine_api = EngineApi::new(&base_url, SECRET);
assert_eq!(engine_api.base_url, "http://0.0.0.0:8551");
assert_eq!(engine_api.port, 8551);

// Construct mock server params
let secret = JwtSecret::from_hex(SECRET).unwrap();
let iat = SystemTime::UNIX_EPOCH;
let iat_secs = iat.duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
let claims = Claims { iat: iat_secs, exp: Some(iat_secs + 60) };
let jwt = secret.encode(&claims).unwrap();
assert_eq!(jwt, String::from("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjAsImV4cCI6NjB9.rJv_krfkQefjWnZxrpnDimR1NN1UEUffK3hQzD1KInA"));
// let bearer = format!("Bearer {jwt}");
// let expected_body = r#"{"jsonrpc": "2.0", "method": "engine_getPayloadV1", "params":
// [""], "id": 1}"#; let mock_response = ExecutionPayloadResponse {
// jsonrpc: "2.0".to_string(),
// id: 1,
// result: ExecutionPayload {
// parent_hash: H256::from(
// }
// };

// Create the mock server
// let server = ServerBuilder::default()
// .set_id_provider(RandomStringIdProvider::new(16))
// .set_middleware(middleware)
// .build(addr.parse::<SocketAddr>().unwrap())
// .await
// .unwrap();

// Query the engine api client
// let execution_payload = engine_api.get_payload(PayloadId::default()).await.unwrap();
// let expected_block_hash =
// H256::from_str("0xdc0818cf78f21a8e70579cb46a43643f78291264dda342ae31049421c82d21ae")
// .unwrap();
// assert_eq!(expected_block_hash, execution_payload.block_hash);

// Stop the server
// server.stop().unwrap();
// server.stopped().await;
}
}
20 changes: 20 additions & 0 deletions crates/engine/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#![doc = include_str!("../README.md")]
#![doc(issue_tracker_base_url = "https://github.com/anton-rs/hilo/issues/")]
#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))]
#![cfg_attr(not(test), warn(unused_crate_dependencies))]

// Re-export the [JwtSecret] type from alloy_rpc_types_engine.
pub use alloy_rpc_types_engine::JwtSecret;

mod api;
pub use api::EngineApi;

mod types;
pub use types::{
DEFAULT_AUTH_PORT, ENGINE_FORKCHOICE_UPDATED_TIMEOUT, ENGINE_FORKCHOICE_UPDATED_V2,
ENGINE_GET_PAYLOAD_TIMEOUT, ENGINE_GET_PAYLOAD_V2, ENGINE_NEW_PAYLOAD_TIMEOUT,
ENGINE_NEW_PAYLOAD_V2, JSONRPC_VERSION, STATIC_ID,
};

mod traits;
pub use traits::Engine;
Loading