From 920f8797592b09be9547ba83f5034b80779718e8 Mon Sep 17 00:00:00 2001 From: Joe Clapis Date: Mon, 9 Feb 2026 10:45:09 -0500 Subject: [PATCH 01/11] PBS: Reload the config file when it changes (#409) --- Cargo.lock | 55 +++++++++ bin/pbs.rs | 4 +- crates/common/Cargo.toml | 1 + crates/common/src/config/log.rs | 2 +- crates/common/src/config/mod.rs | 10 +- crates/common/src/config/module.rs | 4 +- crates/common/src/config/pbs.rs | 33 ++++-- crates/common/src/config/signer.rs | 2 +- crates/common/src/config/utils.rs | 16 ++- crates/pbs/Cargo.toml | 1 + crates/pbs/src/mev_boost/reload.rs | 4 +- crates/pbs/src/service.rs | 45 ++++++- crates/pbs/src/state.rs | 10 +- examples/status_api/src/main.rs | 15 ++- tests/Cargo.toml | 1 + tests/tests/pbs_cfg_file_update.rs | 158 +++++++++++++++++++++++++ tests/tests/pbs_get_header.rs | 8 +- tests/tests/pbs_get_status.rs | 6 +- tests/tests/pbs_mux.rs | 4 +- tests/tests/pbs_mux_refresh.rs | 4 +- tests/tests/pbs_post_blinded_blocks.rs | 6 +- tests/tests/pbs_post_validators.rs | 8 +- 22 files changed, 339 insertions(+), 58 deletions(-) create mode 100644 tests/tests/pbs_cfg_file_update.rs diff --git a/Cargo.lock b/Cargo.lock index 6d7a97e0..6d6d055d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1731,6 +1731,7 @@ dependencies = [ "eyre", "futures", "lazy_static", + "notify", "parking_lot", "prometheus", "reqwest 0.13.2", @@ -1787,6 +1788,7 @@ dependencies = [ "serde_json", "tempfile", "tokio", + "toml", "tracing", "tracing-subscriber", "tracing-test", @@ -3684,6 +3686,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inotify" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" +dependencies = [ + "bitflags 2.9.4", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.4" @@ -4178,6 +4200,30 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.9.4", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -5396,6 +5442,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" diff --git a/bin/pbs.rs b/bin/pbs.rs index 0b7c3f72..ca8d9c9c 100644 --- a/bin/pbs.rs +++ b/bin/pbs.rs @@ -27,10 +27,10 @@ async fn main() -> Result<()> { let _args = cb_cli::PbsArgs::parse(); - let pbs_config = load_pbs_config().await?; + let (pbs_config, config_path) = load_pbs_config(None).await?; PbsService::init_metrics(pbs_config.chain)?; - let state = PbsState::new(pbs_config); + let state = PbsState::new(pbs_config, config_path); let server = PbsService::run::<_, DefaultBuilderApi>(state); tokio::select! { diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 34b2f83d..55edb082 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -31,6 +31,7 @@ lh_bls.workspace = true lh_eth2.workspace = true lh_eth2_keystore.workspace = true lh_types.workspace = true +notify.workspace = true pbkdf2.workspace = true rand.workspace = true rayon.workspace = true diff --git a/crates/common/src/config/log.rs b/crates/common/src/config/log.rs index 595a81a1..a792ebc8 100644 --- a/crates/common/src/config/log.rs +++ b/crates/common/src/config/log.rs @@ -16,7 +16,7 @@ pub struct LogsSettings { impl LogsSettings { pub fn from_env_config() -> Result { - let mut config = CommitBoostConfig::from_env_path()?; + let (mut config, _) = CommitBoostConfig::from_env_path()?; // Override log dir path if env var is set if let Some(log_dir) = load_optional_env_var(LOGS_DIR_ENV) { diff --git a/crates/common/src/config/mod.rs b/crates/common/src/config/mod.rs index 67a13cb0..c833d8e3 100644 --- a/crates/common/src/config/mod.rs +++ b/crates/common/src/config/mod.rs @@ -56,14 +56,14 @@ impl CommitBoostConfig { } pub fn from_file(path: &PathBuf) -> Result { - let config: Self = load_from_file(path)?; + let (config, _): (Self, _) = load_from_file(path)?; Ok(config) } // When loading the config from the environment, it's important that every path // is replaced with the correct value if the config is loaded inside a container - pub fn from_env_path() -> Result { - let helper_config: HelperConfig = load_file_from_env(CONFIG_ENV)?; + pub fn from_env_path() -> Result<(Self, PathBuf)> { + let (helper_config, config_path): (HelperConfig, PathBuf) = load_file_from_env(CONFIG_ENV)?; let chain = match helper_config.chain { ChainLoader::Path { path, genesis_time_secs } => { @@ -109,13 +109,13 @@ impl CommitBoostConfig { logs: helper_config.logs, }; - Ok(config) + Ok((config, config_path)) } /// Returns the path to the chain spec file if any pub fn chain_spec_file(path: &PathBuf) -> Option { match load_from_file::<_, ChainConfig>(path) { - Ok(config) => { + Ok((config, _)) => { if let ChainLoader::Path { path, genesis_time_secs: _ } = config.chain { Some(path) } else { diff --git a/crates/common/src/config/module.rs b/crates/common/src/config/module.rs index 02fa90da..6f5fdd3e 100644 --- a/crates/common/src/config/module.rs +++ b/crates/common/src/config/module.rs @@ -82,7 +82,7 @@ pub fn load_commit_module_config() -> Result = load_file_from_env(CONFIG_ENV)?; + let (cb_config, _): (StubConfig, _) = load_file_from_env(CONFIG_ENV)?; // find all matching modules config let matches: Vec> = cb_config @@ -148,7 +148,7 @@ pub fn load_builder_module_config() -> eyre::Result = load_file_from_env(CONFIG_ENV)?; + let (cb_config, _): (StubConfig, _) = load_file_from_env(CONFIG_ENV)?; // find all matching modules config let matches: Vec> = cb_config diff --git a/crates/common/src/config/pbs.rs b/crates/common/src/config/pbs.rs index 7bcf91e3..f24e75c4 100644 --- a/crates/common/src/config/pbs.rs +++ b/crates/common/src/config/pbs.rs @@ -3,6 +3,7 @@ use std::{ collections::HashMap, net::{Ipv4Addr, SocketAddr}, + path::PathBuf, sync::Arc, }; @@ -242,8 +243,11 @@ fn default_pbs() -> String { } /// Loads the default pbs config, i.e. with no signer client or custom data -pub async fn load_pbs_config() -> Result { - let config = CommitBoostConfig::from_env_path()?; +pub async fn load_pbs_config(config_path: Option) -> Result<(PbsModuleConfig, PathBuf)> { + let (config, config_path) = match config_path { + Some(path) => (CommitBoostConfig::from_file(&path)?, path), + None => CommitBoostConfig::from_env_path()?, + }; config.validate().await?; // Make sure relays isn't empty - since the config is still technically valid if @@ -295,16 +299,19 @@ pub async fn load_pbs_config() -> Result { let all_relays = all_relays.into_values().collect(); - Ok(PbsModuleConfig { - chain: config.chain, - endpoint, - pbs_config: Arc::new(config.pbs.pbs_config), - relays: relay_clients, - all_relays, - signer_client: None, - registry_muxes, - mux_lookup, - }) + Ok(( + PbsModuleConfig { + chain: config.chain, + endpoint, + pbs_config: Arc::new(config.pbs.pbs_config), + relays: relay_clients, + all_relays, + signer_client: None, + registry_muxes, + mux_lookup, + }, + config_path, + )) } /// Loads a custom pbs config, i.e. with signer client and/or custom data @@ -326,7 +333,7 @@ pub async fn load_pbs_custom_config() -> Result<(PbsModuleC } // load module config including the extra data (if any) - let cb_config: StubConfig = load_file_from_env(CONFIG_ENV)?; + let (cb_config, _): (StubConfig, _) = load_file_from_env(CONFIG_ENV)?; cb_config.pbs.static_config.pbs_config.validate(cb_config.chain).await?; // use endpoint from env if set, otherwise use default host and port diff --git a/crates/common/src/config/signer.rs b/crates/common/src/config/signer.rs index 8ab8186f..ae55c16d 100644 --- a/crates/common/src/config/signer.rs +++ b/crates/common/src/config/signer.rs @@ -142,7 +142,7 @@ pub struct StartSignerConfig { impl StartSignerConfig { pub fn load_from_env() -> Result { - let config = CommitBoostConfig::from_env_path()?; + let (config, _) = CommitBoostConfig::from_env_path()?; let jwts = load_jwt_secrets()?; diff --git a/crates/common/src/config/utils.rs b/crates/common/src/config/utils.rs index b956df59..a8fcbacd 100644 --- a/crates/common/src/config/utils.rs +++ b/crates/common/src/config/utils.rs @@ -1,4 +1,7 @@ -use std::{collections::HashMap, path::Path}; +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; use eyre::{Context, Result, bail}; use serde::de::DeserializeOwned; @@ -17,13 +20,18 @@ pub fn load_optional_env_var(env: &str) -> Option { std::env::var(env).ok() } -pub fn load_from_file + std::fmt::Debug, T: DeserializeOwned>(path: P) -> Result { +pub fn load_from_file + std::fmt::Debug, T: DeserializeOwned>( + path: P, +) -> Result<(T, PathBuf)> { let config_file = std::fs::read_to_string(path.as_ref()) .wrap_err(format!("Unable to find config file: {path:?}"))?; - toml::from_str(&config_file).wrap_err("could not deserialize toml from string") + match toml::from_str(&config_file).wrap_err("could not deserialize toml from string") { + Ok(config) => Ok((config, path.as_ref().to_path_buf())), + Err(e) => Err(e), + } } -pub fn load_file_from_env(env: &str) -> Result { +pub fn load_file_from_env(env: &str) -> Result<(T, PathBuf)> { let path = std::env::var(env).wrap_err(format!("{env} is not set"))?; load_from_file(&path) } diff --git a/crates/pbs/Cargo.toml b/crates/pbs/Cargo.toml index e8cb0b31..a9124c06 100644 --- a/crates/pbs/Cargo.toml +++ b/crates/pbs/Cargo.toml @@ -15,6 +15,7 @@ cb-metrics.workspace = true eyre.workspace = true futures.workspace = true lazy_static.workspace = true +notify.workspace = true parking_lot.workspace = true prometheus.workspace = true reqwest.workspace = true diff --git a/crates/pbs/src/mev_boost/reload.rs b/crates/pbs/src/mev_boost/reload.rs index 0a0555b6..adfab89f 100644 --- a/crates/pbs/src/mev_boost/reload.rs +++ b/crates/pbs/src/mev_boost/reload.rs @@ -6,8 +6,8 @@ use crate::{BuilderApiState, PbsState}; /// Reload the PBS state with the latest configuration in the config file /// Returns 200 if successful or 500 if failed pub async fn reload(state: PbsState) -> eyre::Result> { - let pbs_config = load_pbs_config().await?; - let new_state = PbsState::new(pbs_config).with_data(state.data); + let (pbs_config, config_path) = load_pbs_config(None).await?; + let new_state = PbsState::new(pbs_config, config_path).with_data(state.data); if state.config.pbs_config.host != new_state.config.pbs_config.host { warn!( diff --git a/crates/pbs/src/service.rs b/crates/pbs/src/service.rs index 6659ae85..376a1fb7 100644 --- a/crates/pbs/src/service.rs +++ b/crates/pbs/src/service.rs @@ -5,13 +5,14 @@ use std::{ }; use cb_common::{ - config::{MuxKeysLoader, PbsModuleConfig}, + config::{MuxKeysLoader, PbsModuleConfig, load_pbs_config}, constants::{COMMIT_BOOST_COMMIT, COMMIT_BOOST_VERSION}, pbs::{BUILDER_V1_API_PATH, GET_STATUS_PATH}, types::Chain, }; use cb_metrics::provider::MetricsProvider; use eyre::{Context, Result, bail}; +use notify::{Error, Event, RecommendedWatcher, RecursiveMode, Watcher}; use parking_lot::RwLock; use prometheus::core::Collector; use tokio::net::TcpListener; @@ -40,6 +41,7 @@ impl PbsService { }) }); + let config_path = state.config_path.clone(); let state: Arc>> = RwLock::new(state).into(); let app = create_app_router::(state.clone()); let listener = TcpListener::bind(addr).await?; @@ -59,6 +61,47 @@ impl PbsService { bail!("PBS server failed to start. Are the relays properly configured?"); } + // Set up the filesystem watcher for the config file + let mut watcher: RecommendedWatcher; + if config_path.to_str() != Some("") { + let state_for_watcher = state.clone(); + let config_path_for_watcher = config_path.clone(); + watcher = RecommendedWatcher::new( + move |result: Result| { + match result { + Err(e) => { + warn!(%e, "error watching PBS config file for changes"); + return; + } + Ok(event) => { + if !event.kind.is_modify() { + return; + } + } + } + + // Reload the configuration when the file is modified + info!("detected change in PBS config file, reloading configuration"); + let result = futures::executor::block_on(load_pbs_config(Some( + config_path_for_watcher.to_path_buf(), + ))); + match result { + Ok((new_config, _)) => { + let mut state = state_for_watcher.write(); + state.config = Arc::new(new_config); + info!("configuration reloaded from file after update"); + } + Err(err) => { + warn!(%err, "failed to reload configuration from file after update"); + } + } + }, + notify::Config::default(), + )?; + watcher.watch(config_path.as_path(), RecursiveMode::Recursive)?; + info!("watching PBS config file for changes: {:?}", config_path); + } + // Run the registry refresher task if is_refreshing_required { let mut interval = tokio::time::interval(Duration::from_secs(registry_refresh_time)); diff --git a/crates/pbs/src/state.rs b/crates/pbs/src/state.rs index dd0e118e..bd683e5f 100644 --- a/crates/pbs/src/state.rs +++ b/crates/pbs/src/state.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::{path::PathBuf, sync::Arc}; use cb_common::{ config::{PbsConfig, PbsModuleConfig}, @@ -19,17 +19,19 @@ pub type PbsStateGuard = Arc>>; pub struct PbsState { /// Config data for the Pbs service pub config: Arc, + /// Path of the config file, for watching changes + pub config_path: Arc, /// Opaque extra data for library use pub data: S, } impl PbsState<()> { - pub fn new(config: PbsModuleConfig) -> Self { - Self { config: Arc::new(config), data: () } + pub fn new(config: PbsModuleConfig, config_path: PathBuf) -> Self { + Self { config: Arc::new(config), config_path: Arc::new(config_path), data: () } } pub fn with_data(self, data: S) -> PbsState { - PbsState { data, config: self.config } + PbsState { data, config: self.config, config_path: self.config_path } } } diff --git a/examples/status_api/src/main.rs b/examples/status_api/src/main.rs index 7ad9b533..3530c800 100644 --- a/examples/status_api/src/main.rs +++ b/examples/status_api/src/main.rs @@ -1,6 +1,9 @@ -use std::sync::{ - Arc, - atomic::{AtomicU64, Ordering}, +use std::{ + path::PathBuf, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, }; use async_trait::async_trait; @@ -70,7 +73,8 @@ impl BuilderApi for MyBuilderApi { let mut data = state.data.clone(); data.inc_amount = extra_config.inc_amount; - Ok(PbsState::new(pbs_config).with_data(data)) + let empty_config_path = PathBuf::new(); + Ok(PbsState::new(pbs_config, empty_config_path).with_data(data)) } fn extra_routes() -> Option>> { @@ -94,7 +98,8 @@ async fn main() -> Result<()> { let _guard = initialize_tracing_log(PBS_MODULE_NAME, LogsSettings::from_env_config()?)?; let custom_state = MyBuilderState::from_config(extra); - let state = PbsState::new(pbs_config).with_data(custom_state); + let empty_config_path = PathBuf::new(); + let state = PbsState::new(pbs_config, empty_config_path).with_data(custom_state); PbsService::register_metric(Box::new(CHECK_RECEIVED_COUNTER.clone())); PbsService::init_metrics(chain)?; diff --git a/tests/Cargo.toml b/tests/Cargo.toml index 5e8e1596..270fb9f6 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -16,6 +16,7 @@ reqwest.workspace = true serde_json.workspace = true tempfile.workspace = true tokio.workspace = true +toml.workspace = true tracing.workspace = true tracing-subscriber.workspace = true tracing-test.workspace = true diff --git a/tests/tests/pbs_cfg_file_update.rs b/tests/tests/pbs_cfg_file_update.rs new file mode 100644 index 00000000..0c4e8e47 --- /dev/null +++ b/tests/tests/pbs_cfg_file_update.rs @@ -0,0 +1,158 @@ +use std::{net::Ipv4Addr, sync::Arc, time::Duration}; + +use alloy::primitives::U256; +use cb_common::{ + config::{CommitBoostConfig, LogsSettings, PbsConfig, RelayConfig, StaticPbsConfig}, + pbs::RelayEntry, + signer::random_secret, + types::Chain, +}; +use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; +use cb_tests::{ + mock_relay::{MockRelayState, start_mock_relay_service}, + mock_validator::MockValidator, + utils::{generate_mock_relay, get_pbs_static_config, setup_test_env, to_pbs_config}, +}; +use eyre::Result; +use reqwest::StatusCode; +use tracing::info; +use url::Url; + +/// Updates the config file that was used to load the PBS config, and ensures +/// the filesystem watcher triggers a reload of the configuration. +#[tokio::test] +async fn test_cfg_file_update() -> Result<()> { + // Random keys needed for the relays to start + setup_test_env(); + let signer = random_secret(); + let pubkey = signer.public_key(); + + let chain = Chain::Hoodi; + let pbs_port = 3720; + + // Start relay 1 + let relay1_port = pbs_port + 1; + let relay1 = generate_mock_relay(relay1_port, pubkey.clone())?; + let relay1_state = Arc::new(MockRelayState::new(chain, signer.clone())); + tokio::spawn(start_mock_relay_service(relay1_state.clone(), relay1_port)); + + // Start relay 2 + let relay2_port = relay1_port + 1; + let relay2 = generate_mock_relay(relay2_port, pubkey.clone())?; + let relay2_id = relay2.id.clone().to_string(); + let relay2_state = Arc::new(MockRelayState::new(chain, signer)); + tokio::spawn(start_mock_relay_service(relay2_state.clone(), relay2_port)); + + // Make a config with relay 1 only + let pbs_config = PbsConfig { + // get_pbs_static_config(pbs_port); + host: Ipv4Addr::LOCALHOST, + port: pbs_port, + relay_check: false, + wait_all_registrations: false, + timeout_get_header_ms: 950, + timeout_get_payload_ms: 4000, + timeout_register_validator_ms: 3000, + skip_sigverify: true, + min_bid_wei: U256::ZERO, + late_in_slot_time_ms: u64::MAX / 2, /* serde gets very upset about serializing u64::MAX + * or anything close to it */ + extra_validation_enabled: false, + rpc_url: None, + ssv_api_url: Url::parse("http://example.com").unwrap(), + http_timeout_seconds: 10, + register_validator_retry_limit: 3, + validator_registration_batch_size: None, + mux_registry_refresh_interval_seconds: 384, + }; + let cb_config = CommitBoostConfig { + chain, + pbs: StaticPbsConfig { + docker_image: String::new(), + pbs_config: pbs_config.clone(), + with_signer: false, + }, + muxes: None, + modules: None, + signer: None, + logs: LogsSettings::default(), + metrics: None, + relays: vec![RelayConfig { + id: Some(relay1.id.to_string()), + enable_timing_games: false, + frequency_get_header_ms: None, + get_params: None, + headers: None, + target_first_request_ms: None, + validator_registration_batch_size: None, + entry: RelayEntry { + id: relay1.id.to_string(), + url: Url::parse(&format!("http://localhost:{relay1_port}"))?, + pubkey: pubkey.clone(), + }, + }], + }; + + // Save to a file + let temp_file = tempfile::NamedTempFile::new()?; + let config_path = temp_file.path().to_path_buf(); + let config_toml = toml::to_string_pretty(&cb_config)?; + info!("Writing initial config to {:?}", config_path); + std::fs::write(config_path.clone(), config_toml.as_bytes())?; + + // Run the PBS service + let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), vec![relay1.clone()]); + let state = PbsState::new(config, config_path.clone()); + tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); + + // leave some time to start servers - extra time for the file watcher + tokio::time::sleep(Duration::from_millis(1000)).await; + + // Send a get header request - should go to relay 1 only + let mock_validator = MockValidator::new(pbs_port)?; + info!("Sending get header"); + let res = mock_validator.do_get_header(None).await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(relay1_state.received_get_header(), 1); + assert_eq!(relay2_state.received_get_header(), 0); + + // Update the config to only have relay 2 + let cb_config = CommitBoostConfig { + chain, + pbs: StaticPbsConfig { docker_image: String::new(), pbs_config, with_signer: false }, + muxes: None, + modules: None, + signer: None, + logs: LogsSettings::default(), + metrics: None, + relays: vec![RelayConfig { + id: Some(relay2_id.clone()), + enable_timing_games: false, + frequency_get_header_ms: None, + get_params: None, + headers: None, + target_first_request_ms: None, + validator_registration_batch_size: None, + entry: RelayEntry { + id: relay2_id, + url: Url::parse(&format!("http://{pubkey}@localhost:{relay2_port}"))?, + pubkey, + }, + }], + }; + let config_toml = toml::to_string_pretty(&cb_config)?; + info!("Writing updated config to {:?}", config_path); + std::fs::write(config_path, config_toml.as_bytes())?; + + // leave some time for the watcher to pick up the change and reload + tokio::time::sleep(Duration::from_millis(1000)).await; + + // Send another get header request - should go to relay 2 only + info!("Sending get header after config update"); + let res = mock_validator.do_get_header(None).await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(relay1_state.received_get_header(), 1); // no change + assert_eq!(relay2_state.received_get_header(), 1); // incremented + + Ok(()) +} diff --git a/tests/tests/pbs_get_header.rs b/tests/tests/pbs_get_header.rs index d44d70ce..5ae4b656 100644 --- a/tests/tests/pbs_get_header.rs +++ b/tests/tests/pbs_get_header.rs @@ -1,4 +1,4 @@ -use std::{sync::Arc, time::Duration}; +use std::{path::PathBuf, sync::Arc, time::Duration}; use alloy::primitives::{B256, U256}; use cb_common::{ @@ -37,7 +37,7 @@ async fn test_get_header() -> Result<()> { // Run the PBS service let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), vec![mock_relay.clone()]); - let state = PbsState::new(config); + let state = PbsState::new(config, PathBuf::new()); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); // leave some time to start servers @@ -83,7 +83,7 @@ async fn test_get_header_returns_204_if_relay_down() -> Result<()> { // Run the PBS service let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), vec![mock_relay.clone()]); - let state = PbsState::new(config); + let state = PbsState::new(config, PathBuf::new()); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); // leave some time to start servers @@ -115,7 +115,7 @@ async fn test_get_header_returns_400_if_request_is_invalid() -> Result<()> { // Run the PBS service let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), vec![mock_relay.clone()]); - let state = PbsState::new(config); + let state = PbsState::new(config, PathBuf::new()); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); // leave some time to start servers diff --git a/tests/tests/pbs_get_status.rs b/tests/tests/pbs_get_status.rs index 0ca09bf5..9dc8615f 100644 --- a/tests/tests/pbs_get_status.rs +++ b/tests/tests/pbs_get_status.rs @@ -1,4 +1,4 @@ -use std::{sync::Arc, time::Duration}; +use std::{path::PathBuf, sync::Arc, time::Duration}; use cb_common::{signer::random_secret, types::Chain}; use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; @@ -31,7 +31,7 @@ async fn test_get_status() -> Result<()> { tokio::spawn(start_mock_relay_service(mock_state.clone(), relay_1_port)); let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), relays.clone()); - let state = PbsState::new(config); + let state = PbsState::new(config, PathBuf::new()); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); // leave some time to start servers @@ -64,7 +64,7 @@ async fn test_get_status_returns_502_if_relay_down() -> Result<()> { // tokio::spawn(start_mock_relay_service(mock_state.clone(), relay_port)); let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), relays.clone()); - let state = PbsState::new(config); + let state = PbsState::new(config, PathBuf::new()); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); // leave some time to start servers diff --git a/tests/tests/pbs_mux.rs b/tests/tests/pbs_mux.rs index 3a15b49b..b5fd14dd 100644 --- a/tests/tests/pbs_mux.rs +++ b/tests/tests/pbs_mux.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, sync::Arc, time::Duration}; +use std::{collections::HashMap, path::PathBuf, sync::Arc, time::Duration}; use cb_common::{ config::{HTTP_TIMEOUT_SECONDS_DEFAULT, MUXER_HTTP_MAX_LENGTH, RuntimeMuxConfig}, @@ -186,7 +186,7 @@ async fn test_mux() -> Result<()> { config.mux_lookup = Some(HashMap::from([(validator_pubkey.clone(), mux)])); // Run PBS service - let state = PbsState::new(config); + let state = PbsState::new(config, PathBuf::new()); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); // leave some time to start servers diff --git a/tests/tests/pbs_mux_refresh.rs b/tests/tests/pbs_mux_refresh.rs index da582ec7..e1a5a8fe 100644 --- a/tests/tests/pbs_mux_refresh.rs +++ b/tests/tests/pbs_mux_refresh.rs @@ -1,4 +1,4 @@ -use std::{sync::Arc, time::Duration}; +use std::{path::PathBuf, sync::Arc, time::Duration}; use cb_common::{ config::{MuxConfig, MuxKeysLoader, PbsMuxes}, @@ -98,7 +98,7 @@ async fn test_auto_refresh() -> Result<()> { config.registry_muxes = Some(registry_muxes); // Run PBS service - let state = PbsState::new(config); + let state = PbsState::new(config, PathBuf::new()); let pbs_server = tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); info!("Started PBS server with pubkey {default_pubkey}"); diff --git a/tests/tests/pbs_post_blinded_blocks.rs b/tests/tests/pbs_post_blinded_blocks.rs index 9c70b562..b5854829 100644 --- a/tests/tests/pbs_post_blinded_blocks.rs +++ b/tests/tests/pbs_post_blinded_blocks.rs @@ -1,4 +1,4 @@ -use std::{sync::Arc, time::Duration}; +use std::{path::PathBuf, sync::Arc, time::Duration}; use cb_common::{ pbs::{BuilderApiVersion, GetPayloadInfo, SubmitBlindedBlockResponse}, @@ -71,7 +71,7 @@ async fn test_submit_block_too_large() -> Result<()> { tokio::spawn(start_mock_relay_service(mock_state.clone(), pbs_port + 1)); let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), relays); - let state = PbsState::new(config); + let state = PbsState::new(config, PathBuf::new()); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); // leave some time to start servers @@ -113,7 +113,7 @@ async fn submit_block_impl( // Run the PBS service let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), relays); - let state = PbsState::new(config); + let state = PbsState::new(config, PathBuf::new()); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); // leave some time to start servers diff --git a/tests/tests/pbs_post_validators.rs b/tests/tests/pbs_post_validators.rs index 35e6c5be..ef2ac40b 100644 --- a/tests/tests/pbs_post_validators.rs +++ b/tests/tests/pbs_post_validators.rs @@ -1,4 +1,4 @@ -use std::{sync::Arc, time::Duration}; +use std::{path::PathBuf, sync::Arc, time::Duration}; use alloy::rpc::types::beacon::relay::ValidatorRegistration; use cb_common::{ @@ -31,7 +31,7 @@ async fn test_register_validators() -> Result<()> { // Run the PBS service let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), relays); - let state = PbsState::new(config); + let state = PbsState::new(config, PathBuf::new()); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); // leave some time to start servers @@ -80,7 +80,7 @@ async fn test_register_validators_does_not_retry_on_429() -> Result<()> { // Run the PBS service let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), relays); - let state = PbsState::new(config); + let state = PbsState::new(config, PathBuf::new()); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state.clone())); // Leave some time to start servers @@ -135,7 +135,7 @@ async fn test_register_validators_retries_on_500() -> Result<()> { pbs_config.register_validator_retry_limit = 3; let config = to_pbs_config(chain, pbs_config, relays); - let state = PbsState::new(config); + let state = PbsState::new(config, PathBuf::new()); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state.clone())); tokio::time::sleep(Duration::from_millis(100)).await; From d6bdaec1d222b092d44855b7a2ceb2c715a91ef0 Mon Sep 17 00:00:00 2001 From: iurii-ssv <183610124+iurii-ssv@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:45:40 +0200 Subject: [PATCH 02/11] ssv-network: SSV-node API support (#415) Co-authored-by: Joe Clapis --- config.example.toml | 10 +- crates/common/src/config/mux.rs | 75 ++++- crates/common/src/config/pbs.rs | 18 +- crates/common/src/interop/ssv/types.rs | 61 +++- crates/common/src/interop/ssv/utils.rs | 40 ++- crates/pbs/src/service.rs | 3 +- tests/Cargo.toml | 1 + tests/data/ssv_valid_node.json | 22 ++ .../{ssv_valid.json => ssv_valid_public.json} | 0 tests/src/lib.rs | 3 +- tests/src/mock_ssv_node.rs | 118 ++++++++ tests/src/{mock_ssv.rs => mock_ssv_public.rs} | 23 +- tests/src/utils.rs | 3 +- tests/tests/pbs_mux.rs | 266 +++++++++++++++++- tests/tests/pbs_mux_refresh.rs | 14 +- 15 files changed, 586 insertions(+), 71 deletions(-) create mode 100644 tests/data/ssv_valid_node.json rename tests/data/{ssv_valid.json => ssv_valid_public.json} (100%) create mode 100644 tests/src/mock_ssv_node.rs rename tests/src/{mock_ssv.rs => mock_ssv_public.rs} (83%) diff --git a/config.example.toml b/config.example.toml index e85b98e1..08fe8a0b 100644 --- a/config.example.toml +++ b/config.example.toml @@ -55,9 +55,13 @@ extra_validation_enabled = false # Execution Layer RPC url to use for extra validation # OPTIONAL # rpc_url = "https://ethereum-holesky-rpc.publicnode.com" -# URL of the SSV API server to use, if you have a mux that targets an SSV node operator -# OPTIONAL, DEFAULT: "https://api.ssv.network/api/v4" -# ssv_api_url = "https://api.ssv.network/api/v4" +# URL of your local SSV node API endpoint, if you have a mux that targets an SSV node operator +# OPTIONAL, DEFAULT: "http://localhost:16000/v1/" +# ssv_node_api_url = "http://localhost:16000/v1/" +# URL of the public SSV API server, if you have a mux that targets an SSV node operator. This is used as +# a fallback if the user's own SSV node is not reachable. +# OPTIONAL, DEFAULT: "https://api.ssv.network/api/v4/" +# ssv_public_api_url = "https://api.ssv.network/api/v4/" # Timeout for any HTTP requests sent from the PBS module to other services, in seconds # OPTIONAL, DEFAULT: 10 http_timeout_seconds = 10 diff --git a/crates/common/src/config/mux.rs b/crates/common/src/config/mux.rs index b5436ae2..8a9dab73 100644 --- a/crates/common/src/config/mux.rs +++ b/crates/common/src/config/mux.rs @@ -62,7 +62,8 @@ impl PbsMuxes { .load( &mux.id, chain, - default_pbs.ssv_api_url.clone(), + default_pbs.ssv_node_api_url.clone(), + default_pbs.ssv_public_api_url.clone(), default_pbs.rpc_url.clone(), http_timeout, ) @@ -212,7 +213,8 @@ impl MuxKeysLoader { &self, mux_id: &str, chain: Chain, - ssv_api_url: Url, + ssv_node_api_url: Url, + ssv_public_api_url: Url, rpc_url: Option, http_timeout: Duration, ) -> eyre::Result> { @@ -258,7 +260,8 @@ impl MuxKeysLoader { } NORegistry::SSV => { fetch_ssv_pubkeys( - ssv_api_url, + ssv_node_api_url, + ssv_public_api_url, chain, U256::from(*node_operator_id), http_timeout, @@ -391,11 +394,62 @@ async fn fetch_lido_registry_keys( } async fn fetch_ssv_pubkeys( - mut api_url: Url, + node_url: Url, + public_url: Url, chain: Chain, node_operator_id: U256, http_timeout: Duration, ) -> eyre::Result> { + // Try the node API first + match fetch_ssv_pubkeys_from_ssv_node(node_url.clone(), node_operator_id, http_timeout).await { + Ok(pubkeys) => Ok(pubkeys), + Err(e) => { + // Fall back to public API + warn!( + "failed to fetch pubkeys from SSV node API at {node_url}: {e}; falling back to public API", + ); + fetch_ssv_pubkeys_from_public_api(public_url, chain, node_operator_id, http_timeout) + .await + } + } +} + +/// Ensures that the SSV API URL has a trailing slash +fn ensure_ssv_api_url(url: &mut Url) -> eyre::Result<()> { + // Validate the URL - this appends a trailing slash if missing as efficiently as + // possible + if !url.path().ends_with('/') { + match url.path_segments_mut() { + Ok(mut segments) => segments.push(""), // Analogous to a trailing slash + Err(_) => bail!("SSV API URL is not a valid base URL"), + }; + } + Ok(()) +} + +/// Fetches SSV pubkeys from the user's SSV node +async fn fetch_ssv_pubkeys_from_ssv_node( + mut url: Url, + node_operator_id: U256, + http_timeout: Duration, +) -> eyre::Result> { + ensure_ssv_api_url(&mut url)?; + let route = "validators"; + let url = url.join(route).wrap_err("failed to construct SSV API URL")?; + + let response = request_ssv_pubkeys_from_ssv_node(url, node_operator_id, http_timeout).await?; + let pubkeys = response.data.into_iter().map(|v| v.public_key).collect::>(); + Ok(pubkeys) +} + +/// Fetches SSV pubkeys from the public SSV network API with pagination +async fn fetch_ssv_pubkeys_from_public_api( + mut url: Url, + chain: Chain, + node_operator_id: U256, + http_timeout: Duration, +) -> eyre::Result> { + ensure_ssv_api_url(&mut url)?; const MAX_PER_PAGE: usize = 100; let chain_name = match chain { @@ -409,22 +463,13 @@ async fn fetch_ssv_pubkeys( let mut page = 1; let mut expected_total: Option = None; - // Validate the URL - this appends a trailing slash if missing as efficiently as - // possible - if !api_url.path().ends_with('/') { - match api_url.path_segments_mut() { - Ok(mut segments) => segments.push(""), // Analogous to a trailing slash - Err(_) => bail!("SSV API URL is not a valid base URL"), - }; - } - loop { let route = format!( "{chain_name}/validators/in_operator/{node_operator_id}?perPage={MAX_PER_PAGE}&page={page}", ); - let url = api_url.join(&route).wrap_err("failed to construct SSV API URL")?; + let url = url.join(&route).wrap_err("failed to construct SSV API URL")?; - let response = fetch_ssv_pubkeys_from_url(url, http_timeout).await?; + let response = request_ssv_pubkeys_from_public_api(url, http_timeout).await?; let fetched = response.validators.len(); if expected_total.is_none() && fetched > 0 { expected_total = Some(response.pagination.total); diff --git a/crates/common/src/config/pbs.rs b/crates/common/src/config/pbs.rs index f24e75c4..759b478f 100644 --- a/crates/common/src/config/pbs.rs +++ b/crates/common/src/config/pbs.rs @@ -124,9 +124,12 @@ pub struct PbsConfig { pub extra_validation_enabled: bool, /// Execution Layer RPC url to use for extra validation pub rpc_url: Option, - /// URL for the SSV network API - #[serde(default = "default_ssv_api_url")] - pub ssv_api_url: Url, + /// URL for the user's own SSV node API endpoint + #[serde(default = "default_ssv_node_api_url")] + pub ssv_node_api_url: Url, + /// URL for the public SSV network API server + #[serde(default = "default_public_ssv_api_url")] + pub ssv_public_api_url: Url, /// Timeout for HTTP requests in seconds #[serde(default = "default_u64::")] pub http_timeout_seconds: u64, @@ -409,7 +412,12 @@ pub async fn load_pbs_custom_config() -> Result<(PbsModuleC )) } -/// Default URL for the SSV network API -fn default_ssv_api_url() -> Url { +/// Default URL for the user's SSV node API endpoint (/v1/validators). +fn default_ssv_node_api_url() -> Url { + Url::parse("http://localhost:16000/v1/").expect("default URL is valid") +} + +/// Default URL for the public SSV network API. +fn default_public_ssv_api_url() -> Url { Url::parse("https://api.ssv.network/api/v4/").expect("default URL is valid") } diff --git a/crates/common/src/interop/ssv/types.rs b/crates/common/src/interop/ssv/types.rs index b8ac2e23..0a133393 100644 --- a/crates/common/src/interop/ssv/types.rs +++ b/crates/common/src/interop/ssv/types.rs @@ -2,11 +2,60 @@ use serde::{Deserialize, Deserializer, Serialize}; use crate::types::BlsPublicKey; -/// Response from the SSV API for validators +/// Response from the SSV API for validators (the new way, relies on using SSV +/// node API) #[derive(Deserialize, Serialize)] -pub struct SSVResponse { +pub struct SSVNodeResponse { /// List of validators returned by the SSV API - pub validators: Vec, + pub data: Vec, +} + +/// Representation of a validator in the SSV API +#[derive(Clone)] +pub struct SSVNodeValidator { + /// The public key of the validator + pub public_key: BlsPublicKey, +} + +impl<'de> Deserialize<'de> for SSVNodeValidator { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct SSVValidator { + public_key: String, + } + + let s = SSVValidator::deserialize(deserializer)?; + let bytes = alloy::hex::decode(&s.public_key).map_err(serde::de::Error::custom)?; + let pubkey = BlsPublicKey::deserialize(&bytes) + .map_err(|e| serde::de::Error::custom(format!("invalid BLS public key: {e:?}")))?; + + Ok(Self { public_key: pubkey }) + } +} + +impl Serialize for SSVNodeValidator { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + #[derive(Serialize)] + struct SSVValidator { + public_key: String, + } + + let s = SSVValidator { public_key: self.public_key.as_hex_string() }; + s.serialize(serializer) + } +} + +/// Response from the SSV API for validators from the public api.ssv.network URL +#[derive(Deserialize, Serialize)] +pub struct SSVPublicResponse { + /// List of validators returned by the SSV API + pub validators: Vec, /// Pagination information pub pagination: SSVPagination, @@ -14,12 +63,12 @@ pub struct SSVResponse { /// Representation of a validator in the SSV API #[derive(Clone)] -pub struct SSVValidator { +pub struct SSVPublicValidator { /// The public key of the validator pub pubkey: BlsPublicKey, } -impl<'de> Deserialize<'de> for SSVValidator { +impl<'de> Deserialize<'de> for SSVPublicValidator { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, @@ -38,7 +87,7 @@ impl<'de> Deserialize<'de> for SSVValidator { } } -impl Serialize for SSVValidator { +impl Serialize for SSVPublicValidator { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, diff --git a/crates/common/src/interop/ssv/utils.rs b/crates/common/src/interop/ssv/utils.rs index e443e018..4a262d69 100644 --- a/crates/common/src/interop/ssv/utils.rs +++ b/crates/common/src/interop/ssv/utils.rs @@ -1,24 +1,52 @@ use std::time::Duration; +use alloy::primitives::U256; use eyre::Context; +use serde_json::json; use url::Url; -use crate::{config::safe_read_http_response, interop::ssv::types::SSVResponse}; +use crate::{ + config::safe_read_http_response, + interop::ssv::types::{SSVNodeResponse, SSVPublicResponse}, +}; -pub async fn fetch_ssv_pubkeys_from_url( +pub async fn request_ssv_pubkeys_from_ssv_node( url: Url, + node_operator_id: U256, http_timeout: Duration, -) -> eyre::Result { +) -> eyre::Result { + let client = reqwest::ClientBuilder::new().timeout(http_timeout).build()?; + let body = json!({ + "operators": [node_operator_id] + }); + let response = client.get(url).json(&body).send().await.map_err(|e| { + if e.is_timeout() { + eyre::eyre!("Request to SSV node timed out: {e}") + } else { + eyre::eyre!("Error sending request to SSV node: {e}") + } + })?; + + // Parse the response as JSON + let body_bytes = safe_read_http_response(response).await?; + serde_json::from_slice::(&body_bytes).wrap_err("failed to parse SSV response") +} + +pub async fn request_ssv_pubkeys_from_public_api( + url: Url, + http_timeout: Duration, +) -> eyre::Result { let client = reqwest::ClientBuilder::new().timeout(http_timeout).build()?; let response = client.get(url).send().await.map_err(|e| { if e.is_timeout() { - eyre::eyre!("Request to SSV network API timed out: {e}") + eyre::eyre!("Request to SSV public API timed out: {e}") } else { - eyre::eyre!("Error sending request to SSV network API: {e}") + eyre::eyre!("Error sending request to SSV public API: {e}") } })?; // Parse the response as JSON let body_bytes = safe_read_http_response(response).await?; - serde_json::from_slice::(&body_bytes).wrap_err("failed to parse SSV response") + serde_json::from_slice::(&body_bytes) + .wrap_err("failed to parse SSV response") } diff --git a/crates/pbs/src/service.rs b/crates/pbs/src/service.rs index 376a1fb7..8be422ca 100644 --- a/crates/pbs/src/service.rs +++ b/crates/pbs/src/service.rs @@ -160,7 +160,8 @@ impl PbsService { .load( &runtime_config.id, config.chain, - default_pbs.ssv_api_url.clone(), + default_pbs.ssv_node_api_url.clone(), + default_pbs.ssv_public_api_url.clone(), default_pbs.rpc_url.clone(), http_timeout, ) diff --git a/tests/Cargo.toml b/tests/Cargo.toml index 270fb9f6..cceba3bc 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -13,6 +13,7 @@ cb-signer.workspace = true eyre.workspace = true lh_types.workspace = true reqwest.workspace = true +serde.workspace = true serde_json.workspace = true tempfile.workspace = true tokio.workspace = true diff --git a/tests/data/ssv_valid_node.json b/tests/data/ssv_valid_node.json new file mode 100644 index 00000000..59ad205f --- /dev/null +++ b/tests/data/ssv_valid_node.json @@ -0,0 +1,22 @@ +{ + "data": [ + { + "public_key": "aa370f6250d421d00437b9900407a7ad93b041aeb7259d99b55ab8b163277746680e93e841f87350737bceee46aa104d", + "index": "1311498", + "status": "active_ongoing", + "activation_epoch": "273156", + "exit_epoch": "18446744073709551615", + "owner": "5e33db0b37622f7e6b2f0654aa7b985d854ea9cb", + "committee": [ + 1, + 2, + 3, + 4 + ], + "quorum": 0, + "partial_quorum": 0, + "graffiti": "", + "liquidated": false + } + ] +} \ No newline at end of file diff --git a/tests/data/ssv_valid.json b/tests/data/ssv_valid_public.json similarity index 100% rename from tests/data/ssv_valid.json rename to tests/data/ssv_valid_public.json diff --git a/tests/src/lib.rs b/tests/src/lib.rs index 42e36a8e..d332711f 100644 --- a/tests/src/lib.rs +++ b/tests/src/lib.rs @@ -1,4 +1,5 @@ pub mod mock_relay; -pub mod mock_ssv; +pub mod mock_ssv_node; +pub mod mock_ssv_public; pub mod mock_validator; pub mod utils; diff --git a/tests/src/mock_ssv_node.rs b/tests/src/mock_ssv_node.rs new file mode 100644 index 00000000..7f24569d --- /dev/null +++ b/tests/src/mock_ssv_node.rs @@ -0,0 +1,118 @@ +use std::{net::SocketAddr, sync::Arc}; + +use alloy::primitives::U256; +use axum::{ + extract::{Json, State}, + response::Response, + routing::get, +}; +use cb_common::{ + config::MUXER_HTTP_MAX_LENGTH, + interop::ssv::types::{SSVNodeResponse, SSVNodeValidator}, +}; +use serde::Deserialize; +use tokio::{net::TcpListener, sync::RwLock, task::JoinHandle}; +use tracing::info; + +pub const TEST_HTTP_TIMEOUT: u64 = 2; + +/// State for the mock server +#[derive(Clone)] +pub struct SsvNodeMockState { + /// List of pubkeys for the mock server to return + pub validators: Arc>>, + + /// Whether to force a timeout response to simulate a server error + pub force_timeout: Arc>, +} + +#[derive(Deserialize)] +struct SsvNodeValidatorsRequestBody { + pub operators: Vec, +} + +/// Creates a simple mock server to simulate the SSV API endpoint under +/// various conditions for testing. Note this ignores +pub async fn create_mock_ssv_node_server( + port: u16, + state: Option, +) -> Result, axum::Error> { + let data = include_str!("../../tests/data/ssv_valid_node.json"); + let response = + serde_json::from_str::(data).expect("failed to parse test data"); + let state = state.unwrap_or(SsvNodeMockState { + validators: Arc::new(RwLock::new(response.data)), + force_timeout: Arc::new(RwLock::new(false)), + }); + let router = axum::Router::new() + .route("/v1/validators", get(handle_validators)) + .route("/big_data", get(handle_big_data)) + .with_state(state) + .into_make_service(); + + let address = SocketAddr::from(([127, 0, 0, 1], port)); + let listener = TcpListener::bind(address).await.map_err(axum::Error::new)?; + let server = axum::serve(listener, router).with_graceful_shutdown(async { + tokio::signal::ctrl_c().await.expect("Failed to listen for shutdown signal"); + }); + let result = Ok(tokio::spawn(async move { + if let Err(e) = server.await { + eprintln!("Server error: {e}"); + } + })); + info!("Mock server started on http://localhost:{port}/"); + result +} + +/// Returns a valid SSV validators response, or a timeout if requested in +/// the server state +async fn handle_validators( + State(state): State, + Json(body): Json, +) -> Response { + // Time out if requested + if *state.force_timeout.read().await { + return handle_timeout().await; + } + + // Make sure the request deserialized properly + let _operators = body.operators; + + // Generate the response based on the current validators + let response: SSVNodeResponse; + { + let validators = state.validators.read().await; + response = SSVNodeResponse { data: validators.clone() }; + } + + // Create a valid response + Response::builder() + .status(200) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&response).unwrap().into()) + .unwrap() +} + +/// Sends a response with a large body - larger than the maximum allowed. +/// Note that hyper overwrites the content-length header automatically, so +/// setting it here wouldn't actually change the value that ultimately +/// gets sent to the server. +async fn handle_big_data() -> Response { + let body = "f".repeat(2 * MUXER_HTTP_MAX_LENGTH); + Response::builder() + .status(200) + .header("Content-Type", "application/text") + .body(body.into()) + .unwrap() +} + +/// Simulates a timeout by sleeping for a long time +async fn handle_timeout() -> Response { + // Sleep for a long time to simulate a timeout + tokio::time::sleep(std::time::Duration::from_secs(2 * TEST_HTTP_TIMEOUT)).await; + Response::builder() + .status(200) + .header("Content-Type", "application/text") + .body("Timeout response".into()) + .unwrap() +} diff --git a/tests/src/mock_ssv.rs b/tests/src/mock_ssv_public.rs similarity index 83% rename from tests/src/mock_ssv.rs rename to tests/src/mock_ssv_public.rs index 7ed8eb23..a014db42 100644 --- a/tests/src/mock_ssv.rs +++ b/tests/src/mock_ssv_public.rs @@ -7,7 +7,7 @@ use axum::{ }; use cb_common::{ config::MUXER_HTTP_MAX_LENGTH, - interop::ssv::types::{SSVPagination, SSVResponse, SSVValidator}, + interop::ssv::types::{SSVPagination, SSVPublicResponse, SSVPublicValidator}, }; use tokio::{net::TcpListener, sync::RwLock, task::JoinHandle}; use tracing::info; @@ -16,9 +16,9 @@ pub const TEST_HTTP_TIMEOUT: u64 = 2; /// State for the mock server #[derive(Clone)] -pub struct SsvMockState { +pub struct PublicSsvMockState { /// List of pubkeys for the mock server to return - pub validators: Arc>>, + pub validators: Arc>>, /// Whether to force a timeout response to simulate a server error pub force_timeout: Arc>, @@ -26,13 +26,14 @@ pub struct SsvMockState { /// Creates a simple mock server to simulate the SSV API endpoint under /// various conditions for testing. Note this ignores -pub async fn create_mock_ssv_server( +pub async fn create_mock_public_ssv_server( port: u16, - state: Option, + state: Option, ) -> Result, axum::Error> { - let data = include_str!("../../tests/data/ssv_valid.json"); - let response = serde_json::from_str::(data).expect("failed to parse test data"); - let state = state.unwrap_or(SsvMockState { + let data = include_str!("../../tests/data/ssv_valid_public.json"); + let response = + serde_json::from_str::(data).expect("failed to parse test data"); + let state = state.unwrap_or(PublicSsvMockState { validators: Arc::new(RwLock::new(response.validators)), force_timeout: Arc::new(RwLock::new(false)), }); @@ -62,7 +63,7 @@ pub async fn create_mock_ssv_server( /// Returns a valid SSV validators response, or a timeout if requested in /// the server state async fn handle_validators( - State(state): State, + State(state): State, Path((_, _)): Path<(String, u64)>, ) -> Response { // Time out if requested @@ -71,11 +72,11 @@ async fn handle_validators( } // Generate the response based on the current validators - let response: SSVResponse; + let response: SSVPublicResponse; { let validators = state.validators.read().await; let pagination = SSVPagination { total: validators.len() }; - response = SSVResponse { validators: validators.clone(), pagination }; + response = SSVPublicResponse { validators: validators.clone(), pagination }; } // Create a valid response diff --git a/tests/src/utils.rs b/tests/src/utils.rs index 58ef42cf..253007c7 100644 --- a/tests/src/utils.rs +++ b/tests/src/utils.rs @@ -79,7 +79,8 @@ pub fn get_pbs_static_config(port: u16) -> PbsConfig { min_bid_wei: U256::ZERO, late_in_slot_time_ms: u64::MAX, extra_validation_enabled: false, - ssv_api_url: Url::parse("https://example.net").unwrap(), + ssv_node_api_url: Url::parse("http://localhost:0").unwrap(), + ssv_public_api_url: Url::parse("http://localhost:0").unwrap(), rpc_url: None, http_timeout_seconds: 10, register_validator_retry_limit: u32::MAX, diff --git a/tests/tests/pbs_mux.rs b/tests/tests/pbs_mux.rs index b5fd14dd..85c84a55 100644 --- a/tests/tests/pbs_mux.rs +++ b/tests/tests/pbs_mux.rs @@ -1,8 +1,15 @@ use std::{collections::HashMap, path::PathBuf, sync::Arc, time::Duration}; +use alloy::primitives::U256; use cb_common::{ - config::{HTTP_TIMEOUT_SECONDS_DEFAULT, MUXER_HTTP_MAX_LENGTH, RuntimeMuxConfig}, - interop::ssv::utils::fetch_ssv_pubkeys_from_url, + config::{ + HTTP_TIMEOUT_SECONDS_DEFAULT, MUXER_HTTP_MAX_LENGTH, MuxConfig, MuxKeysLoader, PbsMuxes, + RuntimeMuxConfig, + }, + interop::ssv::{ + types::{SSVNodeValidator, SSVPublicValidator}, + utils::{request_ssv_pubkeys_from_public_api, request_ssv_pubkeys_from_ssv_node}, + }, signer::random_secret, types::Chain, utils::{ResponseReadError, set_ignore_content_length}, @@ -10,7 +17,8 @@ use cb_common::{ use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; use cb_tests::{ mock_relay::{MockRelayState, start_mock_relay_service}, - mock_ssv::{SsvMockState, TEST_HTTP_TIMEOUT, create_mock_ssv_server}, + mock_ssv_node::{SsvNodeMockState, create_mock_ssv_node_server}, + mock_ssv_public::{PublicSsvMockState, TEST_HTTP_TIMEOUT, create_mock_public_ssv_server}, mock_validator::MockValidator, utils::{ bls_pubkey_from_hex_unchecked, generate_mock_relay, get_pbs_static_config, setup_test_env, @@ -25,18 +33,20 @@ use url::Url; #[tokio::test] /// Tests that a successful SSV network fetch is handled and parsed properly -async fn test_ssv_network_fetch() -> Result<()> { +/// from the public API +async fn test_ssv_public_network_fetch() -> Result<()> { // Start the mock server let port = 30100; - let _server_handle = create_mock_ssv_server(port, None).await?; + let server_handle = create_mock_public_ssv_server(port, None).await?; let url = Url::parse(&format!("http://localhost:{port}/api/v4/test_chain/validators/in_operator/1")) .unwrap(); let response = - fetch_ssv_pubkeys_from_url(url, Duration::from_secs(HTTP_TIMEOUT_SECONDS_DEFAULT)).await?; + request_ssv_pubkeys_from_public_api(url, Duration::from_secs(HTTP_TIMEOUT_SECONDS_DEFAULT)) + .await?; // Make sure the response is correct - // NOTE: requires that ssv_data.json dpesn't change + // NOTE: requires that ssv_valid_public.json doesn't change assert_eq!(response.validators.len(), 3); let expected_pubkeys = [ bls_pubkey_from_hex_unchecked( @@ -54,7 +64,7 @@ async fn test_ssv_network_fetch() -> Result<()> { } // Clean up the server handle - _server_handle.abort(); + server_handle.abort(); Ok(()) } @@ -65,9 +75,10 @@ async fn test_ssv_network_fetch() -> Result<()> { async fn test_ssv_network_fetch_big_data() -> Result<()> { // Start the mock server let port = 30101; - let server_handle = cb_tests::mock_ssv::create_mock_ssv_server(port, None).await?; + let server_handle = + cb_tests::mock_ssv_public::create_mock_public_ssv_server(port, None).await?; let url = Url::parse(&format!("http://localhost:{port}/big_data")).unwrap(); - let response = fetch_ssv_pubkeys_from_url(url, Duration::from_secs(120)).await; + let response = request_ssv_pubkeys_from_public_api(url, Duration::from_secs(120)).await; // The response should fail due to content length being too big match response { @@ -96,15 +107,16 @@ async fn test_ssv_network_fetch_big_data() -> Result<()> { async fn test_ssv_network_fetch_timeout() -> Result<()> { // Start the mock server let port = 30102; - let state = SsvMockState { + let state = PublicSsvMockState { validators: Arc::new(RwLock::new(vec![])), force_timeout: Arc::new(RwLock::new(true)), }; - let server_handle = create_mock_ssv_server(port, Some(state)).await?; + let server_handle = create_mock_public_ssv_server(port, Some(state)).await?; let url = Url::parse(&format!("http://localhost:{port}/api/v4/test_chain/validators/in_operator/1")) .unwrap(); - let response = fetch_ssv_pubkeys_from_url(url, Duration::from_secs(TEST_HTTP_TIMEOUT)).await; + let response = + request_ssv_pubkeys_from_public_api(url, Duration::from_secs(TEST_HTTP_TIMEOUT)).await; // The response should fail due to timeout assert!(response.is_err(), "Expected timeout error, but got success"); @@ -125,9 +137,9 @@ async fn test_ssv_network_fetch_big_data_without_content_length() -> Result<()> // Start the mock server let port = 30103; set_ignore_content_length(true); - let server_handle = create_mock_ssv_server(port, None).await?; + let server_handle = create_mock_public_ssv_server(port, None).await?; let url = Url::parse(&format!("http://localhost:{port}/big_data")).unwrap(); - let response = fetch_ssv_pubkeys_from_url(url, Duration::from_secs(120)).await; + let response = request_ssv_pubkeys_from_public_api(url, Duration::from_secs(120)).await; // The response should fail due to the body being too big match response { @@ -150,6 +162,37 @@ async fn test_ssv_network_fetch_big_data_without_content_length() -> Result<()> Ok(()) } +#[tokio::test] +/// Tests that a successful SSV network fetch is handled and parsed properly +/// from the node API +async fn test_ssv_node_network_fetch() -> Result<()> { + // Start the mock server + let port = 30104; + let _server_handle = create_mock_ssv_node_server(port, None).await?; + let url = Url::parse(&format!("http://localhost:{port}/v1/validators")).unwrap(); + let response = request_ssv_pubkeys_from_ssv_node( + url, + U256::from(1), + Duration::from_secs(HTTP_TIMEOUT_SECONDS_DEFAULT), + ) + .await?; + + // Make sure the response is correct + // NOTE: requires that ssv_valid_node.json doesn't change + assert_eq!(response.data.len(), 1); + let expected_pubkeys = [bls_pubkey_from_hex_unchecked( + "aa370f6250d421d00437b9900407a7ad93b041aeb7259d99b55ab8b163277746680e93e841f87350737bceee46aa104d", + )]; + for (i, validator) in response.data.iter().enumerate() { + assert_eq!(validator.public_key, expected_pubkeys[i]); + } + + // Clean up the server handle + _server_handle.abort(); + + Ok(()) +} + #[tokio::test] async fn test_mux() -> Result<()> { setup_test_env(); @@ -228,3 +271,196 @@ async fn test_mux() -> Result<()> { Ok(()) } + +/// Tests the SSV mux with dynamic registry fetching from an SSV node +#[tokio::test] +async fn test_ssv_multi_with_node() -> Result<()> { + // Generate keys + let signer = random_secret(); + let pubkey = signer.public_key(); + let signer2 = random_secret(); + let pubkey2 = signer2.public_key(); + + let chain = Chain::Hoodi; + let pbs_port = 3711; + + // Start the mock SSV node + let ssv_node_port = pbs_port + 1; + let ssv_node_url = Url::parse(&format!("http://localhost:{ssv_node_port}/v1/"))?; + let mock_ssv_node_state = SsvNodeMockState { + validators: Arc::new(RwLock::new(vec![ + SSVNodeValidator { public_key: pubkey.clone() }, + SSVNodeValidator { public_key: pubkey2.clone() }, + ])), + force_timeout: Arc::new(RwLock::new(false)), + }; + let ssv_node_handle = + create_mock_ssv_node_server(ssv_node_port, Some(mock_ssv_node_state.clone())).await?; + + // Start the mock SSV public API + let ssv_public_port = ssv_node_port + 1; + let ssv_public_url = Url::parse(&format!("http://localhost:{ssv_public_port}/api/v4/"))?; + let mock_ssv_public_state = PublicSsvMockState { + validators: Arc::new(RwLock::new(vec![SSVPublicValidator { pubkey: pubkey.clone() }])), + force_timeout: Arc::new(RwLock::new(false)), + }; + let ssv_public_handle = + create_mock_public_ssv_server(ssv_public_port, Some(mock_ssv_public_state.clone())).await?; + + // Start a mock relay to be used by the mux + let relay_port = ssv_public_port + 1; + let relay = generate_mock_relay(relay_port, pubkey.clone())?; + let relay_id = relay.id.clone().to_string(); + let relay_state = Arc::new(MockRelayState::new(chain, signer)); + let relay_task = tokio::spawn(start_mock_relay_service(relay_state.clone(), relay_port)); + + // Create the registry mux + let loader = MuxKeysLoader::Registry { + enable_refreshing: true, + node_operator_id: 1, + lido_module_id: None, + registry: cb_common::config::NORegistry::SSV, + }; + let muxes = PbsMuxes { + muxes: vec![MuxConfig { + id: relay_id.clone(), + loader: Some(loader), + late_in_slot_time_ms: Some(u64::MAX), + relays: vec![(*relay.config).clone()], + timeout_get_header_ms: Some(u64::MAX - 1), + validator_pubkeys: vec![], + }], + }; + + // Set up the PBS config + let mut pbs_config = get_pbs_static_config(pbs_port); + pbs_config.ssv_node_api_url = ssv_node_url.clone(); + pbs_config.ssv_public_api_url = ssv_public_url.clone(); + pbs_config.mux_registry_refresh_interval_seconds = 1; // Refresh the mux every second + let (mux_lookup, registry_muxes) = muxes.validate_and_fill(chain, &pbs_config).await?; + let relays = vec![relay.clone()]; // Default relay only + let mut config = to_pbs_config(chain, pbs_config, relays); + config.all_relays.push(relay.clone()); // Add the mux relay to just this field + config.mux_lookup = Some(mux_lookup); + config.registry_muxes = Some(registry_muxes); + + // Run PBS service + let state = PbsState::new(config); + let pbs_server = tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); + info!("Started PBS server with pubkey {pubkey}"); + + // Wait for the server to start + tokio::time::sleep(Duration::from_millis(100)).await; + + // Try to run a get_header on the new pubkey, which should use the default + // relay only since it hasn't been seen in the mux yet + let mock_validator = MockValidator::new(pbs_port)?; + info!("Sending get header"); + let res = mock_validator.do_get_header(Some(pubkey2.clone())).await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(relay_state.received_get_header(), 1); // pubkey2 was loaded from the SSV node + + // Shut down the server handles + pbs_server.abort(); + ssv_node_handle.abort(); + ssv_public_handle.abort(); + relay_task.abort(); + + Ok(()) +} + +/// Tests the SSV mux with dynamic registry fetching from the public SSV API +/// when the local node is down +#[tokio::test] +async fn test_ssv_multi_with_public() -> Result<()> { + // Generate keys + let signer = random_secret(); + let pubkey = signer.public_key(); + let signer2 = random_secret(); + let pubkey2 = signer2.public_key(); + + let chain = Chain::Hoodi; + let pbs_port = 3720; + + // Start the mock SSV node + let ssv_node_port = pbs_port + 1; + let ssv_node_url = Url::parse(&format!("http://localhost:{ssv_node_port}/v1/"))?; + + // Don't start the SSV node server to simulate it being down + // let ssv_node_handle = create_mock_ssv_node_server(ssv_node_port, + // Some(mock_ssv_node_state.clone())).await?; + + // Start the mock SSV public API + let ssv_public_port = ssv_node_port + 1; + let ssv_public_url = Url::parse(&format!("http://localhost:{ssv_public_port}/api/v4/"))?; + let mock_ssv_public_state = PublicSsvMockState { + validators: Arc::new(RwLock::new(vec![ + SSVPublicValidator { pubkey: pubkey.clone() }, + SSVPublicValidator { pubkey: pubkey2.clone() }, + ])), + force_timeout: Arc::new(RwLock::new(false)), + }; + let ssv_public_handle = + create_mock_public_ssv_server(ssv_public_port, Some(mock_ssv_public_state.clone())).await?; + + // Start a mock relay to be used by the mux + let relay_port = ssv_public_port + 1; + let relay = generate_mock_relay(relay_port, pubkey.clone())?; + let relay_id = relay.id.clone().to_string(); + let relay_state = Arc::new(MockRelayState::new(chain, signer)); + let relay_task = tokio::spawn(start_mock_relay_service(relay_state.clone(), relay_port)); + + // Create the registry mux + let loader = MuxKeysLoader::Registry { + enable_refreshing: true, + node_operator_id: 1, + lido_module_id: None, + registry: cb_common::config::NORegistry::SSV, + }; + let muxes = PbsMuxes { + muxes: vec![MuxConfig { + id: relay_id.clone(), + loader: Some(loader), + late_in_slot_time_ms: Some(u64::MAX), + relays: vec![(*relay.config).clone()], + timeout_get_header_ms: Some(u64::MAX - 1), + validator_pubkeys: vec![], + }], + }; + + // Set up the PBS config + let mut pbs_config = get_pbs_static_config(pbs_port); + pbs_config.ssv_node_api_url = ssv_node_url.clone(); + pbs_config.ssv_public_api_url = ssv_public_url.clone(); + pbs_config.mux_registry_refresh_interval_seconds = 1; // Refresh the mux every second + let (mux_lookup, registry_muxes) = muxes.validate_and_fill(chain, &pbs_config).await?; + let relays = vec![relay.clone()]; // Default relay only + let mut config = to_pbs_config(chain, pbs_config, relays); + config.all_relays.push(relay.clone()); // Add the mux relay to just this field + config.mux_lookup = Some(mux_lookup); + config.registry_muxes = Some(registry_muxes); + + // Run PBS service + let state = PbsState::new(config); + let pbs_server = tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); + info!("Started PBS server with pubkey {pubkey}"); + + // Wait for the server to start + tokio::time::sleep(Duration::from_millis(100)).await; + + // Try to run a get_header on the new pubkey, which should use the default + // relay only since it hasn't been seen in the mux yet + let mock_validator = MockValidator::new(pbs_port)?; + info!("Sending get header"); + let res = mock_validator.do_get_header(Some(pubkey2.clone())).await?; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(relay_state.received_get_header(), 1); // pubkey2 was loaded from the SSV public API + + // Shut down the server handles + pbs_server.abort(); + //ssv_node_handle.abort(); + ssv_public_handle.abort(); + relay_task.abort(); + + Ok(()) +} diff --git a/tests/tests/pbs_mux_refresh.rs b/tests/tests/pbs_mux_refresh.rs index e1a5a8fe..ceb688cb 100644 --- a/tests/tests/pbs_mux_refresh.rs +++ b/tests/tests/pbs_mux_refresh.rs @@ -2,14 +2,14 @@ use std::{path::PathBuf, sync::Arc, time::Duration}; use cb_common::{ config::{MuxConfig, MuxKeysLoader, PbsMuxes}, - interop::ssv::types::SSVValidator, + interop::ssv::types::SSVPublicValidator, signer::random_secret, types::Chain, }; use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; use cb_tests::{ mock_relay::{MockRelayState, start_mock_relay_service}, - mock_ssv::{SsvMockState, create_mock_ssv_server}, + mock_ssv_public::{PublicSsvMockState, create_mock_public_ssv_server}, mock_validator::MockValidator, utils::{generate_mock_relay, get_pbs_static_config, to_pbs_config}, }; @@ -44,14 +44,14 @@ async fn test_auto_refresh() -> Result<()> { let ssv_api_port = pbs_port + 1; // Intentionally missing a trailing slash to ensure this is handled properly let ssv_api_url = Url::parse(&format!("http://localhost:{ssv_api_port}/api/v4"))?; - let mock_ssv_state = SsvMockState { - validators: Arc::new(RwLock::new(vec![SSVValidator { + let mock_ssv_state = PublicSsvMockState { + validators: Arc::new(RwLock::new(vec![SSVPublicValidator { pubkey: existing_mux_pubkey.clone(), }])), force_timeout: Arc::new(RwLock::new(false)), }; let ssv_server_handle = - create_mock_ssv_server(ssv_api_port, Some(mock_ssv_state.clone())).await?; + create_mock_public_ssv_server(ssv_api_port, Some(mock_ssv_state.clone())).await?; // Start a default relay for non-mux keys let default_relay_port = ssv_api_port + 1; @@ -88,7 +88,7 @@ async fn test_auto_refresh() -> Result<()> { // Set up the PBS config let mut pbs_config = get_pbs_static_config(pbs_port); - pbs_config.ssv_api_url = ssv_api_url.clone(); + pbs_config.ssv_public_api_url = ssv_api_url.clone(); pbs_config.mux_registry_refresh_interval_seconds = 1; // Refresh the mux every second let (mux_lookup, registry_muxes) = muxes.validate_and_fill(chain, &pbs_config).await?; let relays = vec![default_relay.clone()]; // Default relay only @@ -126,7 +126,7 @@ async fn test_auto_refresh() -> Result<()> { // Add another validator { let mut validators = mock_ssv_state.validators.write().await; - validators.push(SSVValidator { pubkey: new_mux_pubkey.clone() }); + validators.push(SSVPublicValidator { pubkey: new_mux_pubkey.clone() }); info!("Added new validator {new_mux_pubkey} to the SSV mock server"); } From b61de32c782e4deb70bb09c5c13768750abe20f0 Mon Sep 17 00:00:00 2001 From: Sergey Yakovlev Date: Mon, 2 Mar 2026 15:34:52 +0200 Subject: [PATCH 03/11] fix(tests): update tests for SSV API rename and PbsState signature change (#427) --- .gitignore | 8 ++++++++ tests/tests/pbs_cfg_file_update.rs | 3 ++- tests/tests/pbs_mux.rs | 4 ++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index e48792b4..d49aa37a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,11 @@ targets.json .idea/ logs .vscode/ + +# Nix +.direnv/ +.devenv/ +devenv.* +devenv.lock +.devenv.flake.nix +.envrc diff --git a/tests/tests/pbs_cfg_file_update.rs b/tests/tests/pbs_cfg_file_update.rs index 0c4e8e47..0699eb27 100644 --- a/tests/tests/pbs_cfg_file_update.rs +++ b/tests/tests/pbs_cfg_file_update.rs @@ -59,7 +59,8 @@ async fn test_cfg_file_update() -> Result<()> { * or anything close to it */ extra_validation_enabled: false, rpc_url: None, - ssv_api_url: Url::parse("http://example.com").unwrap(), + ssv_node_api_url: Url::parse("http://example.com").unwrap(), + ssv_public_api_url: Url::parse("http://example.com").unwrap(), http_timeout_seconds: 10, register_validator_retry_limit: 3, validator_registration_batch_size: None, diff --git a/tests/tests/pbs_mux.rs b/tests/tests/pbs_mux.rs index 85c84a55..34da1dc7 100644 --- a/tests/tests/pbs_mux.rs +++ b/tests/tests/pbs_mux.rs @@ -345,7 +345,7 @@ async fn test_ssv_multi_with_node() -> Result<()> { config.registry_muxes = Some(registry_muxes); // Run PBS service - let state = PbsState::new(config); + let state = PbsState::new(config, PathBuf::new()); let pbs_server = tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); info!("Started PBS server with pubkey {pubkey}"); @@ -441,7 +441,7 @@ async fn test_ssv_multi_with_public() -> Result<()> { config.registry_muxes = Some(registry_muxes); // Run PBS service - let state = PbsState::new(config); + let state = PbsState::new(config, PathBuf::new()); let pbs_server = tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); info!("Started PBS server with pubkey {pubkey}"); From 7bcdd49010e3cfda3d4d2ec09b102a5ec1fba140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Niedba=C5=82a?= Date: Mon, 2 Mar 2026 15:46:13 +0100 Subject: [PATCH 04/11] Fix broken CLI caused by double-parse bug (#428) Co-authored-by: Joe Clapis --- bin/cli.rs | 14 -------------- bin/pbs.rs | 14 +------------- bin/signer.rs | 14 +------------- crates/cli/src/lib.rs | 25 ++++++++++++++++++++++--- 4 files changed, 24 insertions(+), 43 deletions(-) diff --git a/bin/cli.rs b/bin/cli.rs index 234dc9bd..5512b820 100644 --- a/bin/cli.rs +++ b/bin/cli.rs @@ -1,22 +1,8 @@ use clap::Parser; -/// Version string with a leading 'v' -const VERSION: &str = concat!("v", env!("CARGO_PKG_VERSION")); - -/// Subcommands and global arguments for the module -#[derive(Parser, Debug)] -#[command(name = "Commit-Boost CLI", version = VERSION, about, long_about = None)] -struct Cli {} - -/// Main entry point of the Commit-Boost CLI #[tokio::main] async fn main() -> eyre::Result<()> { - // Parse the CLI arguments (currently only used for version info, more can be - // added later) - let _cli = Cli::parse(); - color_eyre::install()?; - // set default backtrace unless provided let args = cb_cli::Args::parse(); diff --git a/bin/pbs.rs b/bin/pbs.rs index ca8d9c9c..01328e4c 100644 --- a/bin/pbs.rs +++ b/bin/pbs.rs @@ -7,26 +7,14 @@ use clap::Parser; use eyre::Result; use tracing::{error, info}; -/// Version string with a leading 'v' -const VERSION: &str = concat!("v", env!("CARGO_PKG_VERSION")); - -/// Subcommands and global arguments for the module -#[derive(Parser, Debug)] -#[command(name = "Commit-Boost PBS Service", version = VERSION, about, long_about = None)] -struct Cli {} - #[tokio::main] async fn main() -> Result<()> { - // Parse the CLI arguments (currently only used for version info, more can be - // added later) - let _cli = Cli::parse(); + let _args = cb_cli::PbsArgs::parse(); color_eyre::install()?; let _guard = initialize_tracing_log(PBS_MODULE_NAME, LogsSettings::from_env_config()?); - let _args = cb_cli::PbsArgs::parse(); - let (pbs_config, config_path) = load_pbs_config(None).await?; PbsService::init_metrics(pbs_config.chain)?; diff --git a/bin/signer.rs b/bin/signer.rs index 01f3c970..e86acd60 100644 --- a/bin/signer.rs +++ b/bin/signer.rs @@ -7,26 +7,14 @@ use clap::Parser; use eyre::Result; use tracing::{error, info}; -/// Version string with a leading 'v' -const VERSION: &str = concat!("v", env!("CARGO_PKG_VERSION")); - -/// Subcommands and global arguments for the module -#[derive(Parser, Debug)] -#[command(name = "Commit-Boost Signer Service", version = VERSION, about, long_about = None)] -struct Cli {} - #[tokio::main] async fn main() -> Result<()> { - // Parse the CLI arguments (currently only used for version info, more can be - // added later) - let _cli = Cli::parse(); + let _args = cb_cli::SignerArgs::parse(); color_eyre::install()?; let _guard = initialize_tracing_log(SIGNER_MODULE_NAME, LogsSettings::from_env_config()?); - let _args = cb_cli::SignerArgs::parse(); - let config = StartSignerConfig::load_from_env()?; let server = SigningService::run(config); diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 34170470..738285e1 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -5,8 +5,11 @@ use clap::{Parser, Subcommand}; mod docker_init; +/// Version string with a leading 'v' +const VERSION: &str = concat!("v", env!("CARGO_PKG_VERSION")); + #[derive(Parser, Debug)] -#[command(version, about, long_about = LONG_ABOUT, name = "commit-boost-cli")] +#[command(version = VERSION, about, long_about = LONG_ABOUT, name = "commit-boost-cli")] pub struct Args { #[command(subcommand)] pub cmd: Command, @@ -41,9 +44,25 @@ impl Args { const LONG_ABOUT: &str = "Commit-Boost allows Ethereum validators to safely run MEV-Boost and community-built commitment protocols"; #[derive(Parser, Debug)] -#[command(version, about, long_about = LONG_ABOUT, name = "commit-boost-pbs")] +#[command(version = VERSION, about, long_about = LONG_ABOUT, name = "commit-boost-pbs")] pub struct PbsArgs; #[derive(Parser, Debug)] -#[command(version, about, long_about = LONG_ABOUT, name = "commit-boost-signer")] +#[command(version = VERSION, about, long_about = LONG_ABOUT, name = "commit-boost-signer")] pub struct SignerArgs; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn version_has_v_prefix() { + assert!(VERSION.starts_with('v'), "VERSION should start with 'v', got: {VERSION}"); + } + + #[test] + fn parse_init_subcommand() { + Args::try_parse_from(["commit-boost-cli", "init", "--config", "/tmp/config.toml"]) + .expect("should parse init subcommand"); + } +} From e4e29321a7c03030eab3670480d8bbab3f278f7d Mon Sep 17 00:00:00 2001 From: JasonVranek Date: Mon, 2 Mar 2026 19:14:52 +0000 Subject: [PATCH 05/11] Support custom chain ids (#429) --- config.example.toml | 4 +- crates/common/src/config/mod.rs | 5 +- crates/common/src/signature.rs | 4 - crates/common/src/types.rs | 84 ++++++++-------- tests/data/helder_spec.yml | 150 ---------------------------- tests/data/kurtosis_spec.json | 168 ++++++++++++++++++++++++++++++++ 6 files changed, 213 insertions(+), 202 deletions(-) delete mode 100644 tests/data/helder_spec.yml create mode 100644 tests/data/kurtosis_spec.json diff --git a/config.example.toml b/config.example.toml index 08fe8a0b..cc065442 100644 --- a/config.example.toml +++ b/config.example.toml @@ -2,9 +2,9 @@ # Some fields are optional and can be omitted, in which case the default value, if present, will be used. # Chain spec ID. Supported values: -# A network ID. Supported values: Mainnet, Holesky, Sepolia, Helder, Hoodi. Lower case values e.g. "mainnet" are also accepted +# A network ID. Supported values: Mainnet, Holesky, Sepolia, Hoodi. Lower case values e.g. "mainnet" are also accepted # A custom object, e.g., chain = { genesis_time_secs = 1695902400, path = "/path/to/spec.json" }, with a path to a chain spec file, either in .json format (e.g., as returned by the beacon endpoint /eth/v1/config/spec), or in .yml format (see examples in tests/data). -# A custom object, e.g., chain = { genesis_time_secs = 1695902400, slot_time_secs = 12, genesis_fork_version = "0x01017000" }. +# A custom object, e.g., chain = { genesis_time_secs = 1695902400, slot_time_secs = 12, genesis_fork_version = "0x01017000", chain_id = 17000 }. chain = "Holesky" # Configuration for the PBS module diff --git a/crates/common/src/config/mod.rs b/crates/common/src/config/mod.rs index c833d8e3..8ba35f42 100644 --- a/crates/common/src/config/mod.rs +++ b/crates/common/src/config/mod.rs @@ -68,7 +68,7 @@ impl CommitBoostConfig { let chain = match helper_config.chain { ChainLoader::Path { path, genesis_time_secs } => { // check if the file path is overridden by env var - let (slot_time_secs, genesis_fork_version, fulu_fork_slot) = + let (slot_time_secs, genesis_fork_version, fulu_fork_slot, chain_id) = if let Some(path) = load_optional_env_var(CHAIN_SPEC_ENV) { load_chain_from_file(path.parse()?)? } else { @@ -79,6 +79,7 @@ impl CommitBoostConfig { slot_time_secs, genesis_fork_version, fulu_fork_slot, + chain_id, } } ChainLoader::Known(known) => Chain::from(known), @@ -87,6 +88,7 @@ impl CommitBoostConfig { slot_time_secs, genesis_fork_version, fulu_fork_slot, + chain_id, } => { let genesis_fork_version: ForkVersion = genesis_fork_version.as_ref().try_into()?; Chain::Custom { @@ -94,6 +96,7 @@ impl CommitBoostConfig { slot_time_secs, genesis_fork_version, fulu_fork_slot, + chain_id, } } }; diff --git a/crates/common/src/signature.rs b/crates/common/src/signature.rs index 9753a9a1..d899842b 100644 --- a/crates/common/src/signature.rs +++ b/crates/common/src/signature.rs @@ -106,10 +106,6 @@ mod tests { compute_domain(Chain::Sepolia, APPLICATION_BUILDER_DOMAIN), Chain::Sepolia.builder_domain() ); - assert_eq!( - compute_domain(Chain::Helder, APPLICATION_BUILDER_DOMAIN), - Chain::Helder.builder_domain() - ); assert_eq!( compute_domain(Chain::Hoodi, APPLICATION_BUILDER_DOMAIN), Chain::Hoodi.builder_domain() diff --git a/crates/common/src/types.rs b/crates/common/src/types.rs index 6b06d040..8262f4fd 100644 --- a/crates/common/src/types.rs +++ b/crates/common/src/types.rs @@ -34,13 +34,13 @@ pub enum Chain { Mainnet, Holesky, Sepolia, - Helder, Hoodi, Custom { genesis_time_secs: u64, slot_time_secs: u64, genesis_fork_version: ForkVersion, fulu_fork_slot: u64, + chain_id: u64, }, } @@ -69,7 +69,7 @@ pub type ForkVersion = [u8; 4]; impl std::fmt::Display for Chain { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::Mainnet | Self::Holesky | Self::Sepolia | Self::Helder | Self::Hoodi => { + Self::Mainnet | Self::Holesky | Self::Sepolia | Self::Hoodi => { write!(f, "{self:?}") } Self::Custom { .. } => write!(f, "Custom"), @@ -83,19 +83,20 @@ impl std::fmt::Debug for Chain { Self::Mainnet => write!(f, "Mainnet"), Self::Holesky => write!(f, "Holesky"), Self::Sepolia => write!(f, "Sepolia"), - Self::Helder => write!(f, "Helder"), Self::Hoodi => write!(f, "Hoodi"), Self::Custom { genesis_time_secs, slot_time_secs, genesis_fork_version, fulu_fork_slot, + chain_id, } => f .debug_struct("Custom") .field("genesis_time_secs", genesis_time_secs) .field("slot_time_secs", slot_time_secs) .field("genesis_fork_version", &hex::encode_prefixed(genesis_fork_version)) .field("fulu_fork_slot", fulu_fork_slot) + .field("chain_id", chain_id) .finish(), } } @@ -107,11 +108,8 @@ impl Chain { Chain::Mainnet => KnownChain::Mainnet.id(), Chain::Holesky => KnownChain::Holesky.id(), Chain::Sepolia => KnownChain::Sepolia.id(), - Chain::Helder => KnownChain::Helder.id(), Chain::Hoodi => KnownChain::Hoodi.id(), - Chain::Custom { .. } => { - unimplemented!("chain id is not supported on custom chains, please file an issue") - } + Chain::Custom { chain_id, .. } => *chain_id, } } @@ -120,7 +118,6 @@ impl Chain { Chain::Mainnet => KnownChain::Mainnet.builder_domain(), Chain::Holesky => KnownChain::Holesky.builder_domain(), Chain::Sepolia => KnownChain::Sepolia.builder_domain(), - Chain::Helder => KnownChain::Helder.builder_domain(), Chain::Hoodi => KnownChain::Hoodi.builder_domain(), Chain::Custom { .. } => compute_domain(*self, APPLICATION_BUILDER_DOMAIN), } @@ -131,7 +128,6 @@ impl Chain { Chain::Mainnet => KnownChain::Mainnet.genesis_fork_version(), Chain::Holesky => KnownChain::Holesky.genesis_fork_version(), Chain::Sepolia => KnownChain::Sepolia.genesis_fork_version(), - Chain::Helder => KnownChain::Helder.genesis_fork_version(), Chain::Hoodi => KnownChain::Hoodi.genesis_fork_version(), Chain::Custom { genesis_fork_version, .. } => *genesis_fork_version, } @@ -142,7 +138,6 @@ impl Chain { Chain::Mainnet => KnownChain::Mainnet.genesis_time_sec(), Chain::Holesky => KnownChain::Holesky.genesis_time_sec(), Chain::Sepolia => KnownChain::Sepolia.genesis_time_sec(), - Chain::Helder => KnownChain::Helder.genesis_time_sec(), Chain::Hoodi => KnownChain::Hoodi.genesis_time_sec(), Chain::Custom { genesis_time_secs, .. } => *genesis_time_secs, } @@ -153,7 +148,6 @@ impl Chain { Chain::Mainnet => KnownChain::Mainnet.slot_time_sec(), Chain::Holesky => KnownChain::Holesky.slot_time_sec(), Chain::Sepolia => KnownChain::Sepolia.slot_time_sec(), - Chain::Helder => KnownChain::Helder.slot_time_sec(), Chain::Hoodi => KnownChain::Hoodi.slot_time_sec(), Chain::Custom { slot_time_secs, .. } => *slot_time_secs, } @@ -164,7 +158,6 @@ impl Chain { Chain::Mainnet => KnownChain::Mainnet.fulu_fork_slot(), Chain::Holesky => KnownChain::Holesky.fulu_fork_slot(), Chain::Sepolia => KnownChain::Sepolia.fulu_fork_slot(), - Chain::Helder => KnownChain::Helder.fulu_fork_slot(), Chain::Hoodi => KnownChain::Hoodi.fulu_fork_slot(), Chain::Custom { slot_time_secs, .. } => *slot_time_secs, } @@ -183,8 +176,6 @@ pub enum KnownChain { Holesky, #[serde(alias = "sepolia")] Sepolia, - #[serde(alias = "helder")] - Helder, #[serde(alias = "hoodi")] Hoodi, } @@ -196,7 +187,6 @@ impl KnownChain { KnownChain::Mainnet => 1, KnownChain::Holesky => 17000, KnownChain::Sepolia => 11155111, - KnownChain::Helder => 167000, KnownChain::Hoodi => 560048, } } @@ -212,9 +202,6 @@ impl KnownChain { KnownChain::Sepolia => { b256!("0x00000001d3010778cd08ee514b08fe67b6c503b510987a4ce43f42306d97c67c") } - KnownChain::Helder => { - b256!("0x0000000194c41af484fff7964969e0bdd922f82dff0f4be87a60d0664cc9d1ff") - } KnownChain::Hoodi => { b256!("0x00000001719103511efa4f1362ff2a50996cccf329cc84cb410c5e5c7d351d03") } @@ -226,7 +213,6 @@ impl KnownChain { KnownChain::Mainnet => hex!("00000000"), KnownChain::Holesky => hex!("01017000"), KnownChain::Sepolia => hex!("90000069"), - KnownChain::Helder => hex!("10000000"), KnownChain::Hoodi => hex!("10000910"), } } @@ -236,25 +222,21 @@ impl KnownChain { KnownChain::Mainnet => 1606824023, KnownChain::Holesky => 1695902400, KnownChain::Sepolia => 1655733600, - KnownChain::Helder => 1718967660, KnownChain::Hoodi => 1742213400, } } pub fn slot_time_sec(&self) -> u64 { match self { - KnownChain::Mainnet | - KnownChain::Holesky | - KnownChain::Sepolia | - KnownChain::Helder | - KnownChain::Hoodi => 12, + KnownChain::Mainnet | KnownChain::Holesky | KnownChain::Sepolia | KnownChain::Hoodi => { + 12 + } } } pub fn fulu_fork_slot(&self) -> u64 { match self { KnownChain::Mainnet => 13164544, - KnownChain::Helder => u64::MAX, KnownChain::Holesky => 5283840, KnownChain::Sepolia => 8724480, KnownChain::Hoodi => 1622016, @@ -268,7 +250,6 @@ impl From for Chain { KnownChain::Mainnet => Chain::Mainnet, KnownChain::Holesky => Chain::Holesky, KnownChain::Sepolia => Chain::Sepolia, - KnownChain::Helder => Chain::Helder, KnownChain::Hoodi => Chain::Hoodi, } } @@ -291,6 +272,7 @@ pub enum ChainLoader { slot_time_secs: u64, genesis_fork_version: Bytes, fulu_fork_slot: u64, + chain_id: u64, }, } @@ -303,18 +285,19 @@ impl Serialize for Chain { Chain::Mainnet => ChainLoader::Known(KnownChain::Mainnet), Chain::Holesky => ChainLoader::Known(KnownChain::Holesky), Chain::Sepolia => ChainLoader::Known(KnownChain::Sepolia), - Chain::Helder => ChainLoader::Known(KnownChain::Helder), Chain::Hoodi => ChainLoader::Known(KnownChain::Hoodi), Chain::Custom { genesis_time_secs, slot_time_secs, genesis_fork_version, fulu_fork_slot, + chain_id, } => ChainLoader::Custom { genesis_time_secs: *genesis_time_secs, slot_time_secs: *slot_time_secs, genesis_fork_version: Bytes::from(*genesis_fork_version), fulu_fork_slot: *fulu_fork_slot, + chain_id: *chain_id, }, }; @@ -332,13 +315,14 @@ impl<'de> Deserialize<'de> for Chain { match loader { ChainLoader::Known(known) => Ok(Chain::from(known)), ChainLoader::Path { genesis_time_secs, path } => { - let (slot_time_secs, genesis_fork_version, fulu_fork_slot) = + let (slot_time_secs, genesis_fork_version, fulu_fork_slot, chain_id) = load_chain_from_file(path).map_err(serde::de::Error::custom)?; Ok(Chain::Custom { genesis_time_secs, slot_time_secs, genesis_fork_version, fulu_fork_slot, + chain_id, }) } ChainLoader::Custom { @@ -346,6 +330,7 @@ impl<'de> Deserialize<'de> for Chain { slot_time_secs, genesis_fork_version, fulu_fork_slot, + chain_id, } => { let genesis_fork_version: ForkVersion = genesis_fork_version.as_ref().try_into().map_err(serde::de::Error::custom)?; @@ -354,19 +339,20 @@ impl<'de> Deserialize<'de> for Chain { slot_time_secs, genesis_fork_version, fulu_fork_slot, + chain_id, }) } } } } -/// Returns seconds_per_slot and genesis_fork_version from a spec, such as -/// returned by /eth/v1/config/spec ref: https://ethereum.github.io/beacon-APIs/#/Config/getSpec +/// Returns seconds_per_slot, genesis_fork_version, fulu_fork_epoch, and +/// deposit_chain_id from a spec, such as returned by /eth/v1/config/spec ref: https://ethereum.github.io/beacon-APIs/#/Config/getSpec /// Try to load two formats: /// - JSON as return the getSpec endpoint, either with or without the `data` /// field /// - YAML as used e.g. in Kurtosis/Ethereum Package -pub fn load_chain_from_file(path: PathBuf) -> eyre::Result<(u64, ForkVersion, u64)> { +pub fn load_chain_from_file(path: PathBuf) -> eyre::Result<(u64, ForkVersion, u64, u64)> { #[derive(Deserialize)] #[serde(rename_all = "UPPERCASE")] struct QuotedSpecFile { @@ -377,14 +363,16 @@ pub fn load_chain_from_file(path: PathBuf) -> eyre::Result<(u64, ForkVersion, u6 slots_per_epoch: u64, #[serde(with = "serde_utils::quoted_u64")] fulu_fork_epoch: u64, + #[serde(with = "serde_utils::quoted_u64")] + deposit_chain_id: u64, } impl QuotedSpecFile { - fn to_chain(&self) -> eyre::Result<(u64, ForkVersion, u64)> { + fn to_chain(&self) -> eyre::Result<(u64, ForkVersion, u64, u64)> { let genesis_fork_version: ForkVersion = self.genesis_fork_version.as_ref().try_into()?; let fulu_fork_slot = self.fulu_fork_epoch.saturating_mul(self.slots_per_epoch); - Ok((self.seconds_per_slot, genesis_fork_version, fulu_fork_slot)) + Ok((self.seconds_per_slot, genesis_fork_version, fulu_fork_slot, self.deposit_chain_id)) } } @@ -400,14 +388,15 @@ pub fn load_chain_from_file(path: PathBuf) -> eyre::Result<(u64, ForkVersion, u6 genesis_fork_version: u32, slots_per_epoch: Option, fulu_fork_epoch: u64, + deposit_chain_id: u64, } impl SpecFile { - fn to_chain(&self) -> (u64, ForkVersion, u64) { + fn to_chain(&self) -> (u64, ForkVersion, u64, u64) { let genesis_fork_version: ForkVersion = self.genesis_fork_version.to_be_bytes(); let fulu_fork_slot = self.fulu_fork_epoch.saturating_mul(self.slots_per_epoch.unwrap_or(32)); - (self.seconds_per_slot, genesis_fork_version, fulu_fork_slot) + (self.seconds_per_slot, genesis_fork_version, fulu_fork_slot, self.deposit_chain_id) } } @@ -443,13 +432,14 @@ mod tests { #[test] fn test_load_custom() { - let s = r#"chain = { genesis_time_secs = 1, slot_time_secs = 2, genesis_fork_version = "0x01000000", fulu_fork_slot = 1 }"#; + let s = r#"chain = { genesis_time_secs = 1, slot_time_secs = 2, genesis_fork_version = "0x01000000", fulu_fork_slot = 1, chain_id = 123 }"#; let decoded: MockConfig = toml::from_str(s).unwrap(); assert_eq!(decoded.chain, Chain::Custom { genesis_time_secs: 1, slot_time_secs: 2, genesis_fork_version: [1, 0, 0, 0], - fulu_fork_slot: 1 + fulu_fork_slot: 1, + chain_id: 123, }) } @@ -492,6 +482,7 @@ mod tests { slot_time_secs: KnownChain::Holesky.slot_time_sec(), genesis_fork_version: KnownChain::Holesky.genesis_fork_version(), fulu_fork_slot: KnownChain::Holesky.fulu_fork_slot(), + chain_id: KnownChain::Holesky.id(), }) } @@ -507,12 +498,13 @@ mod tests { let s = format!("chain = {{ genesis_time_secs = 1, path = {path:?}}}"); let decoded: MockConfig = toml::from_str(&s).unwrap(); - assert_eq!(decoded.chain.slot_time_sec(), KnownChain::Helder.slot_time_sec()); + assert_eq!(decoded.chain.slot_time_sec(), KnownChain::Sepolia.slot_time_sec()); assert_eq!(decoded.chain, Chain::Custom { genesis_time_secs: 1, slot_time_secs: KnownChain::Sepolia.slot_time_sec(), genesis_fork_version: KnownChain::Sepolia.genesis_fork_version(), fulu_fork_slot: KnownChain::Sepolia.fulu_fork_slot(), + chain_id: KnownChain::Sepolia.id(), }) } @@ -534,27 +526,29 @@ mod tests { slot_time_secs: KnownChain::Hoodi.slot_time_sec(), genesis_fork_version: KnownChain::Hoodi.genesis_fork_version(), fulu_fork_slot: KnownChain::Hoodi.fulu_fork_slot(), + chain_id: KnownChain::Hoodi.id(), }) } #[test] - fn test_spec_helder_yml() { + fn test_spec_kurtosis_data_json() { let a = env!("CARGO_MANIFEST_DIR"); let mut path = PathBuf::from(a); path.pop(); path.pop(); - path.push("tests/data/helder_spec.yml"); + path.push("tests/data/kurtosis_spec.json"); let s = format!("chain = {{ genesis_time_secs = 1, path = {path:?}}}"); let decoded: MockConfig = toml::from_str(&s).unwrap(); - assert_eq!(decoded.chain.slot_time_sec(), KnownChain::Helder.slot_time_sec()); + assert_eq!(decoded.chain.slot_time_sec(), 12); assert_eq!(decoded.chain, Chain::Custom { genesis_time_secs: 1, - slot_time_secs: KnownChain::Helder.slot_time_sec(), - genesis_fork_version: KnownChain::Helder.genesis_fork_version(), - fulu_fork_slot: KnownChain::Helder.fulu_fork_slot(), + slot_time_secs: 12, + genesis_fork_version: hex!("0x10000038"), + fulu_fork_slot: 0, + chain_id: 3151908, }) } } diff --git a/tests/data/helder_spec.yml b/tests/data/helder_spec.yml deleted file mode 100644 index 7d3f9b04..00000000 --- a/tests/data/helder_spec.yml +++ /dev/null @@ -1,150 +0,0 @@ -# Extends the mainnet preset -PRESET_BASE: mainnet -CONFIG_NAME: testnet # needs to exist because of Prysm. Otherwise it conflicts with mainnet genesis - -# Genesis -# --------------------------------------------------------------- -# `2**14` (= 16,384) -MIN_GENESIS_ACTIVE_VALIDATOR_COUNT: 1000 -# Mar-01-2021 08:53:32 AM +UTC -# This is an invalid valid and should be updated when you create the genesis -MIN_GENESIS_TIME: 1718967600 -GENESIS_FORK_VERSION: 0x10000000 -GENESIS_DELAY: 60 - -# Forking -# --------------------------------------------------------------- -# Some forks are disabled for now: -# - These may be re-assigned to another fork-version later -# - Temporarily set to max uint64 value: 2**64 - 1 - -# Altair -ALTAIR_FORK_VERSION: 0x20000000 -ALTAIR_FORK_EPOCH: 0 -# Merge -BELLATRIX_FORK_VERSION: 0x30000000 -BELLATRIX_FORK_EPOCH: 0 -TERMINAL_TOTAL_DIFFICULTY: 0 -TERMINAL_BLOCK_HASH: 0x0000000000000000000000000000000000000000000000000000000000000000 -TERMINAL_BLOCK_HASH_ACTIVATION_EPOCH: 18446744073709551615 - -# Capella -CAPELLA_FORK_VERSION: 0x40000000 -CAPELLA_FORK_EPOCH: 0 - -# DENEB -DENEB_FORK_VERSION: 0x50132736 -DENEB_FORK_EPOCH: 0 - -# Electra -ELECTRA_FORK_VERSION: 0x60132736 -ELECTRA_FORK_EPOCH: 999999 - -FULU_FORK_EPOCH: 18446744073709551615 - -# EIP7594 - Peerdas -EIP7594_FORK_VERSION: 0x70132736 -EIP7594_FORK_EPOCH: 999999 - -# Time parameters -# --------------------------------------------------------------- -# 12 seconds -SECONDS_PER_SLOT: 12 -# 14 (estimate from Eth1 mainnet) -SECONDS_PER_ETH1_BLOCK: 12 -# 2**8 (= 256) epochs ~27 hours -MIN_VALIDATOR_WITHDRAWABILITY_DELAY: 256 -# 2**8 (= 256) epochs ~27 hours -SHARD_COMMITTEE_PERIOD: 256 -# 2**11 (= 2,048) Eth1 blocks ~8 hours -ETH1_FOLLOW_DISTANCE: 2048 - -# Validator cycle -# --------------------------------------------------------------- -# 2**2 (= 4) -INACTIVITY_SCORE_BIAS: 4 -# 2**4 (= 16) -INACTIVITY_SCORE_RECOVERY_RATE: 16 -# 2**4 * 10**9 (= 16,000,000,000) Gwei -EJECTION_BALANCE: 16000000000 -# 2**2 (= 4) -MIN_PER_EPOCH_CHURN_LIMIT: 4 -# 2**16 (= 65,536) -CHURN_LIMIT_QUOTIENT: 65536 -# [New in Deneb:EIP7514] 2**3 (= 8) -MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT: 8 - -# Fork choice -# --------------------------------------------------------------- -# 40% -PROPOSER_SCORE_BOOST: 40 -# 20% -REORG_HEAD_WEIGHT_THRESHOLD: 20 -# 160% -REORG_PARENT_WEIGHT_THRESHOLD: 160 -# `2` epochs -REORG_MAX_EPOCHS_SINCE_FINALIZATION: 2 - -# Deposit contract -# --------------------------------------------------------------- -DEPOSIT_CHAIN_ID: 7014190335 -DEPOSIT_NETWORK_ID: 7014190335 -DEPOSIT_CONTRACT_ADDRESS: 0x4242424242424242424242424242424242424242 - -# Networking -# --------------------------------------------------------------- -# `10 * 2**20` (= 10485760, 10 MiB) -GOSSIP_MAX_SIZE: 10485760 -# `2**10` (= 1024) -MAX_REQUEST_BLOCKS: 1024 -# `2**8` (= 256) -EPOCHS_PER_SUBNET_SUBSCRIPTION: 256 -# `MIN_VALIDATOR_WITHDRAWABILITY_DELAY + CHURN_LIMIT_QUOTIENT // 2` (= 33024, ~5 months) -MIN_EPOCHS_FOR_BLOCK_REQUESTS: 33024 -# `10 * 2**20` (=10485760, 10 MiB) -MAX_CHUNK_SIZE: 10485760 -# 5s -TTFB_TIMEOUT: 5 -# 10s -RESP_TIMEOUT: 10 -ATTESTATION_PROPAGATION_SLOT_RANGE: 32 -# 500ms -MAXIMUM_GOSSIP_CLOCK_DISPARITY: 500 -MESSAGE_DOMAIN_INVALID_SNAPPY: 0x00000000 -MESSAGE_DOMAIN_VALID_SNAPPY: 0x01000000 -# 2 subnets per node -SUBNETS_PER_NODE: 2 -# 2**8 (= 64) -ATTESTATION_SUBNET_COUNT: 64 -ATTESTATION_SUBNET_EXTRA_BITS: 0 -# ceillog2(ATTESTATION_SUBNET_COUNT) + ATTESTATION_SUBNET_EXTRA_BITS -ATTESTATION_SUBNET_PREFIX_BITS: 6 - -# Deneb -# `2**7` (=128) -MAX_REQUEST_BLOCKS_DENEB: 128 -# MAX_REQUEST_BLOCKS_DENEB * MAX_BLOBS_PER_BLOCK -MAX_REQUEST_BLOB_SIDECARS: 768 -# `2**12` (= 4096 epochs, ~18 days) -MIN_EPOCHS_FOR_BLOB_SIDECARS_REQUESTS: 4096 -# `6` -BLOB_SIDECAR_SUBNET_COUNT: 6 - -# Whisk -# `Epoch(2**8)` -WHISK_EPOCHS_PER_SHUFFLING_PHASE: 256 -# `Epoch(2)` -WHISK_PROPOSER_SELECTION_GAP: 2 - -# EIP7594 -NUMBER_OF_COLUMNS: 128 -MAX_CELLS_IN_EXTENDED_MATRIX: 768 -DATA_COLUMN_SIDECAR_SUBNET_COUNT: 32 -MAX_REQUEST_DATA_COLUMN_SIDECARS: 16384 -SAMPLES_PER_SLOT: 8 -CUSTODY_REQUIREMENT: 1 -TARGET_NUMBER_OF_PEERS: 70 - -# [New in Electra:EIP7251] -MIN_PER_EPOCH_CHURN_LIMIT_ELECTRA: 128000000000 # 2**7 * 10**9 (= 128,000,000,000) -MAX_PER_EPOCH_ACTIVATION_EXIT_CHURN_LIMIT: 256000000000 # 2**8 * 10**9 (= 256,000,000,000) diff --git a/tests/data/kurtosis_spec.json b/tests/data/kurtosis_spec.json new file mode 100644 index 00000000..9129f573 --- /dev/null +++ b/tests/data/kurtosis_spec.json @@ -0,0 +1,168 @@ +{ + "data": { + "CONFIG_NAME": "testnet", + "PRESET_BASE": "mainnet", + "TERMINAL_TOTAL_DIFFICULTY": "0", + "TERMINAL_BLOCK_HASH": "0x0000000000000000000000000000000000000000000000000000000000000000", + "TERMINAL_BLOCK_HASH_ACTIVATION_EPOCH": "18446744073709551615", + "MIN_GENESIS_ACTIVE_VALIDATOR_COUNT": "4", + "MIN_GENESIS_TIME": "1772130224", + "GENESIS_FORK_VERSION": "0x10000038", + "GENESIS_DELAY": "0", + "ALTAIR_FORK_VERSION": "0x20000038", + "ALTAIR_FORK_EPOCH": "0", + "BELLATRIX_FORK_VERSION": "0x30000038", + "BELLATRIX_FORK_EPOCH": "0", + "CAPELLA_FORK_VERSION": "0x40000038", + "CAPELLA_FORK_EPOCH": "0", + "DENEB_FORK_VERSION": "0x50000038", + "DENEB_FORK_EPOCH": "0", + "ELECTRA_FORK_VERSION": "0x60000038", + "ELECTRA_FORK_EPOCH": "0", + "FULU_FORK_VERSION": "0x70000038", + "FULU_FORK_EPOCH": "0", + "GLOAS_FORK_VERSION": "0x80000038", + "GLOAS_FORK_EPOCH": "18446744073709551615", + "SECONDS_PER_SLOT": "12", + "SECONDS_PER_ETH1_BLOCK": "12", + "MIN_VALIDATOR_WITHDRAWABILITY_DELAY": "256", + "SHARD_COMMITTEE_PERIOD": "256", + "ETH1_FOLLOW_DISTANCE": "2048", + "SUBNETS_PER_NODE": "2", + "INACTIVITY_SCORE_BIAS": "4", + "INACTIVITY_SCORE_RECOVERY_RATE": "16", + "EJECTION_BALANCE": "16000000000", + "MIN_PER_EPOCH_CHURN_LIMIT": "4", + "MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT": "8", + "CHURN_LIMIT_QUOTIENT": "65536", + "PROPOSER_SCORE_BOOST": "40", + "DEPOSIT_CHAIN_ID": "3151908", + "DEPOSIT_NETWORK_ID": "3151908", + "DEPOSIT_CONTRACT_ADDRESS": "0x00000000219ab540356cbb839cbe05303d7705fa", + "GAS_LIMIT_ADJUSTMENT_FACTOR": "1024", + "MAX_PAYLOAD_SIZE": "10485760", + "MAX_REQUEST_BLOCKS": "1024", + "MIN_EPOCHS_FOR_BLOCK_REQUESTS": "33024", + "TTFB_TIMEOUT": "5", + "RESP_TIMEOUT": "10", + "ATTESTATION_PROPAGATION_SLOT_RANGE": "32", + "MAXIMUM_GOSSIP_CLOCK_DISPARITY": "500", + "MESSAGE_DOMAIN_INVALID_SNAPPY": "0x00000000", + "MESSAGE_DOMAIN_VALID_SNAPPY": "0x01000000", + "ATTESTATION_SUBNET_PREFIX_BITS": "6", + "MAX_REQUEST_BLOCKS_DENEB": "128", + "MAX_REQUEST_BLOB_SIDECARS": "768", + "MAX_REQUEST_DATA_COLUMN_SIDECARS": "16384", + "MIN_EPOCHS_FOR_BLOB_SIDECARS_REQUESTS": "4096", + "BLOB_SIDECAR_SUBNET_COUNT": "6", + "MAX_BLOBS_PER_BLOCK": "6", + "MIN_PER_EPOCH_CHURN_LIMIT_ELECTRA": "128000000000", + "MAX_PER_EPOCH_ACTIVATION_EXIT_CHURN_LIMIT": "256000000000", + "MAX_BLOBS_PER_BLOCK_ELECTRA": "9", + "BLOB_SIDECAR_SUBNET_COUNT_ELECTRA": "9", + "MAX_REQUEST_BLOB_SIDECARS_ELECTRA": "1152", + "NUMBER_OF_CUSTODY_GROUPS": "128", + "DATA_COLUMN_SIDECAR_SUBNET_COUNT": "128", + "SAMPLES_PER_SLOT": "8", + "CUSTODY_REQUIREMENT": "4", + "BLOB_SCHEDULE": [ + { + "EPOCH": "0", + "MAX_BLOBS_PER_BLOCK": "15" + } + ], + "VALIDATOR_CUSTODY_REQUIREMENT": "8", + "BALANCE_PER_ADDITIONAL_CUSTODY_GROUP": "32000000000", + "MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS": "4096", + "MAX_COMMITTEES_PER_SLOT": "64", + "TARGET_COMMITTEE_SIZE": "128", + "MAX_VALIDATORS_PER_COMMITTEE": "2048", + "SHUFFLE_ROUND_COUNT": "90", + "HYSTERESIS_QUOTIENT": "4", + "HYSTERESIS_DOWNWARD_MULTIPLIER": "1", + "HYSTERESIS_UPWARD_MULTIPLIER": "5", + "MIN_DEPOSIT_AMOUNT": "1000000000", + "MAX_EFFECTIVE_BALANCE": "32000000000", + "EFFECTIVE_BALANCE_INCREMENT": "1000000000", + "MIN_ATTESTATION_INCLUSION_DELAY": "1", + "SLOTS_PER_EPOCH": "32", + "MIN_SEED_LOOKAHEAD": "1", + "MAX_SEED_LOOKAHEAD": "4", + "EPOCHS_PER_ETH1_VOTING_PERIOD": "64", + "SLOTS_PER_HISTORICAL_ROOT": "8192", + "MIN_EPOCHS_TO_INACTIVITY_PENALTY": "4", + "EPOCHS_PER_HISTORICAL_VECTOR": "65536", + "EPOCHS_PER_SLASHINGS_VECTOR": "8192", + "HISTORICAL_ROOTS_LIMIT": "16777216", + "VALIDATOR_REGISTRY_LIMIT": "1099511627776", + "BASE_REWARD_FACTOR": "64", + "WHISTLEBLOWER_REWARD_QUOTIENT": "512", + "PROPOSER_REWARD_QUOTIENT": "8", + "INACTIVITY_PENALTY_QUOTIENT": "67108864", + "MIN_SLASHING_PENALTY_QUOTIENT": "128", + "PROPORTIONAL_SLASHING_MULTIPLIER": "1", + "MAX_PROPOSER_SLASHINGS": "16", + "MAX_ATTESTER_SLASHINGS": "2", + "MAX_ATTESTATIONS": "128", + "MAX_DEPOSITS": "16", + "MAX_VOLUNTARY_EXITS": "16", + "INACTIVITY_PENALTY_QUOTIENT_ALTAIR": "50331648", + "MIN_SLASHING_PENALTY_QUOTIENT_ALTAIR": "64", + "PROPORTIONAL_SLASHING_MULTIPLIER_ALTAIR": "2", + "SYNC_COMMITTEE_SIZE": "512", + "EPOCHS_PER_SYNC_COMMITTEE_PERIOD": "256", + "MIN_SYNC_COMMITTEE_PARTICIPANTS": "1", + "INACTIVITY_PENALTY_QUOTIENT_BELLATRIX": "16777216", + "MIN_SLASHING_PENALTY_QUOTIENT_BELLATRIX": "32", + "PROPORTIONAL_SLASHING_MULTIPLIER_BELLATRIX": "3", + "MAX_BYTES_PER_TRANSACTION": "1073741824", + "MAX_TRANSACTIONS_PER_PAYLOAD": "1048576", + "BYTES_PER_LOGS_BLOOM": "256", + "MAX_EXTRA_DATA_BYTES": "32", + "MAX_BLS_TO_EXECUTION_CHANGES": "16", + "MAX_WITHDRAWALS_PER_PAYLOAD": "16", + "MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP": "16384", + "MAX_BLOB_COMMITMENTS_PER_BLOCK": "4096", + "KZG_COMMITMENT_INCLUSION_PROOF_DEPTH": "17", + "FIELD_ELEMENTS_PER_BLOB": "4096", + "MIN_ACTIVATION_BALANCE": "32000000000", + "MAX_EFFECTIVE_BALANCE_ELECTRA": "2048000000000", + "MIN_SLASHING_PENALTY_QUOTIENT_ELECTRA": "4096", + "WHISTLEBLOWER_REWARD_QUOTIENT_ELECTRA": "4096", + "PENDING_DEPOSITS_LIMIT": "134217728", + "PENDING_PARTIAL_WITHDRAWALS_LIMIT": "134217728", + "PENDING_CONSOLIDATIONS_LIMIT": "262144", + "MAX_ATTESTER_SLASHINGS_ELECTRA": "1", + "MAX_ATTESTATIONS_ELECTRA": "8", + "MAX_DEPOSIT_REQUESTS_PER_PAYLOAD": "8192", + "MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD": "16", + "MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD": "2", + "MAX_PENDING_PARTIALS_PER_WITHDRAWALS_SWEEP": "8", + "MAX_PENDING_DEPOSITS_PER_EPOCH": "16", + "FIELD_ELEMENTS_PER_CELL": "64", + "FIELD_ELEMENTS_PER_EXT_BLOB": "8192", + "KZG_COMMITMENTS_INCLUSION_PROOF_DEPTH": "4", + "CELLS_PER_EXT_BLOB": "128", + "NUMBER_OF_COLUMNS": "128", + "DOMAIN_DEPOSIT": "0x03000000", + "DOMAIN_BEACON_ATTESTER": "0x01000000", + "ETH1_ADDRESS_WITHDRAWAL_PREFIX": "0x01", + "BLS_WITHDRAWAL_PREFIX": "0x00", + "DOMAIN_AGGREGATE_AND_PROOF": "0x06000000", + "DOMAIN_SELECTION_PROOF": "0x05000000", + "TARGET_AGGREGATORS_PER_COMMITTEE": "16", + "DOMAIN_CONTRIBUTION_AND_PROOF": "0x09000000", + "SYNC_COMMITTEE_SUBNET_COUNT": "4", + "UNSET_DEPOSIT_REQUESTS_START_INDEX": "18446744073709551615", + "DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF": "0x08000000", + "DOMAIN_APPLICATION_MASK": "0x00000001", + "DOMAIN_VOLUNTARY_EXIT": "0x04000000", + "DOMAIN_BEACON_PROPOSER": "0x00000000", + "VERSIONED_HASH_VERSION_KZG": "1", + "COMPOUNDING_WITHDRAWAL_PREFIX": "0x02", + "DOMAIN_SYNC_COMMITTEE": "0x07000000", + "FULL_EXIT_REQUEST_AMOUNT": "0", + "DOMAIN_RANDAO": "0x02000000", + "TARGET_AGGREGATORS_PER_SYNC_SUBCOMMITTEE": "16" + } +} \ No newline at end of file From 71da9461b474710acdac916bdb42547db5a17405 Mon Sep 17 00:00:00 2001 From: JasonVranek Date: Wed, 11 Mar 2026 00:20:57 +0000 Subject: [PATCH 06/11] Update README.md (#436) --- README.md | 1 - docs/docs/get_started/troubleshooting.md | 2 +- docs/docusaurus.config.js | 4 ---- docs/sidebars.js | 5 ----- 4 files changed, 1 insertion(+), 11 deletions(-) diff --git a/README.md b/README.md index c28c28b5..3911db99 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,6 @@ [![Ci](https://github.com/Commit-Boost/commit-boost-client/actions/workflows/ci.yml/badge.svg)](https://github.com/Commit-Boost/commit-boost-client/actions/workflows/ci.yml) [![Docs](https://img.shields.io/badge/docs-latest-blue.svg)](https://commit-boost.github.io/commit-boost-client/) [![Release](https://img.shields.io/github/v/release/Commit-Boost/commit-boost-client)](https://github.com/Commit-Boost/commit-boost-client/releases) -[![Chat](https://img.shields.io/endpoint?color=neon&logo=telegram&label=chat&url=https%3A%2F%2Ftg.sumanjay.workers.dev%2F%2BPcs9bykxK3BiMzk5)](https://t.me/+Pcs9bykxK3BiMzk5) [![X](https://img.shields.io/twitter/follow/Commit_Boost)](https://x.com/Commit_Boost) A new Ethereum validator sidecar focused on standardizing the last mile of communication between validators and third-party protocols. diff --git a/docs/docs/get_started/troubleshooting.md b/docs/docs/get_started/troubleshooting.md index 7dc9f648..bb8623b8 100644 --- a/docs/docs/get_started/troubleshooting.md +++ b/docs/docs/get_started/troubleshooting.md @@ -4,7 +4,7 @@ description: Common issues # Troubleshooting -Commit-Boost was recently audited and going through a phased approach for validators to move to production. If you find any or have any question, please reach out on [X (Twitter)](https://x.com/Commit_Boost) or [Telegram](https://t.me/+Pcs9bykxK3BiMzk5). If there are any security related items, please see [here](https://github.com/Commit-Boost/commit-boost-client/blob/main/SECURITY.md). +Commit-Boost was recently audited and going through a phased approach for validators to move to production. If you find any or have any question, please reach out on [X (Twitter)](https://x.com/Commit_Boost). If there are any security related items, please see [here](https://github.com/Commit-Boost/commit-boost-client/blob/main/SECURITY.md). If you started the modules correctly you should see the following logs. diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index f78ae69a..8b100e2f 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -99,10 +99,6 @@ const config = { label: 'X (Twitter)', href: 'https://x.com/Commit_Boost', }, - { - label: 'Telegram', - href: 'https://t.me/+Pcs9bykxK3BiMzk5', - }, ], }, ], diff --git a/docs/sidebars.js b/docs/sidebars.js index cba043c2..7b3fc68d 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -82,11 +82,6 @@ const sidebars = { label: 'X (Twitter)', href: 'https://x.com/Commit_Boost', }, - { - type: 'link', - label: 'Telegram', - href: 'https://t.me/+Pcs9bykxK3BiMzk5', - }, ], // But you can create a sidebar manually From d924a83d2c9a2f8ba11a5f63889b1947742471f0 Mon Sep 17 00:00:00 2001 From: Joe Clapis Date: Tue, 17 Mar 2026 11:08:54 -0400 Subject: [PATCH 07/11] Unify the CLI, PBS, and Signer Binaries into One (#425) Co-authored-by: Jason Vranek Closes #430, #431, #432 --- .github/workflows/release-gate.yml | 96 ++ .github/workflows/release.yml | 190 ++- .gitignore | 3 + Cargo.lock | 262 +++- Cargo.toml | 3 + RELEASE.md | 73 + benches/microbench/Cargo.toml | 19 + benches/microbench/src/get_header.rs | 154 ++ bin/Cargo.toml | 16 +- bin/cli.rs | 10 - bin/commit-boost.rs | 127 ++ bin/pbs.rs | 37 - bin/signer.rs | 34 - bin/src/lib.rs | 5 +- bin/tests/binary.rs | 201 +++ crates/cli/Cargo.toml | 4 + crates/cli/src/docker_init.rs | 1722 ++++++++++++++++------ crates/cli/src/lib.rs | 69 +- crates/common/src/config/constants.rs | 4 +- crates/common/src/config/mod.rs | 2 +- crates/common/src/config/module.rs | 4 +- crates/common/src/config/pbs.rs | 26 +- crates/common/src/config/signer.rs | 13 +- docs/docs/get_started/building.md | 46 +- docs/docs/get_started/overview.md | 37 +- docs/docs/get_started/running/binary.md | 2 +- docs/docs/get_started/running/docker.md | 24 +- docs/docs/get_started/running/metrics.md | 2 +- examples/status_api/src/main.rs | 2 +- justfile | 178 ++- provisioning/kurtosis-config.yml | 104 ++ provisioning/pbs.Dockerfile | 5 +- provisioning/pectra-config.yml | 56 - provisioning/signer.Dockerfile | 5 +- tests/tests/pbs_cfg_file_update.rs | 10 +- 35 files changed, 2691 insertions(+), 854 deletions(-) create mode 100644 .github/workflows/release-gate.yml create mode 100644 RELEASE.md create mode 100644 benches/microbench/Cargo.toml create mode 100644 benches/microbench/src/get_header.rs delete mode 100644 bin/cli.rs create mode 100644 bin/commit-boost.rs delete mode 100644 bin/pbs.rs delete mode 100644 bin/signer.rs create mode 100644 bin/tests/binary.rs create mode 100644 provisioning/kurtosis-config.yml delete mode 100644 provisioning/pectra-config.yml diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml new file mode 100644 index 00000000..c5f1a1a4 --- /dev/null +++ b/.github/workflows/release-gate.yml @@ -0,0 +1,96 @@ +name: Release Gate + +on: + pull_request: + types: [closed] + branches: [main] + +jobs: + release-gate: + name: Tag and update release branches + runs-on: ubuntu-latest + # Only run when a release/ branch is merged (not just closed) + if: | + github.event.pull_request.merged == true && + startsWith(github.event.pull_request.head.ref, 'release/v') + + permissions: + contents: write + + steps: + - uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - uses: actions/checkout@v4 + with: + # Full history required for version comparison against existing tags + # and for the fast-forward push to stable/beta. + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - name: Extract and validate version + id: version + env: + BRANCH_REF: ${{ github.event.pull_request.head.ref }} + run: | + BRANCH="$BRANCH_REF" + NEW_VERSION="${BRANCH#release/}" + echo "new=${NEW_VERSION}" >> $GITHUB_OUTPUT + + # Determine if this is an RC + if echo "$NEW_VERSION" | grep -qE '\-rc[0-9]+$'; then + echo "is_rc=true" >> $GITHUB_OUTPUT + else + echo "is_rc=false" >> $GITHUB_OUTPUT + fi + + - name: Validate version is strictly increasing + env: + NEW_VERSION: ${{ steps.version.outputs.new }} + run: | + # Get the latest tag; if none exist yet, skip the comparison + LATEST_TAG=$(git tag --list 'v*' --sort=-version:refname | head -n1) + if [ -z "$LATEST_TAG" ]; then + echo "No existing tags found — skipping version comparison" + exit 0 + fi + + LATEST_VERSION="${LATEST_TAG#v}" + + python3 - <> $GITHUB_ENV + - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -63,8 +62,8 @@ jobs: context: . push: false platforms: linux/amd64,linux/arm64 - cache-from: type=registry,ref=ghcr.io/commit-boost/buildcache:${{ matrix.target-crate}} - cache-to: type=registry,ref=ghcr.io/commit-boost/buildcache:${{ matrix.target-crate }},mode=max + cache-from: type=registry,ref=ghcr.io/${{ env.OWNER }}/buildcache:${{ matrix.target-crate}} + cache-to: type=registry,ref=ghcr.io/${{ env.OWNER }}/buildcache:${{ matrix.target-crate }},mode=max file: provisioning/build.Dockerfile outputs: type=local,dest=build build-args: | @@ -83,7 +82,7 @@ jobs: path: | ${{ matrix.name }}-${{ github.ref_name }}-linux_${{ matrix.package-suffix }}.tar.gz - # Builds the arm64 binaries for Darwin, for all 3 crates, natively + # Builds the arm64 binary for Darwin natively build-binaries-darwin: strategy: matrix: @@ -92,9 +91,7 @@ jobs: # - x86_64-apple-darwin - aarch64-apple-darwin name: - - commit-boost-cli - - commit-boost-pbs - - commit-boost-signer + - commit-boost include: # - target: x86_64-apple-darwin # os: macos-latest-large @@ -158,6 +155,31 @@ jobs: path: | ${{ matrix.name }}-${{ github.ref_name }}-darwin_${{ matrix.package-suffix }}.tar.gz + # Signs the binaries + sign-binaries: + needs: + - build-binaries-linux + - build-binaries-darwin + runs-on: ubuntu-latest + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + path: ./artifacts + pattern: "commit-boost*" + + - name: Sign binaries + uses: sigstore/gh-action-sigstore-python@a5caf349bc536fbef3668a10ed7f5cd309a4b53d #v3.2.0 + with: + inputs: ./artifacts/**/*.tar.gz + + - name: Upload signatures + uses: actions/upload-artifact@v4 + with: + name: signatures-${{ github.ref_name }} + path: | + ./artifacts/**/*.sigstore.json + # Builds the PBS Docker image build-and-push-pbs-docker: needs: [build-binaries-linux] @@ -173,16 +195,19 @@ jobs: uses: actions/download-artifact@v4 with: path: ./artifacts - pattern: "commit-boost-*" + pattern: "commit-boost*" - name: Extract binaries run: | mkdir -p ./artifacts/bin/linux_amd64 mkdir -p ./artifacts/bin/linux_arm64 - tar -xzf ./artifacts/commit-boost-pbs-${{ github.ref_name }}-linux_x86-64/commit-boost-pbs-${{ github.ref_name }}-linux_x86-64.tar.gz -C ./artifacts/bin - mv ./artifacts/bin/commit-boost-pbs ./artifacts/bin/linux_amd64/commit-boost-pbs - tar -xzf ./artifacts/commit-boost-pbs-${{ github.ref_name }}-linux_arm64/commit-boost-pbs-${{ github.ref_name }}-linux_arm64.tar.gz -C ./artifacts/bin - mv ./artifacts/bin/commit-boost-pbs ./artifacts/bin/linux_arm64/commit-boost-pbs + tar -xzf ./artifacts/commit-boost-${{ github.ref_name }}-linux_x86-64/commit-boost-${{ github.ref_name }}-linux_x86-64.tar.gz -C ./artifacts/bin + mv ./artifacts/bin/commit-boost ./artifacts/bin/linux_amd64/commit-boost + tar -xzf ./artifacts/commit-boost-${{ github.ref_name }}-linux_arm64/commit-boost-${{ github.ref_name }}-linux_arm64.tar.gz -C ./artifacts/bin + mv ./artifacts/bin/commit-boost ./artifacts/bin/linux_arm64/commit-boost + + - name: Set lowercase owner + run: echo "OWNER=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -206,8 +231,8 @@ jobs: build-args: | BINARIES_PATH=./artifacts/bin tags: | - ghcr.io/commit-boost/pbs:${{ github.ref_name }} - ${{ !contains(github.ref_name, 'rc') && 'ghcr.io/commit-boost/pbs:latest' || '' }} + ghcr.io/${{ env.OWNER }}/pbs:${{ github.ref_name }} + ${{ !contains(github.ref_name, 'rc') && format('ghcr.io/{0}/pbs:latest', env.OWNER) || '' }} file: provisioning/pbs.Dockerfile # Builds the Signer Docker image @@ -225,16 +250,19 @@ jobs: uses: actions/download-artifact@v4 with: path: ./artifacts - pattern: "commit-boost-*" + pattern: "commit-boost*" - name: Extract binaries run: | mkdir -p ./artifacts/bin/linux_amd64 mkdir -p ./artifacts/bin/linux_arm64 - tar -xzf ./artifacts/commit-boost-signer-${{ github.ref_name }}-linux_x86-64/commit-boost-signer-${{ github.ref_name }}-linux_x86-64.tar.gz -C ./artifacts/bin - mv ./artifacts/bin/commit-boost-signer ./artifacts/bin/linux_amd64/commit-boost-signer - tar -xzf ./artifacts/commit-boost-signer-${{ github.ref_name }}-linux_arm64/commit-boost-signer-${{ github.ref_name }}-linux_arm64.tar.gz -C ./artifacts/bin - mv ./artifacts/bin/commit-boost-signer ./artifacts/bin/linux_arm64/commit-boost-signer + tar -xzf ./artifacts/commit-boost-${{ github.ref_name }}-linux_x86-64/commit-boost-${{ github.ref_name }}-linux_x86-64.tar.gz -C ./artifacts/bin + mv ./artifacts/bin/commit-boost ./artifacts/bin/linux_amd64/commit-boost + tar -xzf ./artifacts/commit-boost-${{ github.ref_name }}-linux_arm64/commit-boost-${{ github.ref_name }}-linux_arm64.tar.gz -C ./artifacts/bin + mv ./artifacts/bin/commit-boost ./artifacts/bin/linux_arm64/commit-boost + + - name: Set lowercase owner + run: echo "OWNER=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -258,32 +286,122 @@ jobs: build-args: | BINARIES_PATH=./artifacts/bin tags: | - ghcr.io/commit-boost/signer:${{ github.ref_name }} - ${{ !contains(github.ref_name, 'rc') && 'ghcr.io/commit-boost/signer:latest' || '' }} + ghcr.io/${{ env.OWNER }}/signer:${{ github.ref_name }} + ${{ !contains(github.ref_name, 'rc') && format('ghcr.io/{0}/signer:latest', env.OWNER) || '' }} file: provisioning/signer.Dockerfile - # Creates a draft release on GitHub with the binaries + # Creates a release on GitHub with the binaries finalize-release: needs: - build-binaries-linux - build-binaries-darwin + - sign-binaries - build-and-push-pbs-docker - build-and-push-signer-docker runs-on: ubuntu-latest steps: - - name: Download artifacts + - uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Checkout code + uses: actions/checkout@v4 + with: + token: ${{ steps.app-token.outputs.token }} + + - name: Download binaries + uses: actions/download-artifact@v4 + with: + path: ./artifacts + pattern: "commit-boost*" + + - name: Download signatures uses: actions/download-artifact@v4 with: path: ./artifacts - pattern: "commit-boost-*" + pattern: "signatures-${{ github.ref_name }}*" - name: Finalize Release uses: softprops/action-gh-release@v2 with: files: ./artifacts/**/* - draft: true - prerelease: false + draft: false + prerelease: ${{ contains(github.ref_name, '-rc') }} tag_name: ${{ github.ref_name }} name: ${{ github.ref_name }} + generate_release_notes: true env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + + # Fast-forwards stable (full release) or beta (RC) to the new tag. + # Runs after all artifacts are built and the draft release is created, + # so stable/beta are never touched if any part of the pipeline fails. + fast-forward-branch: + needs: + - finalize-release + runs-on: ubuntu-latest + steps: + - uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - name: Configure git + run: | + git config user.name "commit-boost-release-bot[bot]" + git config user.email "commit-boost-release-bot[bot]@users.noreply.github.com" + + - name: Fast-forward beta branch (RC releases) + if: contains(github.ref_name, '-rc') + run: | + git checkout beta + git merge --ff-only "${{ github.ref_name }}" + git push origin beta + + - name: Fast-forward stable branch (full releases) + if: "!contains(github.ref_name, '-rc')" + run: | + git checkout stable + git merge --ff-only "${{ github.ref_name }}" + git push origin stable + + # Deletes the tag if any job in the release pipeline fails. + # This keeps the tag and release artifacts in sync — a tag should only + # exist if the full pipeline completed successfully. + # stable/beta are never touched on failure since fast-forward-branch + # only runs after finalize-release succeeds. + # + # Note: if finalize-release specifically fails, a draft release may already + # exist on GitHub pointing at the now-deleted tag and will need manual cleanup. + cleanup-on-failure: + needs: + - build-binaries-linux + - build-binaries-darwin + - sign-binaries + - build-and-push-pbs-docker + - build-and-push-signer-docker + - finalize-release + - fast-forward-branch + runs-on: ubuntu-latest + if: failure() + steps: + - uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - uses: actions/checkout@v4 + with: + token: ${{ steps.app-token.outputs.token }} + + - name: Delete tag + run: git push origin --delete ${{ github.ref_name }} diff --git a/.gitignore b/.gitignore index d49aa37a..739e111a 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ devenv.* devenv.lock .devenv.flake.nix .envrc + +# Generated from testnet +kurtosis-dump \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 6d6d055d..b4c8e1d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -872,6 +872,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "1.0.0" @@ -1147,6 +1153,21 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "assert_cmd" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -1586,6 +1607,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -1628,6 +1660,25 @@ dependencies = [ "serde", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cb-bench-micro" +version = "0.9.3" +dependencies = [ + "alloy", + "axum 0.8.4", + "cb-common", + "cb-pbs", + "cb-tests", + "criterion", + "tokio", +] + [[package]] name = "cb-bench-pbs" version = "0.9.6" @@ -1655,6 +1706,8 @@ dependencies = [ "eyre", "indexmap 2.14.0", "serde_yaml", + "tempfile", + "toml", ] [[package]] @@ -1847,7 +1900,43 @@ dependencies = [ "iana-time-zone", "num-traits", "serde", - "windows-link", + "windows-link 0.2.0", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7" +dependencies = [ + "generic-array 0.14.7", ] [[package]] @@ -1967,6 +2056,7 @@ dependencies = [ name = "commit-boost" version = "0.9.6" dependencies = [ + "assert_cmd", "cb-cli", "cb-common", "cb-metrics", @@ -1975,6 +2065,9 @@ dependencies = [ "clap", "color-eyre", "eyre", + "predicates", + "serde_yaml", + "tempfile", "tokio", "tracing", "tree_hash 0.12.1", @@ -2128,6 +2221,42 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -2424,6 +2553,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.9.0" @@ -2982,6 +3117,15 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + [[package]] name = "fnv" version = "1.0.7" @@ -3242,6 +3386,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -3754,6 +3909,17 @@ dependencies = [ "serde", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.0", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -4200,6 +4366,12 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "notify" version = "8.2.0" @@ -4360,7 +4532,19 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" name = "once_cell_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openssl" @@ -4582,6 +4766,34 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -4606,6 +4818,36 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "pretty_reqwest_error" version = "0.1.0" @@ -6098,6 +6340,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "test_random_derive" version = "0.2.0" @@ -6235,6 +6483,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.11.0" diff --git a/Cargo.toml b/Cargo.toml index 47ab8ceb..49eb7314 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ alloy = { version = "^1.0.35", features = [ "ssz", ] } alloy-primitives = "^1.3.1" +assert_cmd = "2.1.2" async-trait = "0.1.80" axum = { version = "0.8.1", features = ["macros"] } axum-extra = { version = "0.10.0", features = ["typed-header"] } @@ -28,6 +29,7 @@ bimap = { version = "0.6.3", features = ["serde"] } blsful = "^2.5" blst = "^0.3.15" bytes = "1.10.1" +criterion = { version = "0.5", features = ["html_reports"] } cb-cli = { path = "crates/cli" } cb-common = { path = "crates/common" } cb-metrics = { path = "crates/metrics" } @@ -55,6 +57,7 @@ lh_bls = { package = "bls", git = "https://github.com/sigp/lighthouse", tag = "v lh_types = { package = "types", git = "https://github.com/sigp/lighthouse", tag = "v8.1.3" } parking_lot = "0.12.3" pbkdf2 = "0.12.2" +predicates = "3.0.3" prometheus = "0.14.0" prost = "0.13.4" rand = { version = "0.9", features = ["os_rng"] } diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 00000000..4176edfb --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,73 @@ +# Releasing a new version of Commit-Boost + +## Process + +1. Cut a release candidate (RC) +2. Test the RC +3. Collect signoffs +4. Cut the full release + +## How it works + +Releases are fully automated once a release PR is merged into `main`. The branch name controls what CI does: + +| Branch name | Result | +| --- | --- | +| `release/vX.Y.Z-rcQ` | Creates RC tag, fast-forwards `beta`, builds and signs artifacts | +| `release/vX.Y.Z` | Creates release tag, fast-forwards `stable`, builds and signs artifacts | + +No human pushes tags or updates `stable`/`beta` directly, the CI handles everything after the PR merges. + +## Cutting a release candidate + +1. Create a branch named `release/vX.Y.Z-rc1`. For the first RC of a new version, bump the version in `Cargo.toml` and run `cargo check` to update `Cargo.lock`. Always update `CHANGELOG.md`. +2. Open a PR targeting `main`. Get two approvals and merge. +3. CI creates the tag, fast-forwards `beta`, builds and signs binaries, Docker images, and creates a draft release on GitHub. +4. Test the RC on testnets. For subsequent RCs (`-rc2`, etc.), open a new release PR with only a `CHANGELOG.md` update (`Cargo.toml` does not change between RCs). + +## Cutting the full release + +Once testing is complete and signoffs are collected: + +1. Create a branch named `release/vX.Y.Z` and update `CHANGELOG.md` with final release notes. +2. Open a PR targeting `main`. Get two approvals and merge. +3. CI creates the tag, fast-forwards `stable`, builds and signs artifacts, and creates the release. +4. Verify the [binary was correctly signed](#verifying-release-artifacts). +5. Update the community. + +## If the pipeline fails + +CI will automatically delete the tag if any build step fails. `stable` and `beta` are only updated after all artifacts are successfully built, they are never touched on a failed run. Fix the issue and open a new release PR. + +## Verifying release artifacts + +All binaries are signed using [Sigstore cosign](https://docs.sigstore.dev/about/overview/). You can verify any binary was built by the official Commit-Boost CI pipeline from this release's commit. + +Install cosign: https://docs.sigstore.dev/cosign/system_config/installation/ + +```bash +# Set the release version and your target architecture +# Architecture options: darwin_arm64, linux_arm64, linux_x86-64 +export VERSION=vX.Y.Z +export ARCH=linux_x86-64 + +# Download the binary tarball and its signature +curl -L \ + -o "commit-boost-$VERSION-$ARCH.tar.gz" \ + "https://github.com/Commit-Boost/commit-boost-client/releases/download/$VERSION/commit-boost-$VERSION-$ARCH.tar.gz" + +curl -L \ + -o "commit-boost-$VERSION-$ARCH.tar.gz.sigstore.json" \ + "https://github.com/Commit-Boost/commit-boost-client/releases/download/$VERSION/commit-boost-$VERSION-$ARCH.tar.gz.sigstore.json" + +# Verify the binary was signed by the official CI pipeline +cosign verify-blob \ + "commit-boost-$VERSION-$ARCH.tar.gz" \ + --bundle "commit-boost-$VERSION-$ARCH.tar.gz.sigstore.json" \ + --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \ + --certificate-identity="https://github.com/Commit-Boost/commit-boost-client/.github/workflows/release.yml@refs/tags/$VERSION" +``` + +A successful verification prints `Verified OK`. If the binary was modified after being built by CI, this command will fail. + +The `.sigstore.json` bundle for each binary is attached to this release alongside the binary itself. diff --git a/benches/microbench/Cargo.toml b/benches/microbench/Cargo.toml new file mode 100644 index 00000000..185b51ee --- /dev/null +++ b/benches/microbench/Cargo.toml @@ -0,0 +1,19 @@ +[package] +edition.workspace = true +name = "cb-bench-micro" +rust-version.workspace = true +version.workspace = true + +[dependencies] +alloy.workspace = true +axum.workspace = true +cb-common.workspace = true +cb-pbs.workspace = true +cb-tests = { path = "../../tests" } +criterion.workspace = true +tokio.workspace = true + +[[bench]] +name = "get_header" +harness = false +path = "src/get_header.rs" diff --git a/benches/microbench/src/get_header.rs b/benches/microbench/src/get_header.rs new file mode 100644 index 00000000..44eff329 --- /dev/null +++ b/benches/microbench/src/get_header.rs @@ -0,0 +1,154 @@ +//! Criterion benchmarks for the `get_header` PBS flow. +//! +//! # What this measures +//! +//! The full `get_header` pipeline end-to-end: HTTP fan-out to N in-process mock +//! relays, response parsing, header validation, signature verification, and bid +//! selection. This is wall-clock timing — useful for local development feedback +//! and catching latency regressions across relay counts. +//! +//! Criterion runs each benchmark hundreds of times, applies statistical +//! analysis, and reports mean ± standard deviation. Results are saved to +//! `target/criterion/` as HTML reports (open `report/index.html`). +//! +//! # Running +//! +//! ```bash +//! # Run all benchmarks +//! cargo bench --package cb-bench-micro +//! +//! # Run a specific variant by filter +//! cargo bench --package cb-bench-micro -- 3_relays +//! +//! # Save a named baseline to compare against later +//! cargo bench --package cb-bench-micro -- --save-baseline main +//! +//! # Compare against a saved baseline +//! cargo bench --package cb-bench-micro -- --load-baseline main --save-baseline current +//! ``` +//! +//! # What is NOT measured +//! +//! - PBS HTTP server overhead (we call `get_header()` directly, bypassing axum +//! routing) +//! - Mock relay startup time (servers are started once in setup, before timing +//! begins) +//! - `HeaderMap` allocation (created once in setup, cloned cheaply per +//! iteration) + +use std::{path::PathBuf, sync::Arc, time::Duration}; + +use alloy::primitives::B256; +use axum::http::HeaderMap; +use cb_common::{pbs::GetHeaderParams, signer::random_secret, types::Chain}; +use cb_pbs::{PbsState, get_header}; +use cb_tests::{ + mock_relay::{MockRelayState, start_mock_relay_service}, + utils::{generate_mock_relay, get_pbs_static_config, to_pbs_config}, +}; +use criterion::{Criterion, black_box, criterion_group, criterion_main}; + +// Ports 19201–19205 are reserved for the microbenchmark mock relays. +const BASE_PORT: u16 = 19200; +const CHAIN: Chain = Chain::Hoodi; +const MAX_RELAYS: usize = 5; +const RELAY_COUNTS: [usize; 3] = [1, 3, MAX_RELAYS]; + +/// Benchmarks `get_header` across three relay-count variants. +/// +/// # Setup (runs once, not measured) +/// +/// All MAX_RELAYS mock relays are started up-front and shared across variants. +/// Each variant gets its own `PbsState` pointing to a different relay subset. +/// The mock relays are in-process axum servers on localhost. +/// +/// # Per-iteration (measured) +/// +/// Each call to `b.iter(|| ...)` runs `get_header()` once: +/// - Fans out HTTP requests to N mock relays concurrently +/// - Parses and validates each relay response (header data + BLS signature) +/// - Selects the highest-value bid +/// +/// `black_box(...)` prevents the compiler from optimizing away inputs or the +/// return value. Without it, the optimizer could see that the result is unused +/// and eliminate the call entirely, producing a meaningless zero measurement. +fn bench_get_header(c: &mut Criterion) { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + + // Start all mock relays once and build one PbsState per relay-count variant. + // All relays share the same MockRelayState (and therefore the same signing + // key). + let (states, params) = rt.block_on(async { + let signer = random_secret(); + let pubkey = signer.public_key(); + let mock_state = Arc::new(MockRelayState::new(CHAIN, signer)); + + let relay_clients: Vec<_> = (0..MAX_RELAYS) + .map(|i| { + let port = BASE_PORT + 1 + i as u16; + tokio::spawn(start_mock_relay_service(mock_state.clone(), port)); + generate_mock_relay(port, pubkey.clone()).expect("relay client") + }) + .collect(); + + // Give all servers time to bind before benchmarking starts. + tokio::time::sleep(Duration::from_millis(200)).await; + + let params = GetHeaderParams { slot: 0, parent_hash: B256::ZERO, pubkey }; + + // Port 0 here is the port the PBS service itself would bind to for incoming + // validator requests. We call get_header() as a function directly, so no + // PBS server is started and this port is never used. The actual relay + // endpoints are carried inside the RelayClient objects (ports 19201–19205). + let states: Vec = RELAY_COUNTS + .iter() + .map(|&n| { + let config = + to_pbs_config(CHAIN, get_pbs_static_config(0), relay_clients[..n].to_vec()); + PbsState::new(config, PathBuf::new()) + }) + .collect(); + + (states, params) + }); + + // Empty HeaderMap matches what the PBS route handler receives for requests + // without custom headers. Created once here to avoid measuring its + // allocation per iteration. + let headers = HeaderMap::new(); + + // A BenchmarkGroup groups related functions so Criterion produces a single + // comparison table and chart. All variants share the name "get_header/". + let mut group = c.benchmark_group("get_header"); + + for (i, relay_count) in RELAY_COUNTS.iter().enumerate() { + let state = states[i].clone(); + let params = params.clone(); + let headers = headers.clone(); + + // bench_function registers one timing function. The closure receives a + // `Bencher` — calling `b.iter(|| ...)` is the measured hot loop. + // Everything outside `b.iter` is setup and not timed. + group.bench_function(format!("{relay_count}_relays"), |b| { + b.iter(|| { + // block_on drives the async future to completion on the shared + // runtime. get_header takes owned args, so we clone cheap types + // (Arc-backed state, stack-sized params) on each iteration. + rt.block_on(get_header( + black_box(params.clone()), + black_box(headers.clone()), + black_box(state.clone()), + )) + .expect("get_header failed") + }) + }); + } + + group.finish(); +} + +// criterion_group! registers bench_get_header as a benchmark group named +// "benches". criterion_main! generates the main() entry point that Criterion +// uses to run them. +criterion_group!(benches, bench_get_header); +criterion_main!(benches); diff --git a/bin/Cargo.toml b/bin/Cargo.toml index d50c2932..e7a25091 100644 --- a/bin/Cargo.toml +++ b/bin/Cargo.toml @@ -18,14 +18,12 @@ tracing.workspace = true tree_hash.workspace = true tree_hash_derive.workspace = true -[[bin]] -name = "commit-boost-cli" -path = "cli.rs" - -[[bin]] -name = "commit-boost-pbs" -path = "pbs.rs" +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +serde_yaml.workspace = true +tempfile.workspace = true [[bin]] -name = "commit-boost-signer" -path = "signer.rs" +name = "commit-boost" +path = "commit-boost.rs" diff --git a/bin/cli.rs b/bin/cli.rs deleted file mode 100644 index 5512b820..00000000 --- a/bin/cli.rs +++ /dev/null @@ -1,10 +0,0 @@ -use clap::Parser; - -#[tokio::main] -async fn main() -> eyre::Result<()> { - color_eyre::install()?; - - let args = cb_cli::Args::parse(); - - args.run().await -} diff --git a/bin/commit-boost.rs b/bin/commit-boost.rs new file mode 100644 index 00000000..e424d144 --- /dev/null +++ b/bin/commit-boost.rs @@ -0,0 +1,127 @@ +use std::path::PathBuf; + +use cb_cli::docker_init::handle_docker_init; +use cb_common::{ + config::{ + LogsSettings, PBS_SERVICE_NAME, SIGNER_SERVICE_NAME, StartSignerConfig, load_pbs_config, + }, + utils::{initialize_tracing_log, print_logo, wait_for_signal}, +}; +use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; +use cb_signer::service::SigningService; +use clap::{Parser, Subcommand}; +use eyre::Result; +use tracing::{error, info}; + +/// Long about string for the CLI +const LONG_ABOUT: &str = "Commit-Boost allows Ethereum validators to safely run MEV-Boost and community-built commitment protocols"; + +/// Subcommands and global arguments for the module +#[derive(Parser, Debug)] +#[command(name = "Commit-Boost", version = commit_boost::VERSION, about, long_about = LONG_ABOUT)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand, Debug)] +enum Commands { + /// Run the PBS service + Pbs, + + /// Run the Signer service + Signer, + + /// Generate the starting docker-compose files and environment files + Init { + /// Path to config file + #[arg(long("config"))] + config_path: PathBuf, + + /// Path to output files + #[arg(short, long("output"), default_value = "./")] + output_path: PathBuf, + }, +} + +#[tokio::main] +async fn main() -> Result<()> { + // Parse the CLI arguments (currently only used for version info, more can be + // added later) + let cli = Cli::parse(); + + color_eyre::install()?; + + match cli.command { + Commands::Pbs => run_pbs_service().await?, + Commands::Signer => run_signer_service().await?, + Commands::Init { config_path, output_path } => run_init(config_path, output_path).await?, + } + + Ok(()) +} + +/// Run the PBS service +async fn run_pbs_service() -> Result<()> { + let _guard = initialize_tracing_log(PBS_SERVICE_NAME, LogsSettings::from_env_config()?); + let (pbs_config, config_path) = load_pbs_config(None).await?; + + PbsService::init_metrics(pbs_config.chain)?; + let state = PbsState::new(pbs_config, config_path); + let server = PbsService::run::<_, DefaultBuilderApi>(state); + + tokio::select! { + maybe_err = server => { + if let Err(err) = maybe_err { + error!(%err, "PBS service unexpectedly stopped"); + } + }, + _ = wait_for_signal() => { + info!("shutting down"); + } + } + Ok(()) +} + +/// Run the Signer service +async fn run_signer_service() -> Result<()> { + let _guard = initialize_tracing_log(SIGNER_SERVICE_NAME, LogsSettings::from_env_config()?); + let config = StartSignerConfig::load_from_env()?; + let server = SigningService::run(config); + + tokio::select! { + maybe_err = server => { + if let Err(err) = maybe_err { + error!(%err, "signing server unexpectedly stopped"); + } + }, + _ = wait_for_signal() => { + info!("shutting down"); + } + } + + Ok(()) +} + +async fn run_init(config_path: PathBuf, output_path: PathBuf) -> Result<()> { + print_logo(); + handle_docker_init(config_path, output_path).await +} + +#[cfg(test)] +mod tests { + use commit_boost::VERSION; + + use super::*; + + #[test] + fn version_has_v_prefix() { + assert!(VERSION.starts_with('v'), "VERSION should start with 'v', got: {VERSION}"); + } + + #[test] + fn parse_init_subcommand() { + Cli::try_parse_from(["commit-boost", "init", "--config", "/tmp/config.toml"]) + .expect("should parse init subcommand"); + } +} diff --git a/bin/pbs.rs b/bin/pbs.rs deleted file mode 100644 index 01328e4c..00000000 --- a/bin/pbs.rs +++ /dev/null @@ -1,37 +0,0 @@ -use cb_common::{ - config::{LogsSettings, PBS_MODULE_NAME, load_pbs_config}, - utils::{initialize_tracing_log, wait_for_signal}, -}; -use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; -use clap::Parser; -use eyre::Result; -use tracing::{error, info}; - -#[tokio::main] -async fn main() -> Result<()> { - let _args = cb_cli::PbsArgs::parse(); - - color_eyre::install()?; - - let _guard = initialize_tracing_log(PBS_MODULE_NAME, LogsSettings::from_env_config()?); - - let (pbs_config, config_path) = load_pbs_config(None).await?; - - PbsService::init_metrics(pbs_config.chain)?; - let state = PbsState::new(pbs_config, config_path); - let server = PbsService::run::<_, DefaultBuilderApi>(state); - - tokio::select! { - maybe_err = server => { - if let Err(err) = maybe_err { - error!(%err, "PBS service unexpectedly stopped"); - eprintln!("PBS service unexpectedly stopped: {err}"); - } - }, - _ = wait_for_signal() => { - info!("shutting down"); - } - } - - Ok(()) -} diff --git a/bin/signer.rs b/bin/signer.rs deleted file mode 100644 index e86acd60..00000000 --- a/bin/signer.rs +++ /dev/null @@ -1,34 +0,0 @@ -use cb_common::{ - config::{LogsSettings, SIGNER_MODULE_NAME, StartSignerConfig}, - utils::{initialize_tracing_log, wait_for_signal}, -}; -use cb_signer::service::SigningService; -use clap::Parser; -use eyre::Result; -use tracing::{error, info}; - -#[tokio::main] -async fn main() -> Result<()> { - let _args = cb_cli::SignerArgs::parse(); - - color_eyre::install()?; - - let _guard = initialize_tracing_log(SIGNER_MODULE_NAME, LogsSettings::from_env_config()?); - - let config = StartSignerConfig::load_from_env()?; - let server = SigningService::run(config); - - tokio::select! { - maybe_err = server => { - if let Err(err) = maybe_err { - error!(%err, "signing server unexpectedly stopped"); - eprintln!("signing server unexpectedly stopped: {err}"); - } - }, - _ = wait_for_signal() => { - info!("shutting down"); - } - } - - Ok(()) -} diff --git a/bin/src/lib.rs b/bin/src/lib.rs index dd0f52c2..487a46ef 100644 --- a/bin/src/lib.rs +++ b/bin/src/lib.rs @@ -6,7 +6,7 @@ pub mod prelude { SignedProxyDelegationBls, SignedProxyDelegationEcdsa, }, config::{ - LogsSettings, PBS_MODULE_NAME, StartCommitModuleConfig, load_builder_module_config, + LogsSettings, PBS_SERVICE_NAME, StartCommitModuleConfig, load_builder_module_config, load_commit_module_config, load_pbs_config, load_pbs_custom_config, }, signer::EcdsaSignature, @@ -24,3 +24,6 @@ pub mod prelude { } pub use tree_hash_derive::TreeHash; } + +/// Version string with a leading 'v' +pub const VERSION: &str = concat!("v", env!("CARGO_PKG_VERSION")); diff --git a/bin/tests/binary.rs b/bin/tests/binary.rs new file mode 100644 index 00000000..c000ed11 --- /dev/null +++ b/bin/tests/binary.rs @@ -0,0 +1,201 @@ +use assert_cmd::{Command, cargo}; +use cb_cli::docker_init::{CB_COMPOSE_FILE, CB_ENV_FILE}; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const MINIMAL_PBS_TOML: &str = r#" +chain = "Holesky" +[pbs] +docker_image = "ghcr.io/commit-boost/pbs:latest" +"#; + +const MINIMAL_WITH_MODULE_TOML: &str = r#" +chain = "Holesky" +[pbs] +docker_image = "ghcr.io/commit-boost/pbs:latest" + +[signer.local.loader] +key_path = "/keys/keys.json" + +[[modules]] +id = "DA_COMMIT" +type = "commit" +docker_image = "test_da_commit" +"#; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Returns a `Command` pointed at the `commit-boost` binary under test. +fn cmd() -> Command { + Command::new(cargo::cargo_bin!()) +} + +/// Writes `contents` to `cb.toml` inside `dir` and returns the path. +fn write_config(dir: &tempfile::TempDir, contents: &str) -> std::path::PathBuf { + let path = dir.path().join("cb.toml"); + std::fs::write(&path, contents).expect("write test config"); + path +} + +/// Returns a `commit-boost init` command configured with the given config and +/// output directory. +fn init_cmd(config: &std::path::Path, output_dir: &std::path::Path) -> Command { + let mut c = cmd(); + c.args([ + "init", + "--config", + config.to_str().expect("valid config path"), + "--output", + output_dir.to_str().expect("valid output dir"), + ]); + c +} + +// --------------------------------------------------------------------------- +// Binary smoke tests +// --------------------------------------------------------------------------- + +/// Tests that the binary can be run and returns a version string +#[test] +fn test_load_example_config() { + let expected_version = format!("Commit-Boost {}\n", commit_boost::VERSION); + cmd().arg("--version").assert().success().stdout(expected_version); +} + +/// Tests that the init command can be run and complains about not having +/// --config set +#[test] +fn test_run_init() { + cmd().args(["init"]).assert().failure().stderr(predicates::str::contains( + "error: the following required arguments were not provided:\n --config ", + )); +} + +/// Tests that PBS runs without CB_CONFIG being set and complains normally +#[test] +fn test_run_pbs_no_config() { + cmd() + .args(["pbs"]) + .assert() + .failure() + .stderr(predicates::str::contains("CB_CONFIG is not set")); +} + +/// Tests that Signer runs without CB_CONFIG being set and complains normally +#[test] +fn test_run_signer_no_config() { + cmd() + .args(["signer"]) + .assert() + .failure() + .stderr(predicates::str::contains("CB_CONFIG is not set")); +} + +// --------------------------------------------------------------------------- +// handle_docker_init (via `commit-boost init`) integration tests +// --------------------------------------------------------------------------- + +/// Minimal PBS-only config produces a compose file and no .env file. +#[test] +fn test_init_pbs_only_creates_compose_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let config = write_config(&dir, MINIMAL_PBS_TOML); + + init_cmd(&config, dir.path()).assert().success(); + + assert!(dir.path().join(CB_COMPOSE_FILE).exists(), "compose file should be created"); + assert!(!dir.path().join(CB_ENV_FILE).exists(), "no .env file for PBS-only config"); +} + +/// PBS-only compose file has the expected service structure. +#[test] +fn test_init_compose_file_pbs_service_structure() { + let dir = tempfile::tempdir().expect("tempdir"); + let config = write_config(&dir, MINIMAL_PBS_TOML); + + init_cmd(&config, dir.path()).assert().success(); + + let contents = + std::fs::read_to_string(dir.path().join(CB_COMPOSE_FILE)).expect("read compose file"); + let compose: serde_yaml::Value = + serde_yaml::from_str(&contents).expect("compose file is valid YAML"); + + let pbs = &compose["services"]["cb_pbs"]; + assert!(!pbs.is_null(), "cb_pbs service must exist"); + assert_eq!(pbs["image"].as_str(), Some("ghcr.io/commit-boost/pbs:latest"), "image"); + assert_eq!(pbs["container_name"].as_str(), Some("cb_pbs"), "container_name"); + + // Config file must be mounted inside the container. + let volumes = pbs["volumes"].as_sequence().expect("volumes is a list"); + assert!( + volumes.iter().any(|v| v.as_str().map_or(false, |s| s.ends_with(":/cb-config.toml:ro"))), + "config must be mounted at /cb-config.toml" + ); + + // Required environment variables must be present. + let env = &pbs["environment"]; + assert!(!env["CB_CONFIG"].is_null(), "CB_CONFIG env var must be set"); + assert!(!env["CB_PBS_ENDPOINT"].is_null(), "CB_PBS_ENDPOINT env var must be set"); + + // Port 18550 must be exposed. + let ports = pbs["ports"].as_sequence().expect("ports is a list"); + assert!( + ports.iter().any(|p| p.as_str().map_or(false, |s| s.contains("18550"))), + "port 18550 must be exposed" + ); + + // No signer service and no extra network in a PBS-only config. + assert!(compose["services"]["cb_signer"].is_null(), "cb_signer must not exist"); + assert!(compose["networks"].is_null(), "no networks for PBS-only config"); +} + +/// Config with a commit module produces both a compose file and a .env file. +#[test] +fn test_init_with_module_creates_env_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let config = write_config(&dir, MINIMAL_WITH_MODULE_TOML); + + init_cmd(&config, dir.path()).assert().success(); + + assert!(dir.path().join(CB_COMPOSE_FILE).exists(), "compose file should be created"); + assert!(dir.path().join(CB_ENV_FILE).exists(), ".env file should be created for modules"); +} + +/// .env file contains a JWT entry for the module. +#[test] +fn test_init_env_file_contains_module_jwt() { + let dir = tempfile::tempdir().expect("tempdir"); + let config = write_config(&dir, MINIMAL_WITH_MODULE_TOML); + + init_cmd(&config, dir.path()).assert().success(); + + let env_contents = + std::fs::read_to_string(dir.path().join(CB_ENV_FILE)).expect("read .env file"); + assert!(env_contents.contains("CB_JWT_DA_COMMIT="), ".env must contain module JWT"); +} + +/// Missing --config argument produces a clear error message. +#[test] +fn test_init_missing_config_flag_fails_with_message() { + cmd().args(["init"]).assert().failure().stderr(predicates::str::contains("--config")); +} + +/// Non-existent config file produces an error. +#[test] +fn test_init_nonexistent_config_file_fails() { + let dir = tempfile::tempdir().expect("tempdir"); + cmd() + .args([ + "init", + "--config", + "/nonexistent/path/cb.toml", + "--output", + dir.path().to_str().expect("valid dir"), + ]) + .assert() + .failure(); +} diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 2acc6a7b..3e713397 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -12,3 +12,7 @@ docker-compose-types.workspace = true eyre.workspace = true indexmap.workspace = true serde_yaml.workspace = true + +[dev-dependencies] +tempfile.workspace = true +toml.workspace = true diff --git a/crates/cli/src/docker_init.rs b/crates/cli/src/docker_init.rs index b535e54d..3cbde28a 100644 --- a/crates/cli/src/docker_init.rs +++ b/crates/cli/src/docker_init.rs @@ -10,11 +10,12 @@ use cb_common::{ DIRK_CA_CERT_ENV, DIRK_CERT_DEFAULT, DIRK_CERT_ENV, DIRK_DIR_SECRETS_DEFAULT, DIRK_DIR_SECRETS_ENV, DIRK_KEY_DEFAULT, DIRK_KEY_ENV, JWTS_ENV, LOGS_DIR_DEFAULT, LOGS_DIR_ENV, LogsSettings, METRICS_PORT_ENV, MODULE_ID_ENV, MODULE_JWT_ENV, ModuleKind, - PBS_ENDPOINT_ENV, PBS_MODULE_NAME, PROXY_DIR_DEFAULT, PROXY_DIR_ENV, + PBS_ENDPOINT_ENV, PBS_SERVICE_NAME, PROXY_DIR_DEFAULT, PROXY_DIR_ENV, PROXY_DIR_KEYS_DEFAULT, PROXY_DIR_KEYS_ENV, PROXY_DIR_SECRETS_DEFAULT, PROXY_DIR_SECRETS_ENV, SIGNER_DEFAULT, SIGNER_DIR_KEYS_DEFAULT, SIGNER_DIR_KEYS_ENV, SIGNER_DIR_SECRETS_DEFAULT, SIGNER_DIR_SECRETS_ENV, SIGNER_ENDPOINT_ENV, SIGNER_KEYS_ENV, - SIGNER_MODULE_NAME, SIGNER_PORT_DEFAULT, SIGNER_URL_ENV, SignerConfig, SignerType, + SIGNER_PORT_DEFAULT, SIGNER_SERVICE_NAME, SIGNER_URL_ENV, SignerConfig, SignerType, + StaticModuleConfig, }, pbs::{BUILDER_V1_API_PATH, GET_STATUS_PATH}, signer::{ProxyStore, SignerLoader}, @@ -30,217 +31,285 @@ use eyre::Result; use indexmap::IndexMap; /// Name of the docker compose file -pub(super) const CB_COMPOSE_FILE: &str = "cb.docker-compose.yml"; +pub const CB_COMPOSE_FILE: &str = "cb.docker-compose.yml"; /// Name of the envs file -pub(super) const CB_ENV_FILE: &str = ".cb.env"; +pub const CB_ENV_FILE: &str = ".cb.env"; const SIGNER_NETWORK: &str = "signer_network"; +// Info about a custom chain spec to use +struct ServiceChainSpecInfo { + // Environment variable to set for the chain spec file's path + env: (String, Option), + + // Volume for binding the chain spec file into a container + volume: Volumes, +} + +// Info about the Commit-Boost config being used to create services +struct CommitBoostConfigInfo { + // Commit-Boost config + cb_config: CommitBoostConfig, + + // Volume for binding the config file into a container + config_volume: Volumes, +} + +// Information needed to create a Commit-Boost service +struct ServiceCreationInfo { + // Info about the Commit-Boost config being used + config_info: CommitBoostConfigInfo, + + // Environment variables to write in .env file + envs: IndexMap, + + // Targets to pass to prometheus + targets: Vec, + + // Warnings that need to be shown to the user + warnings: Vec, + + // JWTs for any modules owned by this service (TODO: are we going to offload modules to the + // user instead of owning them?) + jwts: IndexMap, + + // Custom chain spec info, if any + chain_spec: Option, + + // Next available port for metrics (TODO: this should be a setting in PBS and in Signer instead + // of a universal one) + metrics_port: u16, +} + /// Builds the docker compose file for the Commit-Boost services // TODO: do more validation for paths, images, etc pub async fn handle_docker_init(config_path: PathBuf, output_dir: PathBuf) -> Result<()> { + // Initialize variables + let mut services = IndexMap::new(); println!("Initializing Commit-Boost with config file: {}", config_path.display()); - let cb_config = CommitBoostConfig::from_file(&config_path)?; - cb_config.validate().await?; + let mut service_config = ServiceCreationInfo { + config_info: CommitBoostConfigInfo { + config_volume: Volumes::Simple(format!( + "./{}:{}:ro", + config_path.display(), + CONFIG_DEFAULT + )), + cb_config: CommitBoostConfig::from_file(&config_path)?, + }, + envs: IndexMap::new(), + targets: Vec::new(), + warnings: Vec::new(), + jwts: IndexMap::new(), + chain_spec: None, + metrics_port: 9100, + }; + service_config.config_info.cb_config.validate().await?; + // Get the custom chain spec, if any let chain_spec_path = CommitBoostConfig::chain_spec_file(&config_path); - - let log_to_file = cb_config.logs.file.enabled; - let mut metrics_port = cb_config.metrics.as_ref().map(|m| m.start_port).unwrap_or_default(); - - let mut services = IndexMap::new(); - - // config volume to pass to all services - let config_volume = - Volumes::Simple(format!("./{}:{}:ro", config_path.display(), CONFIG_DEFAULT)); - let chain_spec_volume = chain_spec_path.as_ref().and_then(|p| { - // this is ok since the config has already been loaded once - let file_name = p.file_name()?.to_str()?; - Some(Volumes::Simple(format!("{}:/{}:ro", p.display(), file_name))) - }); - - let chain_spec_env = chain_spec_path.and_then(|p| { - // this is ok since the config has already been loaded once - let file_name = p.file_name()?.to_str()?; - Some(get_env_val(CHAIN_SPEC_ENV, &format!("/{file_name}"))) - }); - - let mut jwts = IndexMap::new(); - // envs to write in .env file - let mut envs = IndexMap::new(); - // targets to pass to prometheus - let mut targets = Vec::new(); - - // address for signer API communication - let signer_port = cb_config.signer.as_ref().map(|s| s.port).unwrap_or(SIGNER_PORT_DEFAULT); - let signer_server = - if let Some(SignerConfig { inner: SignerType::Remote { url }, .. }) = &cb_config.signer { - url.to_string() - } else { - format!("http://cb_signer:{signer_port}") + if let Some(spec) = chain_spec_path { + let filename = spec + .file_name() + .ok_or_else(|| eyre::eyre!("Chain spec path has no filename: {}", spec.display()))? + .to_str() + .ok_or_else(|| { + eyre::eyre!("Chain spec filename is not valid UTF-8: {}", spec.display()) + })?; + let chain_spec = ServiceChainSpecInfo { + env: get_env_val(CHAIN_SPEC_ENV, &format!("/{filename}")), + volume: Volumes::Simple(format!("{}:/{}:ro", spec.display(), filename)), }; + service_config.chain_spec = Some(chain_spec); + } - let mut warnings = Vec::new(); - - let needs_signer_module = cb_config.pbs.with_signer || - cb_config.modules.as_ref().is_some_and(|modules| { + // Set up variables + service_config.metrics_port = service_config + .config_info + .cb_config + .metrics + .as_ref() + .map(|m| m.start_port) + .unwrap_or_default(); + let needs_signer_module = service_config.config_info.cb_config.pbs.with_signer || + service_config.config_info.cb_config.modules.as_ref().is_some_and(|modules| { modules.iter().any(|module| matches!(module.kind, ModuleKind::Commit)) }); + let signer_config = if needs_signer_module { + Some(service_config.config_info.cb_config.signer.clone().ok_or_else(|| { + eyre::eyre!( + "Signer module required but no signer config provided in Commit-Boost config" + ) + })?) + } else { + None + }; + let signer_server = if let Some(SignerConfig { inner: SignerType::Remote { url }, .. }) = + &service_config.config_info.cb_config.signer + { + url.to_string() + } else { + let signer_port = service_config + .config_info + .cb_config + .signer + .as_ref() + .map(|s| s.port) + .unwrap_or(SIGNER_PORT_DEFAULT); + format!("http://cb_signer:{signer_port}") + }; // setup modules - if let Some(modules_config) = cb_config.modules { - for module in modules_config { - let module_cid = format!("cb_{}", module.id.to_lowercase()); - - let module_service = match module.kind { - // a commit module needs a JWT and access to the signer network - ModuleKind::Commit => { - let mut ports = vec![]; - - let jwt_secret = random_jwt_secret(); - let jwt_name = format!("CB_JWT_{}", module.id.to_uppercase()); - - // module ids are assumed unique, so envs dont override each other - let mut module_envs = IndexMap::from([ - get_env_val(MODULE_ID_ENV, &module.id), - get_env_val(CONFIG_ENV, CONFIG_DEFAULT), - get_env_interp(MODULE_JWT_ENV, &jwt_name), - get_env_val(SIGNER_URL_ENV, &signer_server), - ]); - - // Pass on the env variables - if let Some(envs) = module.env { - for (k, v) in envs { - module_envs.insert(k, Some(SingleValue::String(v))); - } - } - - // Set environment file - let env_file = module.env_file.map(EnvFile::Simple); - - if let Some((key, val)) = chain_spec_env.clone() { - module_envs.insert(key, val); - } - - if let Some(metrics_config) = &cb_config.metrics && - metrics_config.enabled - { - let host_endpoint = SocketAddr::from((metrics_config.host, metrics_port)); - ports.push(format!("{host_endpoint}:{metrics_port}")); - warnings - .push(format!("{module_cid} has an exported port on {metrics_port}")); - targets.push(format!("{host_endpoint}")); - let (key, val) = get_env_uval(METRICS_PORT_ENV, metrics_port as u64); - module_envs.insert(key, val); - - metrics_port += 1; - } - - if log_to_file { - let (key, val) = get_env_val(LOGS_DIR_ENV, LOGS_DIR_DEFAULT); - module_envs.insert(key, val); - } - - envs.insert(jwt_name.clone(), jwt_secret.clone()); - jwts.insert(module.id.clone(), jwt_secret); - - // networks - let module_networks = vec![SIGNER_NETWORK.to_owned()]; - - // volumes - let mut module_volumes = vec![config_volume.clone()]; - module_volumes.extend(chain_spec_volume.clone()); - module_volumes.extend(get_log_volume(&cb_config.logs, &module.id)); - - // depends_on - let mut module_dependencies = IndexMap::new(); - module_dependencies.insert("cb_signer".into(), DependsCondition { - condition: "service_healthy".into(), - }); - - Service { - container_name: Some(module_cid.clone()), - image: Some(module.docker_image), - networks: Networks::Simple(module_networks), - ports: Ports::Short(ports), - volumes: module_volumes, - environment: Environment::KvPair(module_envs), - depends_on: if let Some(SignerConfig { - inner: SignerType::Remote { .. }, - .. - }) = &cb_config.signer - { - DependsOnOptions::Simple(vec![]) - } else { - DependsOnOptions::Conditional(module_dependencies) - }, - env_file, - ..Service::default() - } - } - }; - + if let Some(ref modules_config) = service_config.config_info.cb_config.modules { + for module in modules_config.clone() { + let (module_cid, module_service) = + create_module_service(&module, signer_server.as_str(), &mut service_config)?; services.insert(module_cid, Some(module_service)); } }; // setup pbs service + let pbs_service = create_pbs_service(&mut service_config)?; + services.insert("cb_pbs".to_owned(), Some(pbs_service)); - let mut pbs_envs = IndexMap::from([get_env_val(CONFIG_ENV, CONFIG_DEFAULT)]); - let mut pbs_volumes = vec![config_volume.clone()]; + // setup signer service + if let Some(signer_config) = signer_config { + match &signer_config.inner { + SignerType::Local { loader, store } => { + let signer_service = create_signer_service_local( + &mut service_config, + &signer_config, + loader, + store, + )?; + services.insert("cb_signer".to_owned(), Some(signer_service)); + } + SignerType::Dirk { cert_path, key_path, secrets_path, ca_cert_path, store, .. } => { + let signer_service = create_signer_service_dirk( + &mut service_config, + &signer_config, + cert_path, + key_path, + secrets_path, + ca_cert_path, + store, + )?; + services.insert("cb_signer".to_owned(), Some(signer_service)); + } + SignerType::Remote { .. } => { + eyre::bail!( + "Signer module required but remote signer config provided; use a local or Dirk signer instead" + ); + } + } + } + + let mut compose = Compose::default(); - // ports + if needs_signer_module { + compose.networks.0.insert( + SIGNER_NETWORK.to_owned(), + MapOrEmpty::Map(NetworkSettings { + driver: Some("bridge".to_owned()), + ..NetworkSettings::default() + }), + ); + } + + // write compose to file + compose.services = Services(services); + let compose_path = Path::new(&output_dir).join(CB_COMPOSE_FILE); + write_compose_file(&compose, &compose_path, &service_config)?; + + // Inform user about Prometheus targets + if !service_config.targets.is_empty() { + let targets = service_config.targets.join(", "); + println!("Note: Make sure to add these targets for Prometheus to scrape: {targets}"); + println!( + "Check out the docs on how to configure Prometheus/Grafana/cAdvisor: https://commit-boost.github.io/commit-boost-client/get_started/running/metrics" + ); + } + + if service_config.envs.is_empty() { + println!("Run with:\n\tdocker compose -f {compose_path:?} up -d"); + } else { + // write envs to .env file + let env_path = Path::new(&output_dir).join(CB_ENV_FILE); + write_env_file(&service_config.envs, &env_path)?; + println!(); + println!("Run with:\n\tdocker compose --env-file {env_path:?} -f {compose_path:?} up -d"); + println!("Stop with:\n\tdocker compose --env-file {env_path:?} -f {compose_path:?} down"); + } + + Ok(()) +} + +// Creates a PBS service +fn create_pbs_service(service_config: &mut ServiceCreationInfo) -> eyre::Result { + let metrics_port = service_config.metrics_port; + let cb_config = &service_config.config_info.cb_config; + let config_volume = &service_config.config_info.config_volume; + let mut envs = IndexMap::from([get_env_val(CONFIG_ENV, CONFIG_DEFAULT)]); + let mut volumes = vec![config_volume.clone()]; + + // Bind the API to 0.0.0.0 + let container_endpoint = + SocketAddr::from((Ipv4Addr::UNSPECIFIED, cb_config.pbs.pbs_config.port)); let host_endpoint = SocketAddr::from((cb_config.pbs.pbs_config.host, cb_config.pbs.pbs_config.port)); + let (key, val) = get_env_val(PBS_ENDPOINT_ENV, &container_endpoint.to_string()); + envs.insert(key, val); + + // Exposed ports let mut ports = vec![format!("{}:{}", host_endpoint, cb_config.pbs.pbs_config.port)]; - warnings.push(format!("cb_pbs has an exported port on {}", cb_config.pbs.pbs_config.port)); + service_config + .warnings + .push(format!("cb_pbs has an exported port on {}", cb_config.pbs.pbs_config.port)); - if let Some(mux_config) = cb_config.muxes { + // Volumes for file-based mux config files + if let Some(ref mux_config) = cb_config.muxes { for mux in mux_config.muxes.iter() { if let Some((env_name, actual_path, internal_path)) = mux.loader_env()? { let (key, val) = get_env_val(&env_name, &internal_path); - pbs_envs.insert(key, val); - pbs_volumes.push(Volumes::Simple(format!("{actual_path}:{internal_path}:ro"))); + envs.insert(key, val); + volumes.push(Volumes::Simple(format!("{actual_path}:{internal_path}:ro"))); } } } - if let Some((key, val)) = chain_spec_env.clone() { - pbs_envs.insert(key, val); + // Chain spec env/volume + if let Some(spec) = &service_config.chain_spec { + envs.insert(spec.env.0.clone(), spec.env.1.clone()); + volumes.push(spec.volume.clone()); } + + // Metrics if let Some(metrics_config) = &cb_config.metrics && metrics_config.enabled { let host_endpoint = SocketAddr::from((metrics_config.host, metrics_port)); ports.push(format!("{host_endpoint}:{metrics_port}")); - warnings.push(format!("cb_pbs has an exported port on {metrics_port}")); - targets.push(format!("{host_endpoint}")); + service_config.warnings.push(format!("cb_pbs has an exported port on {metrics_port}")); + service_config.targets.push(format!("{host_endpoint}")); let (key, val) = get_env_uval(METRICS_PORT_ENV, metrics_port as u64); - pbs_envs.insert(key, val); + envs.insert(key, val); - metrics_port += 1; + service_config.metrics_port += 1; } - if log_to_file { + + // Logging + if cb_config.logs.file.enabled { let (key, val) = get_env_val(LOGS_DIR_ENV, LOGS_DIR_DEFAULT); - pbs_envs.insert(key, val); + envs.insert(key, val); } - // inside the container expose on 0.0.0.0 - let container_endpoint = - SocketAddr::from((Ipv4Addr::UNSPECIFIED, cb_config.pbs.pbs_config.port)); - let (key, val) = get_env_val(PBS_ENDPOINT_ENV, &container_endpoint.to_string()); - pbs_envs.insert(key, val); - - // volumes - pbs_volumes.extend(chain_spec_volume.clone()); - pbs_volumes.extend(get_log_volume(&cb_config.logs, PBS_MODULE_NAME)); - + // Create the service + volumes.extend(get_log_volume(&cb_config.logs, PBS_SERVICE_NAME)?); let pbs_service = Service { container_name: Some("cb_pbs".to_owned()), - image: Some(cb_config.pbs.docker_image), + image: Some(cb_config.pbs.docker_image.clone()), ports: Ports::Short(ports), - volumes: pbs_volumes, - environment: Environment::KvPair(pbs_envs), + volumes, + environment: Environment::KvPair(envs), healthcheck: Some(Healthcheck { test: Some(HealthcheckTest::Single(format!( "curl -f http://localhost:{}{}{}", @@ -256,324 +325,408 @@ pub async fn handle_docker_init(config_path: PathBuf, output_dir: PathBuf) -> Re ..Service::default() }; - services.insert("cb_pbs".to_owned(), Some(pbs_service)); + Ok(pbs_service) +} - // setup signer service - if needs_signer_module { - let Some(signer_config) = cb_config.signer else { - panic!("Signer module required but no signer config provided"); - }; +// Creates a Signer service using a local signer +fn create_signer_service_local( + service_config: &mut ServiceCreationInfo, + signer_config: &SignerConfig, + loader: &SignerLoader, + store: &Option, +) -> eyre::Result { + let cb_config = &service_config.config_info.cb_config; + let config_volume = &service_config.config_info.config_volume; + let metrics_port = service_config.metrics_port; + let mut envs = + IndexMap::from([get_env_val(CONFIG_ENV, CONFIG_DEFAULT), get_env_same(JWTS_ENV)]); + let mut volumes = vec![config_volume.clone()]; + + // Bind the API to 0.0.0.0 + let container_endpoint = SocketAddr::from((Ipv4Addr::UNSPECIFIED, signer_config.port)); + let host_endpoint = SocketAddr::from((signer_config.host, signer_config.port)); + let (key, val) = get_env_val(SIGNER_ENDPOINT_ENV, &container_endpoint.to_string()); + envs.insert(key, val); + + // Exposed ports + let mut ports = vec![format!("{}:{}", host_endpoint, signer_config.port)]; + service_config + .warnings + .push(format!("cb_signer has an exported port on {}", signer_config.port)); + + // Chain spec env/volume + if let Some(spec) = &service_config.chain_spec { + envs.insert(spec.env.0.clone(), spec.env.1.clone()); + volumes.push(spec.volume.clone()); + } - match signer_config.inner { - SignerType::Local { loader, store } => { - let mut signer_envs = IndexMap::from([ - get_env_val(CONFIG_ENV, CONFIG_DEFAULT), - get_env_same(JWTS_ENV), - ]); - - // Bind the signer API to 0.0.0.0 - let container_endpoint = - SocketAddr::from((Ipv4Addr::UNSPECIFIED, signer_config.port)); - let (key, val) = get_env_val(SIGNER_ENDPOINT_ENV, &container_endpoint.to_string()); - signer_envs.insert(key, val); - - let host_endpoint = SocketAddr::from((signer_config.host, signer_config.port)); - let mut ports = vec![format!("{}:{}", host_endpoint, signer_config.port)]; - warnings.push(format!("cb_signer has an exported port on {}", signer_config.port)); - - if let Some((key, val)) = chain_spec_env.clone() { - signer_envs.insert(key, val); - } - if let Some(metrics_config) = &cb_config.metrics && - metrics_config.enabled - { - let host_endpoint = SocketAddr::from((metrics_config.host, metrics_port)); - ports.push(format!("{host_endpoint}:{metrics_port}")); - warnings.push(format!("cb_signer has an exported port on {metrics_port}")); - targets.push(format!("{host_endpoint}")); - let (key, val) = get_env_uval(METRICS_PORT_ENV, metrics_port as u64); - signer_envs.insert(key, val); - } - if log_to_file { - let (key, val) = get_env_val(LOGS_DIR_ENV, LOGS_DIR_DEFAULT); - signer_envs.insert(key, val); - } + // Metrics + if let Some(metrics_config) = &cb_config.metrics && + metrics_config.enabled + { + let host_endpoint = SocketAddr::from((metrics_config.host, metrics_port)); + ports.push(format!("{host_endpoint}:{metrics_port}")); + service_config.warnings.push(format!("cb_signer has an exported port on {metrics_port}")); + service_config.targets.push(format!("{host_endpoint}")); + let (key, val) = get_env_uval(METRICS_PORT_ENV, metrics_port as u64); + envs.insert(key, val); + service_config.metrics_port += 1; + } - // write jwts to env - envs.insert(JWTS_ENV.into(), format_comma_separated(&jwts)); - - // volumes - let mut volumes = vec![config_volume.clone()]; - volumes.extend(chain_spec_volume.clone()); - - match loader { - SignerLoader::File { key_path } => { - volumes.push(Volumes::Simple(format!( - "{}:{}:ro", - key_path.display(), - SIGNER_DEFAULT - ))); - let (k, v) = get_env_val(SIGNER_KEYS_ENV, SIGNER_DEFAULT); - signer_envs.insert(k, v); - } - SignerLoader::ValidatorsDir { keys_path, secrets_path, format: _ } => { - volumes.push(Volumes::Simple(format!( - "{}:{}:ro", - keys_path.display(), - SIGNER_DIR_KEYS_DEFAULT - ))); - let (k, v) = get_env_val(SIGNER_DIR_KEYS_ENV, SIGNER_DIR_KEYS_DEFAULT); - signer_envs.insert(k, v); - - volumes.push(Volumes::Simple(format!( - "{}:{}:ro", - secrets_path.display(), - SIGNER_DIR_SECRETS_DEFAULT - ))); - let (k, v) = - get_env_val(SIGNER_DIR_SECRETS_ENV, SIGNER_DIR_SECRETS_DEFAULT); - signer_envs.insert(k, v); - } - }; - - if let Some(store) = store { - match store { - ProxyStore::File { proxy_dir } => { - volumes.push(Volumes::Simple(format!( - "{}:{}:rw", - proxy_dir.display(), - PROXY_DIR_DEFAULT - ))); - let (k, v) = get_env_val(PROXY_DIR_ENV, PROXY_DIR_DEFAULT); - signer_envs.insert(k, v); - } - ProxyStore::ERC2335 { keys_path, secrets_path } => { - volumes.push(Volumes::Simple(format!( - "{}:{}:rw", - keys_path.display(), - PROXY_DIR_KEYS_DEFAULT - ))); - let (k, v) = get_env_val(PROXY_DIR_KEYS_ENV, PROXY_DIR_KEYS_DEFAULT); - signer_envs.insert(k, v); - - volumes.push(Volumes::Simple(format!( - "{}:{}:rw", - secrets_path.display(), - PROXY_DIR_SECRETS_DEFAULT - ))); - let (k, v) = - get_env_val(PROXY_DIR_SECRETS_ENV, PROXY_DIR_SECRETS_DEFAULT); - signer_envs.insert(k, v); - } - } - } + // Logging + if cb_config.logs.file.enabled { + let (key, val) = get_env_val(LOGS_DIR_ENV, LOGS_DIR_DEFAULT); + envs.insert(key, val); + } + volumes.extend(get_log_volume(&cb_config.logs, SIGNER_SERVICE_NAME)?); - volumes.extend(get_log_volume(&cb_config.logs, SIGNER_MODULE_NAME)); - - // networks - let signer_networks = vec![SIGNER_NETWORK.to_owned()]; - - let signer_service = Service { - container_name: Some("cb_signer".to_owned()), - image: Some(signer_config.docker_image), - networks: Networks::Simple(signer_networks), - ports: Ports::Short(ports), - volumes, - environment: Environment::KvPair(signer_envs), - healthcheck: Some(Healthcheck { - test: Some(HealthcheckTest::Single(format!( - "curl -f http://localhost:{signer_port}/status" - ))), - interval: Some("30s".into()), - timeout: Some("5s".into()), - retries: 3, - start_interval: None, - start_period: Some("5s".into()), - disable: false, - }), - ..Service::default() - }; + // write jwts to env + service_config.envs.insert(JWTS_ENV.into(), format_comma_separated(&service_config.jwts)); - services.insert("cb_signer".to_owned(), Some(signer_service)); + // Signer loader volumes and envs + match loader { + SignerLoader::File { key_path } => { + volumes.push(Volumes::Simple(format!("{}:{}:ro", key_path.display(), SIGNER_DEFAULT))); + let (k, v) = get_env_val(SIGNER_KEYS_ENV, SIGNER_DEFAULT); + envs.insert(k, v); + } + SignerLoader::ValidatorsDir { keys_path, secrets_path, format: _ } => { + volumes.push(Volumes::Simple(format!( + "{}:{}:ro", + keys_path.display(), + SIGNER_DIR_KEYS_DEFAULT + ))); + let (k, v) = get_env_val(SIGNER_DIR_KEYS_ENV, SIGNER_DIR_KEYS_DEFAULT); + envs.insert(k, v); + + volumes.push(Volumes::Simple(format!( + "{}:{}:ro", + secrets_path.display(), + SIGNER_DIR_SECRETS_DEFAULT + ))); + let (k, v) = get_env_val(SIGNER_DIR_SECRETS_ENV, SIGNER_DIR_SECRETS_DEFAULT); + envs.insert(k, v); + } + }; + + // Proxy keystore volumes and envs + if let Some(store) = store { + match store { + ProxyStore::File { proxy_dir } => { + volumes.push(Volumes::Simple(format!( + "{}:{}:rw", + proxy_dir.display(), + PROXY_DIR_DEFAULT + ))); + let (k, v) = get_env_val(PROXY_DIR_ENV, PROXY_DIR_DEFAULT); + envs.insert(k, v); } - SignerType::Dirk { cert_path, key_path, secrets_path, ca_cert_path, store, .. } => { - let mut signer_envs = IndexMap::from([ - get_env_val(CONFIG_ENV, CONFIG_DEFAULT), - get_env_same(JWTS_ENV), - get_env_val(DIRK_CERT_ENV, DIRK_CERT_DEFAULT), - get_env_val(DIRK_KEY_ENV, DIRK_KEY_DEFAULT), - get_env_val(DIRK_DIR_SECRETS_ENV, DIRK_DIR_SECRETS_DEFAULT), - ]); - - // Bind the signer API to 0.0.0.0 - let container_endpoint = - SocketAddr::from((Ipv4Addr::UNSPECIFIED, signer_config.port)); - let (key, val) = get_env_val(SIGNER_ENDPOINT_ENV, &container_endpoint.to_string()); - signer_envs.insert(key, val); - - let host_endpoint = SocketAddr::from((signer_config.host, signer_config.port)); - let mut ports = vec![format!("{}:{}", host_endpoint, signer_config.port)]; - warnings.push(format!("cb_signer has an exported port on {}", signer_config.port)); - - if let Some((key, val)) = chain_spec_env.clone() { - signer_envs.insert(key, val); - } - if let Some(metrics_config) = &cb_config.metrics && - metrics_config.enabled - { - let host_endpoint = SocketAddr::from((metrics_config.host, metrics_port)); - ports.push(format!("{host_endpoint}:{metrics_port}")); - warnings.push(format!("cb_signer has an exported port on {metrics_port}")); - targets.push(format!("{host_endpoint}")); - let (key, val) = get_env_uval(METRICS_PORT_ENV, metrics_port as u64); - signer_envs.insert(key, val); - } - if log_to_file { - let (key, val) = get_env_val(LOGS_DIR_ENV, LOGS_DIR_DEFAULT); - signer_envs.insert(key, val); - } + ProxyStore::ERC2335 { keys_path, secrets_path } => { + volumes.push(Volumes::Simple(format!( + "{}:{}:rw", + keys_path.display(), + PROXY_DIR_KEYS_DEFAULT + ))); + let (k, v) = get_env_val(PROXY_DIR_KEYS_ENV, PROXY_DIR_KEYS_DEFAULT); + envs.insert(k, v); + + volumes.push(Volumes::Simple(format!( + "{}:{}:rw", + secrets_path.display(), + PROXY_DIR_SECRETS_DEFAULT + ))); + let (k, v) = get_env_val(PROXY_DIR_SECRETS_ENV, PROXY_DIR_SECRETS_DEFAULT); + envs.insert(k, v); + } + } + } - // write jwts to env - envs.insert(JWTS_ENV.into(), format_comma_separated(&jwts)); - - // volumes - let mut volumes = vec![ - config_volume.clone(), - Volumes::Simple(format!("{}:{}:ro", cert_path.display(), DIRK_CERT_DEFAULT)), - Volumes::Simple(format!("{}:{}:ro", key_path.display(), DIRK_KEY_DEFAULT)), - Volumes::Simple(format!( - "{}:{}", - secrets_path.display(), - DIRK_DIR_SECRETS_DEFAULT - )), - ]; - volumes.extend(chain_spec_volume.clone()); - volumes.extend(get_log_volume(&cb_config.logs, SIGNER_MODULE_NAME)); - - if let Some(ca_cert_path) = ca_cert_path { - volumes.push(Volumes::Simple(format!( - "{}:{}:ro", - ca_cert_path.display(), - DIRK_CA_CERT_DEFAULT - ))); - let (key, val) = get_env_val(DIRK_CA_CERT_ENV, DIRK_CA_CERT_DEFAULT); - signer_envs.insert(key, val); - } + // Create the service + let signer_networks = vec![SIGNER_NETWORK.to_owned()]; + let signer_service = Service { + container_name: Some("cb_signer".to_owned()), + image: Some(signer_config.docker_image.clone()), + networks: Networks::Simple(signer_networks), + ports: Ports::Short(ports), + volumes, + environment: Environment::KvPair(envs), + healthcheck: Some(Healthcheck { + test: Some(HealthcheckTest::Single(format!( + "curl -f http://localhost:{}/status", + signer_config.port, + ))), + interval: Some("30s".into()), + timeout: Some("5s".into()), + retries: 3, + start_interval: None, + start_period: Some("5s".into()), + disable: false, + }), + ..Service::default() + }; + + Ok(signer_service) +} - match store { - Some(ProxyStore::File { proxy_dir }) => { - volumes.push(Volumes::Simple(format!( - "{}:{}", - proxy_dir.display(), - PROXY_DIR_DEFAULT - ))); - let (key, val) = get_env_val(PROXY_DIR_ENV, PROXY_DIR_DEFAULT); - signer_envs.insert(key, val); - } - Some(ProxyStore::ERC2335 { .. }) => { - panic!("ERC2335 store not supported with Dirk signer"); - } - None => {} +// Creates a Signer service that's tied to Dirk +fn create_signer_service_dirk( + service_config: &mut ServiceCreationInfo, + signer_config: &SignerConfig, + cert_path: &Path, + key_path: &Path, + secrets_path: &Path, + ca_cert_path: &Option, + store: &Option, +) -> eyre::Result { + let cb_config = &service_config.config_info.cb_config; + let config_volume = &service_config.config_info.config_volume; + let metrics_port = service_config.metrics_port; + let mut envs = IndexMap::from([ + get_env_val(CONFIG_ENV, CONFIG_DEFAULT), + get_env_same(JWTS_ENV), + get_env_val(DIRK_CERT_ENV, DIRK_CERT_DEFAULT), + get_env_val(DIRK_KEY_ENV, DIRK_KEY_DEFAULT), + get_env_val(DIRK_DIR_SECRETS_ENV, DIRK_DIR_SECRETS_DEFAULT), + ]); + let mut volumes = vec![ + config_volume.clone(), + Volumes::Simple(format!("{}:{}:ro", cert_path.display(), DIRK_CERT_DEFAULT)), + Volumes::Simple(format!("{}:{}:ro", key_path.display(), DIRK_KEY_DEFAULT)), + Volumes::Simple(format!("{}:{}", secrets_path.display(), DIRK_DIR_SECRETS_DEFAULT)), + ]; + + // Bind the API to 0.0.0.0 + let container_endpoint = SocketAddr::from((Ipv4Addr::UNSPECIFIED, signer_config.port)); + let host_endpoint = SocketAddr::from((signer_config.host, signer_config.port)); + let (key, val) = get_env_val(SIGNER_ENDPOINT_ENV, &container_endpoint.to_string()); + envs.insert(key, val); + + // Exposed ports + let mut ports = vec![format!("{}:{}", host_endpoint, signer_config.port)]; + service_config + .warnings + .push(format!("cb_signer has an exported port on {}", signer_config.port)); + + // Chain spec env/volume + if let Some(spec) = &service_config.chain_spec { + envs.insert(spec.env.0.clone(), spec.env.1.clone()); + volumes.push(spec.volume.clone()); + } + + // Metrics + if let Some(metrics_config) = &cb_config.metrics && + metrics_config.enabled + { + let host_endpoint = SocketAddr::from((metrics_config.host, metrics_port)); + ports.push(format!("{host_endpoint}:{metrics_port}")); + service_config.warnings.push(format!("cb_signer has an exported port on {metrics_port}")); + service_config.targets.push(format!("{host_endpoint}")); + let (key, val) = get_env_uval(METRICS_PORT_ENV, metrics_port as u64); + envs.insert(key, val); + service_config.metrics_port += 1; + } + + // Logging + if cb_config.logs.file.enabled { + let (key, val) = get_env_val(LOGS_DIR_ENV, LOGS_DIR_DEFAULT); + envs.insert(key, val); + } + volumes.extend(get_log_volume(&cb_config.logs, SIGNER_SERVICE_NAME)?); + + // write jwts to env + service_config.envs.insert(JWTS_ENV.into(), format_comma_separated(&service_config.jwts)); + + // CA cert volume and env + if let Some(ca_cert_path) = ca_cert_path { + volumes.push(Volumes::Simple(format!( + "{}:{}:ro", + ca_cert_path.display(), + DIRK_CA_CERT_DEFAULT + ))); + let (key, val) = get_env_val(DIRK_CA_CERT_ENV, DIRK_CA_CERT_DEFAULT); + envs.insert(key, val); + } + + // Keystore volumes and envs + match store { + Some(ProxyStore::File { proxy_dir }) => { + volumes.push(Volumes::Simple(format!("{}:{}", proxy_dir.display(), PROXY_DIR_DEFAULT))); + let (key, val) = get_env_val(PROXY_DIR_ENV, PROXY_DIR_DEFAULT); + envs.insert(key, val); + } + Some(ProxyStore::ERC2335 { .. }) => { + eyre::bail!("ERC2335 proxy store is not supported with the Dirk signer"); + } + None => {} + } + + // Create the service + let signer_networks = vec![SIGNER_NETWORK.to_owned()]; + let signer_service = Service { + container_name: Some("cb_signer".to_owned()), + image: Some(signer_config.docker_image.clone()), + networks: Networks::Simple(signer_networks), + ports: Ports::Short(ports), + volumes, + environment: Environment::KvPair(envs), + healthcheck: Some(Healthcheck { + test: Some(HealthcheckTest::Single(format!( + "curl -f http://localhost:{}/status", + signer_config.port, + ))), + interval: Some("30s".into()), + timeout: Some("5s".into()), + retries: 3, + start_interval: None, + start_period: Some("5s".into()), + disable: false, + }), + ..Service::default() + }; + + Ok(signer_service) +} + +/// Creates a Commit-Boost module service +fn create_module_service( + module: &StaticModuleConfig, + signer_server: &str, + service_config: &mut ServiceCreationInfo, +) -> eyre::Result<(String, Service)> { + let cb_config = &service_config.config_info.cb_config; + let config_volume = &service_config.config_info.config_volume; + let metrics_port = service_config.metrics_port; + let module_cid = format!("cb_{}", module.id.to_lowercase()); + + let module_service = match module.kind { + // a commit module needs a JWT and access to the signer network + ModuleKind::Commit => { + let mut ports = vec![]; + + let jwt_secret = random_jwt_secret(); + let jwt_name = format!("CB_JWT_{}", module.id.to_uppercase()); + + // module ids are assumed unique, so envs dont override each other + let mut module_envs = IndexMap::from([ + get_env_val(MODULE_ID_ENV, &module.id), + get_env_val(CONFIG_ENV, CONFIG_DEFAULT), + get_env_interp(MODULE_JWT_ENV, &jwt_name), + get_env_val(SIGNER_URL_ENV, signer_server), + ]); + + // Pass on the env variables + if let Some(envs) = &module.env { + for (k, v) in envs { + module_envs.insert(k.clone(), Some(SingleValue::String(v.clone()))); } + }; - // networks - let signer_networks = vec![SIGNER_NETWORK.to_owned()]; - - let signer_service = Service { - container_name: Some("cb_signer".to_owned()), - image: Some(signer_config.docker_image), - networks: Networks::Simple(signer_networks), - ports: Ports::Short(ports), - volumes, - environment: Environment::KvPair(signer_envs), - healthcheck: Some(Healthcheck { - test: Some(HealthcheckTest::Single(format!( - "curl -f http://localhost:{signer_port}/status" - ))), - interval: Some("30s".into()), - timeout: Some("5s".into()), - retries: 3, - start_interval: None, - start_period: Some("5s".into()), - disable: false, - }), - ..Service::default() - }; + // volumes + let mut module_volumes = vec![config_volume.clone()]; + module_volumes.extend(get_log_volume(&cb_config.logs, &module.id)?); - services.insert("cb_signer".to_owned(), Some(signer_service)); + // Chain spec env/volume + if let Some(spec) = &service_config.chain_spec { + module_envs.insert(spec.env.0.clone(), spec.env.1.clone()); + module_volumes.push(spec.volume.clone()); } - SignerType::Remote { .. } => { - panic!("Signer module required but remote config provided"); + + if let Some(metrics_config) = &cb_config.metrics && + metrics_config.enabled + { + let host_endpoint = SocketAddr::from((metrics_config.host, metrics_port)); + ports.push(format!("{host_endpoint}:{metrics_port}")); + service_config + .warnings + .push(format!("{module_cid} has an exported port on {metrics_port}")); + service_config.targets.push(format!("{host_endpoint}")); + let (key, val) = get_env_uval(METRICS_PORT_ENV, metrics_port as u64); + module_envs.insert(key, val); + + service_config.metrics_port += 1; } - } - } - let mut compose = Compose::default(); + // Logging + if cb_config.logs.file.enabled { + let (key, val) = get_env_val(LOGS_DIR_ENV, LOGS_DIR_DEFAULT); + module_envs.insert(key, val); + } - if needs_signer_module { - compose.networks.0.insert( - SIGNER_NETWORK.to_owned(), - MapOrEmpty::Map(NetworkSettings { - driver: Some("bridge".to_owned()), - ..NetworkSettings::default() - }), - ); - } + // write jwts to env + service_config.envs.insert(jwt_name.clone(), jwt_secret.clone()); + service_config.jwts.insert(module.id.clone(), jwt_secret); + + // Dependencies + let mut module_dependencies = IndexMap::new(); + module_dependencies.insert("cb_signer".into(), DependsCondition { + condition: "service_healthy".into(), + }); + + // Create the service + let module_networks = vec![SIGNER_NETWORK.to_owned()]; + Service { + container_name: Some(module_cid.clone()), + image: Some(module.docker_image.clone()), + networks: Networks::Simple(module_networks), + ports: Ports::Short(ports), + volumes: module_volumes, + environment: Environment::KvPair(module_envs), + depends_on: if let Some(SignerConfig { inner: SignerType::Remote { .. }, .. }) = + &cb_config.signer + { + DependsOnOptions::Simple(vec![]) + } else { + DependsOnOptions::Conditional(module_dependencies) + }, + env_file: module.env_file.clone().map(EnvFile::Simple), + ..Service::default() + } + } + }; - compose.services = Services(services); + Ok((module_cid, module_service)) +} - // write compose to file - let compose_str = serde_yaml::to_string(&compose)?; - let compose_path = Path::new(&output_dir).join(CB_COMPOSE_FILE); - std::fs::write(&compose_path, compose_str)?; - if !warnings.is_empty() { +/// Writes the docker compose file to disk and prints any warnings +fn write_compose_file( + compose: &Compose, + output_path: &Path, + service_config: &ServiceCreationInfo, +) -> Result<()> { + let compose_str = serde_yaml::to_string(compose)?; + std::fs::write(output_path, compose_str)?; + if !service_config.warnings.is_empty() { println!(); - for exposed_port in warnings { + for exposed_port in &service_config.warnings { println!("Warning: {exposed_port}"); } println!() } // if file logging is enabled, warn about permissions + let cb_config = &service_config.config_info.cb_config; if cb_config.logs.file.enabled { - let log_dir = cb_config.logs.file.dir_path; + let log_dir = &cb_config.logs.file.dir_path; println!( "Warning: file logging is enabled, you may need to update permissions for the logs directory. e.g. with:\n\t`sudo chown -R 10001:10001 {}`", log_dir.display() ); println!() } + println!("Docker Compose file written to: {output_path:?}"); + Ok(()) +} - println!("Docker Compose file written to: {compose_path:?}"); - - // write prometheus targets to file - if !targets.is_empty() { - let targets = targets.join(", "); - println!("Note: Make sure to add these targets for Prometheus to scrape: {targets}"); - println!( - "Check out the docs on how to configure Prometheus/Grafana/cAdvisor: https://commit-boost.github.io/commit-boost-client/get_started/running/metrics" - ); - } - - if envs.is_empty() { - println!("Run with:\n\tdocker compose -f {compose_path:?} up -d"); - } else { - // write envs to .env file - let envs_str = { - let mut envs_str = String::new(); - for (k, v) in envs { - envs_str.push_str(&format!("{k}={v}\n")); - } - envs_str - }; - let env_path = Path::new(&output_dir).join(CB_ENV_FILE); - std::fs::write(&env_path, envs_str)?; - println!("Env file written to: {env_path:?}"); - - println!(); - println!("Run with:\n\tdocker compose --env-file {env_path:?} -f {compose_path:?} up -d"); - println!("Stop with:\n\tdocker compose --env-file {env_path:?} -f {compose_path:?} down"); - } - +/// Writes the envs to a .env file +fn write_env_file(envs: &IndexMap, output_path: &Path) -> Result<()> { + let envs_str = { + let mut envs_str = String::new(); + for (k, v) in envs { + envs_str.push_str(&format!("{k}={v}\n")); + } + envs_str + }; + std::fs::write(output_path, envs_str)?; + println!("Env file written to: {output_path:?}"); Ok(()) } @@ -600,18 +753,667 @@ fn get_env_uval(k: &str, v: u64) -> (String, Option) { // (k.into(), Some(SingleValue::Bool(v))) // } -fn get_log_volume(config: &LogsSettings, module_id: &str) -> Option { - config.file.enabled.then_some({ - let p = config.file.dir_path.join(module_id.to_lowercase()); - Volumes::Simple(format!( - "{}:{}", - p.to_str().expect("could not convert pathbuf to str"), - LOGS_DIR_DEFAULT - )) - }) +fn get_log_volume(config: &LogsSettings, module_id: &str) -> eyre::Result> { + if !config.file.enabled { + return Ok(None); + } + let p = config.file.dir_path.join(module_id.to_lowercase()); + let host_path = p + .to_str() + .ok_or_else(|| eyre::eyre!("Log directory path is not valid UTF-8: {}", p.display()))?; + Ok(Some(Volumes::Simple(format!("{host_path}:{LOGS_DIR_DEFAULT}")))) } /// Formats as a comma separated list of key=value fn format_comma_separated(map: &IndexMap) -> String { map.iter().map(|(k, v)| format!("{k}={v}")).collect::>().join(",") } + +#[cfg(test)] +mod tests { + use cb_common::{ + config::{ + CommitBoostConfig, FileLogSettings, LogsSettings, MetricsConfig, StdoutLogSettings, + }, + signer::{ProxyStore, SignerLoader}, + }; + use docker_compose_types::{Environment, Ports, SingleValue, Volumes}; + + use super::*; + + // ------------------------------------------------------------------------- + // Shared test fixtures + // ------------------------------------------------------------------------- + + fn logs_disabled() -> LogsSettings { + LogsSettings::default() + } + + fn logs_enabled(dir: &str) -> LogsSettings { + LogsSettings { + stdout: StdoutLogSettings::default(), + file: FileLogSettings { + enabled: true, + dir_path: dir.into(), + ..FileLogSettings::default() + }, + } + } + + /// Deserialize a minimal PBS-only `CommitBoostConfig` from inline TOML. + /// No relays, so `validate()` won't make network calls. + fn minimal_cb_config() -> CommitBoostConfig { + toml::from_str( + r#" + chain = "Holesky" + [pbs] + docker_image = "ghcr.io/commit-boost/pbs:latest" + "#, + ) + .expect("valid minimal test config") + } + + fn minimal_service_config() -> ServiceCreationInfo { + ServiceCreationInfo { + config_info: CommitBoostConfigInfo { + cb_config: minimal_cb_config(), + config_volume: Volumes::Simple("./cb.toml:/cb.toml:ro".into()), + }, + envs: IndexMap::new(), + targets: Vec::new(), + warnings: Vec::new(), + jwts: IndexMap::new(), + chain_spec: None, + metrics_port: 9100, + } + } + + fn metrics_config() -> MetricsConfig { + MetricsConfig { + enabled: true, + host: "127.0.0.1".parse().expect("valid IP"), + start_port: 9100, + } + } + + // ------------------------------------------------------------------------- + // Service inspection helpers + // ------------------------------------------------------------------------- + + fn env_str(service: &Service, key: &str) -> Option { + match &service.environment { + Environment::KvPair(map) => map.get(key).and_then(|v| match v { + Some(SingleValue::String(s)) => Some(s.clone()), + _ => None, + }), + _ => None, + } + } + + fn env_u64(service: &Service, key: &str) -> Option { + match &service.environment { + Environment::KvPair(map) => map.get(key).and_then(|v| match v { + Some(SingleValue::Unsigned(n)) => Some(*n), + _ => None, + }), + _ => None, + } + } + + fn has_env_key(service: &Service, key: &str) -> bool { + match &service.environment { + Environment::KvPair(map) => map.contains_key(key), + _ => false, + } + } + + fn has_volume(service: &Service, substr: &str) -> bool { + service.volumes.iter().any(|v| matches!(v, Volumes::Simple(s) if s.contains(substr))) + } + + fn has_port(service: &Service, substr: &str) -> bool { + match &service.ports { + Ports::Short(ports) => ports.iter().any(|p| p.contains(substr)), + _ => false, + } + } + + // --- get_env_val --- + + #[test] + fn test_get_env_val_returns_string_pair() { + let (key, val) = get_env_val("MY_KEY", "my_value"); + assert_eq!(key, "MY_KEY"); + assert_eq!(val, Some(SingleValue::String("my_value".into()))); + } + + #[test] + fn test_get_env_val_empty_value() { + let (key, val) = get_env_val("EMPTY", ""); + assert_eq!(key, "EMPTY"); + assert_eq!(val, Some(SingleValue::String("".into()))); + } + + // --- get_env_uval --- + + #[test] + fn test_get_env_uval_returns_unsigned_pair() { + let (key, val) = get_env_uval("PORT", 9100); + assert_eq!(key, "PORT"); + assert_eq!(val, Some(SingleValue::Unsigned(9100))); + } + + // --- get_env_same --- + + #[test] + fn test_get_env_same_interpolates_self() { + let (key, val) = get_env_same("JWTS_ENV"); + assert_eq!(key, "JWTS_ENV"); + assert_eq!(val, Some(SingleValue::String("${JWTS_ENV}".into()))); + } + + // --- get_env_interp --- + + #[test] + fn test_get_env_interp_different_key_and_var() { + let (key, val) = get_env_interp("MODULE_JWT_ENV", "CB_JWT_MY_MODULE"); + assert_eq!(key, "MODULE_JWT_ENV"); + assert_eq!(val, Some(SingleValue::String("${CB_JWT_MY_MODULE}".into()))); + } + + // --- format_comma_separated --- + + #[test] + fn test_format_comma_separated_empty() { + let map = IndexMap::new(); + assert_eq!(format_comma_separated(&map), ""); + } + + #[test] + fn test_format_comma_separated_single_entry() { + let mut map = IndexMap::new(); + map.insert(ModuleId::from("module_a".to_owned()), "secret123".into()); + assert_eq!(format_comma_separated(&map), "module_a=secret123"); + } + + #[test] + fn test_format_comma_separated_multiple_entries_preserves_order() { + let mut map = IndexMap::new(); + map.insert(ModuleId::from("module_a".to_owned()), "jwt_a".into()); + map.insert(ModuleId::from("module_b".to_owned()), "jwt_b".into()); + map.insert(ModuleId::from("module_c".to_owned()), "jwt_c".into()); + assert_eq!(format_comma_separated(&map), "module_a=jwt_a,module_b=jwt_b,module_c=jwt_c"); + } + + // --- get_log_volume --- + + #[test] + fn test_get_log_volume_disabled_returns_none() -> eyre::Result<()> { + let logs = logs_disabled(); + let result = get_log_volume(&logs, "cb_pbs")?; + assert!(result.is_none()); + Ok(()) + } + + #[test] + fn test_get_log_volume_enabled_returns_correct_volume() -> eyre::Result<()> { + let logs = logs_enabled("/var/log/commit-boost"); + let result = get_log_volume(&logs, "cb_pbs")?; + let volume = result.expect("expected a volume when file logging is enabled"); + assert_eq!( + volume, + Volumes::Simple(format!("/var/log/commit-boost/cb_pbs:{LOGS_DIR_DEFAULT}")) + ); + Ok(()) + } + + #[test] + fn test_get_log_volume_lowercases_module_id() -> eyre::Result<()> { + let logs = logs_enabled("/logs"); + let result = get_log_volume(&logs, "MY_MODULE")?; + let volume = result.expect("expected a volume when file logging is enabled"); + assert_eq!(volume, Volumes::Simple(format!("/logs/my_module:{LOGS_DIR_DEFAULT}"))); + Ok(()) + } + + #[test] + fn test_get_log_volume_enabled_with_nested_dir() -> eyre::Result<()> { + let logs = logs_enabled("/home/user/cb/logs"); + let result = get_log_volume(&logs, "cb_signer")?; + let volume = result.expect("expected a volume when file logging is enabled"); + assert_eq!( + volume, + Volumes::Simple(format!("/home/user/cb/logs/cb_signer:{LOGS_DIR_DEFAULT}")) + ); + Ok(()) + } + + // ------------------------------------------------------------------------- + // write_env_file + // ------------------------------------------------------------------------- + + #[test] + fn test_write_env_file_empty_map() -> eyre::Result<()> { + let dir = tempfile::tempdir()?; + let path = dir.path().join(".cb.env"); + write_env_file(&IndexMap::new(), &path)?; + let contents = std::fs::read_to_string(&path)?; + assert_eq!(contents, ""); + Ok(()) + } + + #[test] + fn test_write_env_file_single_entry() -> eyre::Result<()> { + let dir = tempfile::tempdir()?; + let path = dir.path().join(".cb.env"); + let mut map = IndexMap::new(); + map.insert("MY_KEY".to_owned(), "my_value".to_owned()); + write_env_file(&map, &path)?; + let contents = std::fs::read_to_string(&path)?; + assert_eq!(contents, "MY_KEY=my_value\n"); + Ok(()) + } + + #[test] + fn test_write_env_file_multiple_entries_preserves_order() -> eyre::Result<()> { + let dir = tempfile::tempdir()?; + let path = dir.path().join(".cb.env"); + let mut map = IndexMap::new(); + map.insert("KEY_A".to_owned(), "val_a".to_owned()); + map.insert("KEY_B".to_owned(), "val_b".to_owned()); + map.insert("KEY_C".to_owned(), "val_c".to_owned()); + write_env_file(&map, &path)?; + let contents = std::fs::read_to_string(&path)?; + assert_eq!(contents, "KEY_A=val_a\nKEY_B=val_b\nKEY_C=val_c\n"); + Ok(()) + } + + // ------------------------------------------------------------------------- + // write_compose_file + // ------------------------------------------------------------------------- + + #[test] + fn test_write_compose_file_creates_valid_yaml() -> eyre::Result<()> { + let dir = tempfile::tempdir()?; + let path = dir.path().join(CB_COMPOSE_FILE); + let compose = docker_compose_types::Compose::default(); + let service_config = minimal_service_config(); + write_compose_file(&compose, &path, &service_config)?; + assert!(path.exists()); + let contents = std::fs::read_to_string(&path)?; + assert!(!contents.is_empty()); + Ok(()) + } + + // ------------------------------------------------------------------------- + // create_pbs_service + // ------------------------------------------------------------------------- + + #[test] + fn test_create_pbs_service_basic() -> eyre::Result<()> { + let mut sc = minimal_service_config(); + let service = create_pbs_service(&mut sc)?; + + assert_eq!(service.container_name.as_deref(), Some("cb_pbs")); + assert_eq!(service.image.as_deref(), Some("ghcr.io/commit-boost/pbs:latest")); + assert!(env_str(&service, CONFIG_ENV).is_some()); + assert!(env_str(&service, PBS_ENDPOINT_ENV).is_some()); + assert!(service.healthcheck.is_some()); + Ok(()) + } + + #[test] + fn test_create_pbs_service_exposes_pbs_port() -> eyre::Result<()> { + let mut sc = minimal_service_config(); + let service = create_pbs_service(&mut sc)?; + // Default PBS port is 18550 + assert!(has_port(&service, "18550")); + Ok(()) + } + + #[test] + fn test_create_pbs_service_with_metrics() -> eyre::Result<()> { + let mut sc = minimal_service_config(); + sc.config_info.cb_config.metrics = Some(metrics_config()); + sc.metrics_port = 9100; + let service = create_pbs_service(&mut sc)?; + + assert_eq!(env_u64(&service, METRICS_PORT_ENV), Some(9100)); + assert!(has_port(&service, "9100")); + // port counter incremented + assert_eq!(sc.metrics_port, 9101); + // target added for prometheus + assert!(!sc.targets.is_empty()); + Ok(()) + } + + #[test] + fn test_create_pbs_service_with_file_logging() -> eyre::Result<()> { + let mut sc = minimal_service_config(); + sc.config_info.cb_config.logs = logs_enabled("/var/log/cb"); + let service = create_pbs_service(&mut sc)?; + + assert!(env_str(&service, LOGS_DIR_ENV).is_some()); + assert!(has_volume(&service, "pbs")); + Ok(()) + } + + #[test] + fn test_create_pbs_service_with_chain_spec() -> eyre::Result<()> { + let mut sc = minimal_service_config(); + sc.chain_spec = Some(ServiceChainSpecInfo { + env: get_env_val(CHAIN_SPEC_ENV, "/chain.json"), + volume: Volumes::Simple("/host/chain.json:/chain.json:ro".into()), + }); + let service = create_pbs_service(&mut sc)?; + + assert_eq!(env_str(&service, CHAIN_SPEC_ENV), Some("/chain.json".into())); + assert!(has_volume(&service, "chain.json")); + Ok(()) + } + + #[test] + fn test_create_pbs_service_no_metrics_no_metrics_env() -> eyre::Result<()> { + let mut sc = minimal_service_config(); + let service = create_pbs_service(&mut sc)?; + assert!(!has_env_key(&service, METRICS_PORT_ENV)); + Ok(()) + } + + // ------------------------------------------------------------------------- + // create_signer_service_local + // ------------------------------------------------------------------------- + + fn local_signer_config() -> SignerConfig { + toml::from_str( + r#" + [local.loader] + key_path = "/keys/keys.json" + "#, + ) + .expect("valid local signer config") + } + + #[test] + fn test_create_signer_service_local_file_loader() -> eyre::Result<()> { + let mut sc = minimal_service_config(); + let signer_config = local_signer_config(); + let loader = SignerLoader::File { key_path: "/keys/keys.json".into() }; + let service = create_signer_service_local(&mut sc, &signer_config, &loader, &None)?; + + assert_eq!(service.container_name.as_deref(), Some("cb_signer")); + assert!(env_str(&service, SIGNER_KEYS_ENV).is_some()); + assert!(has_volume(&service, "keys.json")); + Ok(()) + } + + #[test] + fn test_create_signer_service_local_validators_dir_loader() -> eyre::Result<()> { + let mut sc = minimal_service_config(); + let signer_config = local_signer_config(); + let loader = SignerLoader::ValidatorsDir { + keys_path: "/keys".into(), + secrets_path: "/secrets".into(), + format: cb_common::signer::ValidatorKeysFormat::Lighthouse, + }; + let service = create_signer_service_local(&mut sc, &signer_config, &loader, &None)?; + + assert!(env_str(&service, SIGNER_DIR_KEYS_ENV).is_some()); + assert!(env_str(&service, SIGNER_DIR_SECRETS_ENV).is_some()); + assert!(has_volume(&service, "/keys")); + assert!(has_volume(&service, "/secrets")); + Ok(()) + } + + #[test] + fn test_create_signer_service_local_with_file_proxy_store() -> eyre::Result<()> { + let mut sc = minimal_service_config(); + let signer_config = local_signer_config(); + let loader = SignerLoader::File { key_path: "/keys/keys.json".into() }; + let store = Some(ProxyStore::File { proxy_dir: "/proxies".into() }); + let service = create_signer_service_local(&mut sc, &signer_config, &loader, &store)?; + + assert!(env_str(&service, PROXY_DIR_ENV).is_some()); + assert!(has_volume(&service, "/proxies")); + Ok(()) + } + + #[test] + fn test_create_signer_service_local_with_erc2335_proxy_store() -> eyre::Result<()> { + let mut sc = minimal_service_config(); + let signer_config = local_signer_config(); + let loader = SignerLoader::File { key_path: "/keys/keys.json".into() }; + let store = Some(ProxyStore::ERC2335 { + keys_path: "/proxy/keys".into(), + secrets_path: "/proxy/secrets".into(), + }); + let service = create_signer_service_local(&mut sc, &signer_config, &loader, &store)?; + + assert!(env_str(&service, PROXY_DIR_KEYS_ENV).is_some()); + assert!(env_str(&service, PROXY_DIR_SECRETS_ENV).is_some()); + assert!(has_volume(&service, "/proxy/keys")); + assert!(has_volume(&service, "/proxy/secrets")); + Ok(()) + } + + #[test] + fn test_create_signer_service_local_jwts_written_to_envs() -> eyre::Result<()> { + let mut sc = minimal_service_config(); + sc.jwts.insert(ModuleId::from("MY_MODULE".to_owned()), "jwt_secret_abc".into()); + let signer_config = local_signer_config(); + let loader = SignerLoader::File { key_path: "/keys/keys.json".into() }; + create_signer_service_local(&mut sc, &signer_config, &loader, &None)?; + + // JWTS_ENV written as comma-separated to service_config.envs + let jwts_val = sc.envs.get(JWTS_ENV).expect("JWTS_ENV must be set in envs"); + assert!(jwts_val.contains("MY_MODULE=jwt_secret_abc")); + Ok(()) + } + + // ------------------------------------------------------------------------- + // create_signer_service_dirk + // ------------------------------------------------------------------------- + + fn dirk_signer_config() -> SignerConfig { + toml::from_str( + r#" + docker_image = "commitboost_signer" + [dirk] + cert_path = "/certs/client.crt" + key_path = "/certs/client.key" + secrets_path = "/dirk_secrets" + [[dirk.hosts]] + url = "https://gateway.dirk.url" + wallets = ["wallet1"] + "#, + ) + .expect("valid dirk signer config") + } + + #[test] + fn test_create_signer_service_dirk_basic() -> eyre::Result<()> { + let mut sc = minimal_service_config(); + let signer_config = dirk_signer_config(); + let service = create_signer_service_dirk( + &mut sc, + &signer_config, + Path::new("/certs/client.crt"), + Path::new("/certs/client.key"), + Path::new("/dirk_secrets"), + &None, + &None, + )?; + + assert_eq!(service.container_name.as_deref(), Some("cb_signer")); + assert!(env_str(&service, DIRK_CERT_ENV).is_some()); + assert!(env_str(&service, DIRK_KEY_ENV).is_some()); + assert!(env_str(&service, DIRK_DIR_SECRETS_ENV).is_some()); + assert!(has_volume(&service, "client.crt")); + assert!(has_volume(&service, "client.key")); + assert!(has_volume(&service, "dirk_secrets")); + Ok(()) + } + + #[test] + fn test_create_signer_service_dirk_with_ca_cert() -> eyre::Result<()> { + let mut sc = minimal_service_config(); + let signer_config = dirk_signer_config(); + let ca_cert = Some(PathBuf::from("/certs/ca.crt")); + let service = create_signer_service_dirk( + &mut sc, + &signer_config, + Path::new("/certs/client.crt"), + Path::new("/certs/client.key"), + Path::new("/dirk_secrets"), + &ca_cert, + &None, + )?; + + assert!(env_str(&service, DIRK_CA_CERT_ENV).is_some()); + assert!(has_volume(&service, "ca.crt")); + Ok(()) + } + + #[test] + fn test_create_signer_service_dirk_without_ca_cert() -> eyre::Result<()> { + let mut sc = minimal_service_config(); + let signer_config = dirk_signer_config(); + let service = create_signer_service_dirk( + &mut sc, + &signer_config, + Path::new("/certs/client.crt"), + Path::new("/certs/client.key"), + Path::new("/dirk_secrets"), + &None, + &None, + )?; + + assert!(!has_env_key(&service, DIRK_CA_CERT_ENV)); + assert!(!has_volume(&service, "ca.crt")); + Ok(()) + } + + #[test] + fn test_create_signer_service_dirk_with_file_proxy_store() -> eyre::Result<()> { + let mut sc = minimal_service_config(); + let signer_config = dirk_signer_config(); + let store = Some(ProxyStore::File { proxy_dir: "/proxies".into() }); + let service = create_signer_service_dirk( + &mut sc, + &signer_config, + Path::new("/certs/client.crt"), + Path::new("/certs/client.key"), + Path::new("/dirk_secrets"), + &None, + &store, + )?; + + assert!(env_str(&service, PROXY_DIR_ENV).is_some()); + assert!(has_volume(&service, "/proxies")); + Ok(()) + } + + #[test] + fn test_create_signer_service_dirk_erc2335_store_returns_error() { + let mut sc = minimal_service_config(); + let signer_config = dirk_signer_config(); + let store = Some(ProxyStore::ERC2335 { + keys_path: "/proxy/keys".into(), + secrets_path: "/proxy/secrets".into(), + }); + let result = create_signer_service_dirk( + &mut sc, + &signer_config, + Path::new("/certs/client.crt"), + Path::new("/certs/client.key"), + Path::new("/dirk_secrets"), + &None, + &store, + ); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("ERC2335")); + } + + // ------------------------------------------------------------------------- + // create_module_service + // ------------------------------------------------------------------------- + + fn commit_module() -> StaticModuleConfig { + toml::from_str( + r#" + id = "DA_COMMIT" + type = "commit" + docker_image = "test_da_commit" + "#, + ) + .expect("valid module config") + } + + #[test] + fn test_create_module_service_container_name_format() -> eyre::Result<()> { + let module = commit_module(); + let mut sc = minimal_service_config(); + let (cid, _) = create_module_service(&module, "http://cb_signer:20000", &mut sc)?; + assert_eq!(cid, "cb_da_commit"); + Ok(()) + } + + #[test] + fn test_create_module_service_sets_required_envs() -> eyre::Result<()> { + let module = commit_module(); + let mut sc = minimal_service_config(); + let (_, service) = create_module_service(&module, "http://cb_signer:20000", &mut sc)?; + + assert!(env_str(&service, MODULE_ID_ENV).is_some()); + assert!(env_str(&service, CONFIG_ENV).is_some()); + assert!(env_str(&service, SIGNER_URL_ENV) == Some("http://cb_signer:20000".into())); + Ok(()) + } + + #[test] + fn test_create_module_service_jwt_written_to_service_config_envs() -> eyre::Result<()> { + let module = commit_module(); + let mut sc = minimal_service_config(); + create_module_service(&module, "http://cb_signer:20000", &mut sc)?; + + // JWT env var should be in the outer service_config.envs (for .env file) + let jwt_key = format!("CB_JWT_{}", "DA_COMMIT".to_uppercase()); + assert!(sc.envs.contains_key(&jwt_key)); + // and also recorded in jwts map + assert!(sc.jwts.contains_key(&ModuleId::from("DA_COMMIT".to_owned()))); + Ok(()) + } + + #[test] + fn test_create_module_service_custom_env_forwarded() -> eyre::Result<()> { + let mut module = commit_module(); + let mut env_map = std::collections::HashMap::new(); + env_map.insert("SOME_ENV_VAR".to_owned(), "some_value".to_owned()); + module.env = Some(env_map); + + let mut sc = minimal_service_config(); + let (_, service) = create_module_service(&module, "http://cb_signer:20000", &mut sc)?; + + assert_eq!(env_str(&service, "SOME_ENV_VAR"), Some("some_value".into())); + Ok(()) + } + + #[test] + fn test_create_module_service_depends_on_signer() -> eyre::Result<()> { + let module = commit_module(); + let mut sc = minimal_service_config(); + let (_, service) = create_module_service(&module, "http://cb_signer:20000", &mut sc)?; + + match &service.depends_on { + docker_compose_types::DependsOnOptions::Conditional(deps) => { + assert!(deps.contains_key("cb_signer")); + } + docker_compose_types::DependsOnOptions::Simple(deps) => { + // Remote signer path returns empty depends_on — but this is a local signer + // config (signer is None), so it still depends on cb_signer + assert!(deps.is_empty(), "unexpected empty depends_on for local signer"); + } + } + Ok(()) + } +} diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 738285e1..7257d53b 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -1,68 +1 @@ -use std::path::PathBuf; - -use cb_common::utils::print_logo; -use clap::{Parser, Subcommand}; - -mod docker_init; - -/// Version string with a leading 'v' -const VERSION: &str = concat!("v", env!("CARGO_PKG_VERSION")); - -#[derive(Parser, Debug)] -#[command(version = VERSION, about, long_about = LONG_ABOUT, name = "commit-boost-cli")] -pub struct Args { - #[command(subcommand)] - pub cmd: Command, -} - -#[derive(Debug, Subcommand)] -pub enum Command { - /// Generate the starting docker-compose file - Init { - /// Path to config file - #[arg(long("config"))] - config_path: PathBuf, - - /// Path to output files - #[arg(short, long("output"), default_value = "./")] - output_path: PathBuf, - }, -} - -impl Args { - pub async fn run(self) -> eyre::Result<()> { - print_logo(); - - match self.cmd { - Command::Init { config_path, output_path } => { - docker_init::handle_docker_init(config_path, output_path).await - } - } - } -} - -const LONG_ABOUT: &str = "Commit-Boost allows Ethereum validators to safely run MEV-Boost and community-built commitment protocols"; - -#[derive(Parser, Debug)] -#[command(version = VERSION, about, long_about = LONG_ABOUT, name = "commit-boost-pbs")] -pub struct PbsArgs; - -#[derive(Parser, Debug)] -#[command(version = VERSION, about, long_about = LONG_ABOUT, name = "commit-boost-signer")] -pub struct SignerArgs; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn version_has_v_prefix() { - assert!(VERSION.starts_with('v'), "VERSION should start with 'v', got: {VERSION}"); - } - - #[test] - fn parse_init_subcommand() { - Args::try_parse_from(["commit-boost-cli", "init", "--config", "/tmp/config.toml"]) - .expect("should parse init subcommand"); - } -} +pub mod docker_init; diff --git a/crates/common/src/config/constants.rs b/crates/common/src/config/constants.rs index 67248596..a2942f3a 100644 --- a/crates/common/src/config/constants.rs +++ b/crates/common/src/config/constants.rs @@ -17,7 +17,7 @@ pub const LOGS_DIR_DEFAULT: &str = "/var/logs/commit-boost"; ///////////////////////// PBS ///////////////////////// pub const PBS_IMAGE_DEFAULT: &str = "ghcr.io/commit-boost/pbs:latest"; -pub const PBS_MODULE_NAME: &str = "pbs"; +pub const PBS_SERVICE_NAME: &str = "pbs"; /// Where to receive BuilderAPI calls from beacon node pub const PBS_ENDPOINT_ENV: &str = "CB_PBS_ENDPOINT"; @@ -27,7 +27,7 @@ pub const MUX_PATH_ENV: &str = "CB_MUX_PATH"; ///////////////////////// SIGNER ///////////////////////// pub const SIGNER_IMAGE_DEFAULT: &str = "ghcr.io/commit-boost/signer:latest"; -pub const SIGNER_MODULE_NAME: &str = "signer"; +pub const SIGNER_SERVICE_NAME: &str = "signer"; /// Where the signer module should open the server pub const SIGNER_ENDPOINT_ENV: &str = "CB_SIGNER_ENDPOINT"; diff --git a/crates/common/src/config/mod.rs b/crates/common/src/config/mod.rs index 8ba35f42..340bb888 100644 --- a/crates/common/src/config/mod.rs +++ b/crates/common/src/config/mod.rs @@ -41,7 +41,7 @@ pub struct CommitBoostConfig { impl CommitBoostConfig { /// Validate config pub async fn validate(&self) -> Result<()> { - self.pbs.pbs_config.validate(self.chain).await?; + self.pbs.validate(self.chain).await?; if let Some(signer) = &self.signer { signer.validate().await?; } diff --git a/crates/common/src/config/module.rs b/crates/common/src/config/module.rs index 6f5fdd3e..332560eb 100644 --- a/crates/common/src/config/module.rs +++ b/crates/common/src/config/module.rs @@ -14,14 +14,14 @@ use crate::{ types::{Chain, Jwt, ModuleId}, }; -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize, Clone, Copy)] pub enum ModuleKind { #[serde(alias = "commit")] Commit, } /// Static module config from config file -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize, Clone)] pub struct StaticModuleConfig { /// Unique id of the module pub id: ModuleId, diff --git a/crates/common/src/config/pbs.rs b/crates/common/src/config/pbs.rs index 759b478f..1021815c 100644 --- a/crates/common/src/config/pbs.rs +++ b/crates/common/src/config/pbs.rs @@ -11,19 +11,20 @@ use alloy::{ primitives::{U256, utils::format_ether}, providers::{Provider, ProviderBuilder}, }; +use docker_image::DockerImage; use eyre::{Result, ensure}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use url::Url; use super::{ CommitBoostConfig, HTTP_TIMEOUT_SECONDS_DEFAULT, PBS_ENDPOINT_ENV, RuntimeMuxConfig, - constants::PBS_IMAGE_DEFAULT, load_optional_env_var, + load_optional_env_var, }; use crate::{ commit::client::SignerClient, config::{ - CONFIG_ENV, MODULE_JWT_ENV, MuxKeysLoader, PBS_MODULE_NAME, PbsMuxes, SIGNER_URL_ENV, - load_env_var, load_file_from_env, + CONFIG_ENV, MODULE_JWT_ENV, MuxKeysLoader, PBS_IMAGE_DEFAULT, PBS_SERVICE_NAME, PbsMuxes, + SIGNER_URL_ENV, load_env_var, load_file_from_env, }, pbs::{ DEFAULT_PBS_PORT, DEFAULT_REGISTRY_REFRESH_SECONDS, DefaultTimeout, LATE_IN_SLOT_TIME_MS, @@ -218,6 +219,21 @@ pub struct StaticPbsConfig { pub with_signer: bool, } +impl StaticPbsConfig { + /// Validate static pbs config + pub async fn validate(&self, chain: Chain) -> Result<()> { + // The Docker tag must parse + ensure!(!self.docker_image.is_empty(), "Docker image is empty"); + ensure!( + DockerImage::parse(&self.docker_image).is_ok(), + format!("Invalid Docker image: {}", self.docker_image) + ); + + // Validate the inner pbs config + self.pbs_config.validate(chain).await + } +} + /// Runtime config for the pbs module #[derive(Debug, Clone)] pub struct PbsModuleConfig { @@ -337,7 +353,7 @@ pub async fn load_pbs_custom_config() -> Result<(PbsModuleC // load module config including the extra data (if any) let (cb_config, _): (StubConfig, _) = load_file_from_env(CONFIG_ENV)?; - cb_config.pbs.static_config.pbs_config.validate(cb_config.chain).await?; + cb_config.pbs.static_config.validate(cb_config.chain).await?; // use endpoint from env if set, otherwise use default host and port let endpoint = if let Some(endpoint) = load_optional_env_var(PBS_ENDPOINT_ENV) { @@ -391,7 +407,7 @@ pub async fn load_pbs_custom_config() -> Result<(PbsModuleC Some(SignerClient::new( signer_server_url, module_jwt, - ModuleId(PBS_MODULE_NAME.to_string()), + ModuleId(PBS_SERVICE_NAME.to_string()), )?) } else { None diff --git a/crates/common/src/config/signer.rs b/crates/common/src/config/signer.rs index ae55c16d..2aa555e6 100644 --- a/crates/common/src/config/signer.rs +++ b/crates/common/src/config/signer.rs @@ -11,13 +11,15 @@ use tonic::transport::{Certificate, Identity}; use url::Url; use super::{ - CommitBoostConfig, SIGNER_ENDPOINT_ENV, SIGNER_IMAGE_DEFAULT, - SIGNER_JWT_AUTH_FAIL_LIMIT_DEFAULT, SIGNER_JWT_AUTH_FAIL_LIMIT_ENV, - SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_DEFAULT, SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_ENV, - SIGNER_PORT_DEFAULT, load_jwt_secrets, load_optional_env_var, utils::load_env_var, + CommitBoostConfig, SIGNER_ENDPOINT_ENV, SIGNER_JWT_AUTH_FAIL_LIMIT_DEFAULT, + SIGNER_JWT_AUTH_FAIL_LIMIT_ENV, SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_DEFAULT, + SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_ENV, SIGNER_PORT_DEFAULT, load_jwt_secrets, + load_optional_env_var, utils::load_env_var, }; use crate::{ - config::{DIRK_CA_CERT_ENV, DIRK_CERT_ENV, DIRK_DIR_SECRETS_ENV, DIRK_KEY_ENV}, + config::{ + DIRK_CA_CERT_ENV, DIRK_CERT_ENV, DIRK_DIR_SECRETS_ENV, DIRK_KEY_ENV, SIGNER_IMAGE_DEFAULT, + }, signer::{ProxyStore, SignerLoader}, types::{Chain, ModuleId}, utils::{default_host, default_u16, default_u32}, @@ -32,6 +34,7 @@ pub struct SignerConfig { /// Port to listen for signer API calls on #[serde(default = "default_u16::")] pub port: u16, + /// Docker image of the module #[serde(default = "default_signer_image")] pub docker_image: String, diff --git a/docs/docs/get_started/building.md b/docs/docs/get_started/building.md index 966b0362..dd860be2 100644 --- a/docs/docs/get_started/building.md +++ b/docs/docs/get_started/building.md @@ -14,15 +14,15 @@ The build system assumes that you've added your user account to the `docker` gro The Docker builder is built into the project's `justfile` which is used to invoke many facets of Commit Boost development. To use it, you'll need to install [Just](https://github.com/casey/just) on your system. -Use `just --list` to show all of the actions - there are many. The `justfile` provides granular actions, called "recipes", for building just the binaries of a specific crate (such as the CLI, `pbs`, or `signer`), as well as actions to build the Docker images for the PBS and Signer modules. +Use `just --list` to show all of the actions - there are many. The `justfile` provides granular actions, called "recipes", for building just the binaries of a specific crate (such as the CLI, `pbs`, or `signer`), as well as actions to build the Docker images for the PBS and Signer services. Below is a brief summary of the relevant ones for building the Commit-Boost artifacts: -- `build-all ` will build the `commit-boost-cli`, `commit-boost-pbs`, and `commit-boost-signer` binaries for your local system architecture. It will also create Docker images called `commit-boost/pbs:` and `commit-boost/signer:` and load them into your local Docker registry for use. -- `build-cli-bin `, `build-pbs-bin `, and `build-signer-bin ` can be used to create the `commit-boost-cli`, `commit-boost-pbs`, and `commit-boost-signer` binaries, respectively. -- `build-pbs-img ` and `build-signer-img ` can be used to create the Docker images for the PBS and Signer modules, respectively. +- `build-all ` will build the `commit-boost` binary for your local system architecture. It will also create Docker images called `commit-boost/pbs:` and `commit-boost/signer:` and load them into your local Docker registry for use. +- `build-bin ` can be used to create the `commit-boost` binary itself. +- `build-pbs-img ` and `build-signer-img ` can be used to create the Docker images for the PBS and Signer services, respectively. -The `version` provided will be used to house the output binaries in `./build/`, and act as the version tag for the Docker images when they're added to your local system or uploaded to your local Docker repository. +The `version` provided will be used to house the output binaries in `./build/`, and act as the version tag for the Docker images when they're added to your local system or uploaded to your local Docker repository. For example, using `$(git rev-parse --short HEAD)` will set the version to the current commit hash. If you're interested in building the binaries and/or Docker images for multiple architectures (currently Linux `amd64` and `arm64`), use the variants of those recipes that have the `-multiarch` suffix. Note that building a multiarch Docker image manifest will require the use of a [custom Docker registry](https://www.digitalocean.com/community/tutorials/how-to-set-up-a-private-docker-registry-on-ubuntu-20-04), as the local registry built into Docker does not have multiarch manifest support. @@ -81,31 +81,25 @@ git submodule update --init --recursive Your build environment should now be ready to use. -### Building the CLI +### Building the Binary -To build the CLI, run: +To build the binary, run: ``` -cargo build --release --bin commit-boost-cli +just build-bin ``` -This will create a binary in `./target/release/commit-boost-cli`. Confirm that it works: +This will create a binary in `build//`, for example `build/206658b/linux_amd64/`. Confirm that it works: ``` -./target/release/commit-boost-cli --version +./build///commit-boost --version ``` You can now use this to generate the Docker Compose file to drive the other modules if desired. See the [configuration](./configuration.md) guide for more information. -### Building the PBS Module +### Verifying the PBS Service -To build PBS, run: - -``` -cargo build --release --bin commit-boost-pbs -``` - -This will create a binary in `./target/release/commit-boost-pbs`. To verify it works, create [a TOML configuration](./configuration.md) for the PBS module (e.g., `cb-config.toml`). +To verify the PBS service works, create [a TOML configuration](./configuration.md) for the PBS module (e.g., `cb-config.toml`). As a quick example, we'll use this configuration that connects to the Flashbots relay on the Hoodi network: @@ -134,7 +128,7 @@ secrets_path = "/tmp/secrets" Set the path to it in the `CB_CONFIG` environment variable and run the binary: ``` -CB_CONFIG=cb-config.toml ./target/release/commit-boost-pbs +CB_CONFIG=cb-config.toml ./build///commit-boost pbs ``` If it works, you should see output like this: @@ -146,17 +140,11 @@ If it works, you should see output like this: 2025-05-07T21:09:17.896196Z INFO : relay check successful method=/eth/v1/builder/status req_id=5c405c33-0496-42ea-a35d-a7a01dbba356 ``` -If you do, then the binary works. +If you do, then the PBS service works. -### Building the Signer Module - -To build the Signer, run: - -``` -cargo build --release --bin commit-boost-signer -``` +### Verifying the Signer Module -This will create a binary in `./target/release/commit-boost-signer`. To verify it works, create [a TOML configuration](./configuration.md) for the Signer module (e.g., `cb-config.toml`). We'll use the example in the PBS build section above. +To verify the Signer service works, create [a TOML configuration](./configuration.md) for the Signer module (e.g., `cb-config.toml`). We'll use the example in the PBS section above. The signer needs the following environment variables set: @@ -167,7 +155,7 @@ Set these values, create the `keys` and `secrets` directories listed in the conf ``` mkdir -p /tmp/keys && mkdir -p /tmp/secrets -CB_CONFIG=cb-config.toml CB_JWTS="test_jwts=dummy" ./target/release/commit-boost-signer +CB_CONFIG=cb-config.toml CB_JWTS="test_jwts=dummy" ./build///commit-boost signer ``` You should see output like this: diff --git a/docs/docs/get_started/overview.md b/docs/docs/get_started/overview.md index 7956774b..b5719567 100644 --- a/docs/docs/get_started/overview.md +++ b/docs/docs/get_started/overview.md @@ -7,14 +7,14 @@ description: Initial setup Commit-Boost is primarily based on [Docker](https://www.docker.com/) to enable modularity, sandboxing and cross-platform compatibility. It is also possible to run Commit-Boost [natively](/get_started/running/binary) without Docker. Each component roughly maps to a container: from a single `.toml` config file, the node operator can specify which modules they want to run, and Commit-Boost takes care of spinning up the services and creating links between them. -Commit-Boost ships with two core modules: +Commit-Boost ships with two core services: - A PBS module which implements the [BuilderAPI](https://ethereum.github.io/builder-specs/) for [MEV Boost](https://docs.flashbots.net/flashbots-mev-boost/architecture-overview/specifications). - A signer module, which implements the [Signer API](/api) and provides the interface for modules to request proposer commitments. ## Setup -The Commit-Boost CLI creates a dynamic `docker-compose` file, with services and ports already set up. +The Commit-Boost program can create a dynamic `docker-compose` file, with services and ports already set up. Whether you're using Docker or running the binaries natively, you can compile from source directly from the repo, or download binaries and fetch docker images from the official releases. @@ -22,7 +22,7 @@ Whether you're using Docker or running the binaries natively, you can compile fr Find the latest releases at https://github.com/Commit-Boost/commit-boost-client/releases. -The modules are also published at [each release](https://github.com/orgs/Commit-Boost/packages?repo_name=commit-boost-client). +The services are also published at [each release](https://github.com/orgs/Commit-Boost/packages?repo_name=commit-boost-client). ### From source @@ -49,35 +49,24 @@ git submodule update --init --recursive If you get an `openssl` related error try running: `apt-get update && apt-get install -y openssl ca-certificates libssl3 libssl-dev build-essential pkg-config` ::: -### Docker - -You will need to build the CLI to create the `docker-compose` file: +Now, build the binary, which will be stored in `build//`, for example `build/206658b/linux_amd64/`: ```bash -# Build the CLI -cargo build --release --bin commit-boost-cli - -# Check that it works -./target/release/commit-boost-cli --version +just build-bin $(git rev-parse --short HEAD) ``` -and the modules as Docker images: - +You can confirm the binary was built successfully by navigating to the build directory and checking its version: ```bash -docker build -t commitboost_pbs_default . -f ./provisioning/pbs.Dockerfile -docker build -t commitboost_signer . -f ./provisioning/signer.Dockerfile +./commit-boost --version ``` -This will create two local images called `commitboost_pbs_default` and `commitboost_signer` for the Pbs and Signer module respectively. Make sure to use these images in the `docker_image` field in the `[pbs]` and `[signer]` sections of the `.toml` config file, respectively. - -### Binaries +### Docker -Alternatively, you can also build the modules from source and run them without Docker, in which case you can skip the CLI and only compile the modules: +Building the service images requires the binary to be built using the above instructions first, since it will be copied into those images. Once it's built, create the images with the following: ```bash -# Build the PBS module -cargo build --release --bin commit-boost-pbs - -# Build the Signer module -cargo build --release --bin commit-boost-signer +just build-pbs-img $(git rev-parse --short HEAD) +just build-signer-img $(git rev-parse --short HEAD) ``` + +This will create two local images called `commit_boost/pbs:` and `commit_boost/signer:` for the PBS and Signer services respectively. Make sure to use these images in the `docker_image` field in the `[pbs]` and `[signer]` sections of the `.toml` config file, respectively. diff --git a/docs/docs/get_started/running/binary.md b/docs/docs/get_started/running/binary.md index 10815d6e..74a09373 100644 --- a/docs/docs/get_started/running/binary.md +++ b/docs/docs/get_started/running/binary.md @@ -57,7 +57,7 @@ Modules might also have additional envs required, which should be detailed by th After creating the `cb-config.toml` file, setup the required envs and run the binary. For example: ```bash -CB_CONFIG=./cb-config.toml commit-boost-pbs +CB_CONFIG=./cb-config.toml commit-boost pbs ``` ## Security diff --git a/docs/docs/get_started/running/docker.md b/docs/docs/get_started/running/docker.md index 396fdede..89465a44 100644 --- a/docs/docs/get_started/running/docker.md +++ b/docs/docs/get_started/running/docker.md @@ -3,13 +3,13 @@ description: Run Commit-Boost with Docker --- # Docker -The Commit-Boost CLI generates a dynamic `docker-compose.yml` file using the provided `.toml` config file. This is the recommended approach as Docker provides sandboxing of the containers from the rest of your system. +The Commit-Boost program generates a dynamic `docker-compose.yml` file using the provided `.toml` config file. This is the recommended approach as Docker provides sandboxing of the containers from the rest of your system. ## Init First run: ```bash -commit-boost-cli init --config cb-config.toml +commit-boost init --config cb-config.toml ``` This will create up to three files: - `cb.docker-compose.yml` which contains the full setup of the Commit-Boost services. @@ -73,9 +73,9 @@ Note that there are many more parameters that Commit-Boost supports, but they ar The relays here are placeholder for the sake of the example; for a list of actual relays, visit [the EthStaker relay list](https://github.com/eth-educators/ethstaker-guides/blob/main/MEV-relay-list.md). -### Commit-Boost CLI Output +### Commit-Boost Init Output -Run `commit-boost-cli init --config cb-config.toml` with the above configuration, the CLI will produce the following Docker Compose file: +Run `commit-boost init --config cb-config.toml` with the above configuration, the program will produce the following Docker Compose file: ``` services: @@ -102,14 +102,14 @@ This will run the PBS service in a container named `cb_pbs`. ### Configuration File Volume -The CLI creates a read-only volume binding for the config file, which the PBS service needs to run. The Docker compose file that it creates with the `init` command, `cb.docker-compose.yml`, will be placed into your current working directory when you run the CLI. The volume source will be specified as a *relative path* to that working directory, so it's ideal if the config file is directly within your working directory (or a subdirectory). If you need to specify an absolute path for the config file, you can adjust the `volumes` entry within the Docker compose file manually after its creation. +The program creates a read-only volume binding for the config file, which the PBS service needs to run. The Docker compose file that it creates with the `init` command, `cb.docker-compose.yml`, will be placed into your current working directory when you run the program. The volume source will be specified as a *relative path* to that working directory, so it's ideal if the config file is directly within your working directory (or a subdirectory). If you need to specify an absolute path for the config file, you can adjust the `volumes` entry within the Docker compose file manually after its creation. Since this is a volume, the PBS service container will reload the file from disk any time it's restarted. That means you can change the file any time after the Docker compose file is created to tweak PBS's parameters, but it also means the config file must stay in the same location; if you move it, the PBS container won't be able to mount it anymore and fail to start unless you manually adjust the volume's source location. ### Networking -The CLI will force the PBS service to bind to `0.0.0.0` within Docker's internal network so other Docker containers can access it, but it will only expose the API port (default `18550`) to `127.0.0.1` on your host machine. That way any processes running on the same machine can access it on that port. If you want to open the port for access across your entire network, not just your local machine, you can add the line: +The program will force the PBS service to bind to `0.0.0.0` within Docker's internal network so other Docker containers can access it, but it will only expose the API port (default `18550`) to `127.0.0.1` on your host machine. That way any processes running on the same machine can access it on that port. If you want to open the port for access across your entire network, not just your local machine, you can add the line: ``` host = "0.0.0.0" @@ -124,7 +124,7 @@ to the `[pbs]` section in the configuration. This will cause the resulting `port though you will need to add an entry to your local machine's firewall software (if applicable) for other machines to access it. -Currently, the CLI will always export the PBS service's API port in one of these two ways. If you don't want to expose it at all, so it can only be accessed by other Docker containers running within Docker's internal network, you will need to manually remove the `ports` entry from the Docker compose file after it's been created: +Currently, the program will always export the PBS service's API port in one of these two ways. If you don't want to expose it at all, so it can only be accessed by other Docker containers running within Docker's internal network, you will need to manually remove the `ports` entry from the Docker compose file after it's been created: ``` ports: [] @@ -177,9 +177,9 @@ The relays here are placeholder for the sake of the example; for a list of actua In this scenario there are two folders in the same directory as the configuration file (the working directory): `keys` and `secrets`. These correspond to the folders containing the [EIP-2335 keystores](../configuration.md#local-signer) and secrets in Lighthouse format. For your own keys, adjust the `format` parameter within the configuration and directory paths accordingly. -### Commit-Boost CLI Output +### Commit-Boost Init Output -Run `commit-boost-cli init --config cb-config.toml` with the above configuration, the CLI will produce two files: +Run `commit-boost init --config cb-config.toml` with the above configuration, the program will produce two files: - `cb.docker-compose.yml` - `.cb.env` @@ -261,7 +261,7 @@ CB_JWT_DA_COMMIT=mwDSSr7chwy9eFf7RhedBoyBtrwFUjSQ CB_JWTS=DA_COMMIT=mwDSSr7chwy9eFf7RhedBoyBtrwFUjSQ ``` -The Signer service needs JWT authentication from each of its modules. The CLI creates these and embeds them into the containers via environment variables automatically for convenience. This is demonstrated for the Signer module within the `environment` compose block: the `CB_JWTS: ${CB_JWTS}` forwards the `CB_JWTS` environment variable that's present when running Docker compose. The CLI requests that you do so via the command `docker compose --env-file "./.cb.env" -f "./cb.docker-compose.yml" up -d`; the `--env-file "./.cb.env"` handles loading the CLI's JWT output into this environment variable. +The Signer service needs JWT authentication from each of its modules. The program creates these and embeds them into the containers via environment variables automatically for convenience. This is demonstrated for the Signer module within the `environment` compose block: the `CB_JWTS: ${CB_JWTS}` forwards the `CB_JWTS` environment variable that's present when running Docker compose. The program requests that you do so via the command `docker compose --env-file "./.cb.env" -f "./cb.docker-compose.yml" up -d`; the `--env-file "./.cb.env"` handles loading the program's JWT output into this environment variable. Similarly, for the `cb_da_commit` module, the `CB_SIGNER_JWT: ${CB_JWT_DA_COMMIT}` line within its `environment` block will set the JWT that it should use to authenticate with the Signer service. @@ -273,7 +273,7 @@ As with the PBS-only example, the configuration file is placed into a read-only ### Networking -The CLI will force both the PBS and Signer API endpoints to bind to `0.0.0.0` within Docker's internal network so other Docker containers can access them, but it will only expose the API port (default `18550` for PBS and `20000` for the Signer) to `127.0.0.1` on your host machine. That way any processes running on the same machine can access them on their respective ports. If you want to open the ports for access across your entire network, not just your local machine, you can add the line: +The program will force both the PBS and Signer API endpoints to bind to `0.0.0.0` within Docker's internal network so other Docker containers can access them, but it will only expose the API port (default `18550` for PBS and `20000` for the Signer) to `127.0.0.1` on your host machine. That way any processes running on the same machine can access them on their respective ports. If you want to open the ports for access across your entire network, not just your local machine, you can add the line: ``` host = "0.0.0.0" @@ -296,7 +296,7 @@ to both the `[pbs]` and `[signer]` sections in the configuration. This will caus though you will need to add entries to your local machine's firewall software (if applicable) for other machines to access them. -Currently, the CLI will always export the PBS and Signer services' API ports in one of these two ways. If you don't want to expose them at all, so they can only be accessed by other Docker containers running within Docker's internal network, you will need to manually remove the `ports` entries from the Docker compose files after they've been created: +Currently, the program will always export the PBS and Signer services' API ports in one of these two ways. If you don't want to expose them at all, so they can only be accessed by other Docker containers running within Docker's internal network, you will need to manually remove the `ports` entries from the Docker compose files after they've been created: ``` ports: [] diff --git a/docs/docs/get_started/running/metrics.md b/docs/docs/get_started/running/metrics.md index 38e8534b..58200195 100644 --- a/docs/docs/get_started/running/metrics.md +++ b/docs/docs/get_started/running/metrics.md @@ -12,7 +12,7 @@ Make sure to add the `[metrics]` section to your config file: [metrics] enabled = true ``` -If the section is missing, metrics collection will be disabled. If you generated the `docker-compose.yml` file with `commit-boost-cli`, metrics ports will be automatically configured, and a sample `target.json` file will be created. If you're running the binaries directly, you will need to set the correct environment variables, as described in the [previous section](/get_started/running/binary#common). +If the section is missing, metrics collection will be disabled. If you generated the `docker-compose.yml` file with `commit-boost init`, metrics ports will be automatically configured, and a sample `target.json` file will be created. If you're running the binaries directly, you will need to set the correct environment variables, as described in the [previous section](/get_started/running/binary#common). ## Example setup diff --git a/examples/status_api/src/main.rs b/examples/status_api/src/main.rs index 3530c800..aa65f4d6 100644 --- a/examples/status_api/src/main.rs +++ b/examples/status_api/src/main.rs @@ -95,7 +95,7 @@ async fn main() -> Result<()> { let (pbs_config, extra) = load_pbs_custom_config::().await?; let chain = pbs_config.chain; - let _guard = initialize_tracing_log(PBS_MODULE_NAME, LogsSettings::from_env_config()?)?; + let _guard = initialize_tracing_log(PBS_SERVICE_NAME, LogsSettings::from_env_config()?)?; let custom_state = MyBuilderState::from_config(extra); let empty_config_path = PathBuf::new(); diff --git a/justfile b/justfile index 70475ad9..a0df34d6 100644 --- a/justfile +++ b/justfile @@ -11,28 +11,30 @@ fmt-check: clippy: cargo +{{toolchain}} clippy --all-features --no-deps -- -D warnings +# Everything needed to run before pushing +checklist: + cargo check + just fmt + just clippy + just test + # =================================== # === Build Commands for Services === # =================================== [doc(""" - Builds the commit-boost-cli binary to './build/'. -""")] -build-cli version: \ - (_docker-build-binary version "cli") - -[doc(""" - Builds amd64 and arm64 binaries for the commit-boost-cli crate to './build//', where '' is - the OS / arch platform of the binary (linux_amd64 and linux_arm64). + Builds the commit-boost binary to './build/'. """)] -build-cli-multiarch version: \ - (_docker-build-binary-multiarch version "cli") +build-bin version: \ + (_docker-build-binary version "commit-boost") [doc(""" - Builds the commit-boost-pbs binary to './build/'. + Builds amd64 and arm64 binaries for the commit-boost crate to './build//', where '' is the + OS / arch platform of the binary (linux_amd64 and linux_arm64). + Used when creating the pbs Docker image. """)] -build-pbs-bin version: \ - (_docker-build-binary version "pbs") +build-bin-multiarch version: \ + (_docker-build-binary-multiarch version "commit-boost") [doc(""" Creates a Docker image named 'commit-boost/pbs:' and loads it to the local Docker repository. @@ -43,20 +45,12 @@ build-pbs-img version: \ (_docker-build-image version "pbs") [doc(""" - Builds the commit-boost-pbs binary to './build/' and creates a Docker image named 'commit-boost/pbs:'. + Builds the commit-boost binary to './build/' and creates a Docker image named 'commit-boost/pbs:'. """)] build-pbs version: \ - (build-pbs-bin version) \ + (build-bin version) \ (build-pbs-img version) -[doc(""" - Builds amd64 and arm64 binaries for the commit-boost-pbs crate to './build//', where '' is the - OS / arch platform of the binary (linux_amd64 and linux_arm64). - Used when creating the pbs Docker image. -""")] -build-pbs-bin-multiarch version: \ - (_docker-build-binary-multiarch version "pbs") - [doc(""" Creates a multiarch Docker image manifest named 'commit-boost/pbs:' and pushes it to a custom Docker registry (such as '192.168.1.10:5000'). @@ -66,22 +60,16 @@ build-pbs-img-multiarch version local-docker-registry: \ (_docker-build-image-multiarch version "pbs" local-docker-registry) [doc(""" - Builds amd64 and arm64 binaries for the commit-boost-pbs crate to './build//', where '' is the + Builds amd64 and arm64 binaries for the commit-boost crate to './build//', where '' is the OS / arch platform of the binary (linux_amd64 and linux_arm64). Creates a multiarch Docker image manifest named 'commit-boost/pbs:' and pushes it to a custom Docker registry (such as '192.168.1.10:5000'). Used for testing multiarch images locally instead of using a public registry like GHCR or Docker Hub. """)] build-pbs-multiarch version local-docker-registry: \ - (build-pbs-bin-multiarch version) \ + (build-bin-multiarch version) \ (build-pbs-img-multiarch version local-docker-registry) -[doc(""" - Builds the commit-boost-signer binary to './build/'. -""")] -build-signer-bin version: \ - (_docker-build-binary version "signer") - [doc(""" Creates a Docker image named 'commit-boost/signer:' and loads it to the local Docker repository. Requires the binary to be built first, but this command won't build it automatically if you just need to build the @@ -91,20 +79,12 @@ build-signer-img version: \ (_docker-build-image version "signer") [doc(""" - Builds the commit-boost-signer binary to './build/' and creates a Docker image named 'commit-boost/signer:'. + Builds the commit-boost binary to './build/' and creates a Docker image named 'commit-boost/signer:'. """)] build-signer version: \ - (build-signer-bin version) \ + (build-bin version) \ (build-signer-img version) -[doc(""" - Builds amd64 and arm64 binaries for the commit-boost-signer crate to './build//', where '' is - the OS / arch platform of the binary (linux_amd64 and linux_arm64). - Used when creating the signer Docker image. -""")] -build-signer-bin-multiarch version: \ - (_docker-build-binary-multiarch version "signer") - [doc(""" Creates a multiarch Docker image manifest named 'commit-boost/signer:' and pushes it to a custom Docker registry (such as '192.168.1.10:5000'). @@ -114,14 +94,14 @@ build-signer-img-multiarch version local-docker-registry: \ (_docker-build-image-multiarch version "signer" local-docker-registry) [doc(""" - Builds amd64 and arm64 binaries for the commit-boost-signer crate to './build//', where '' is + Builds amd64 and arm64 binaries for the commit-boost crate to './build//', where '' is the OS / arch platform of the binary (linux_amd64 and linux_arm64). Creates a multiarch Docker image manifest named 'commit-boost/signer:' and pushes it to a custom Docker registry (such as '192.168.1.10:5000'). Used for testing multiarch images locally instead of using a public registry like GHCR or Docker Hub. """)] build-signer-multiarch version local-docker-registry: \ - (build-signer-bin-multiarch version) \ + (build-bin-multiarch version) \ (build-signer-img-multiarch version local-docker-registry) [doc(""" @@ -131,9 +111,9 @@ build-signer-multiarch version local-docker-registry: \ 'commit-boost/signer:'. """)] build-all version: \ - (build-cli version) \ - (build-pbs version) \ - (build-signer version) + (build-bin version) \ + (build-pbs-img version) \ + (build-signer-img version) [doc(""" Builds amd64 and arm64 flavors of the CLI, PBS, and Signer binaries and Docker images for the specified version. @@ -144,9 +124,9 @@ build-all version: \ Used for testing multiarch images locally instead of using a public registry like GHCR or Docker Hub. """)] build-all-multiarch version local-docker-registry: \ - (build-cli-multiarch version) \ - (build-pbs-multiarch version local-docker-registry) \ - (build-signer-multiarch version local-docker-registry) + (build-bin-multiarch version) \ + (build-pbs-img-multiarch version local-docker-registry) \ + (build-signer-img-multiarch version local-docker-registry) # =============================== # === Builder Implementations === @@ -159,7 +139,7 @@ _create-docker-builder: # Builds a binary for a specific crate and version _docker-build-binary version crate: _create-docker-builder export PLATFORM=$(docker buildx inspect --bootstrap | awk -F': ' '/Platforms/ {print $2}' | cut -d',' -f1 | xargs | tr '/' '_'); \ - docker buildx build --rm --platform=local -f provisioning/build.Dockerfile --output "build/{{version}}/$PLATFORM" --target output --build-arg TARGET_CRATE=commit-boost-{{crate}} . + docker buildx build --rm --platform=local -f provisioning/build.Dockerfile --output "build/{{version}}/$PLATFORM" --target output --build-arg TARGET_CRATE=commit-boost . # Builds a Docker image for a specific crate and version _docker-build-image version crate: _create-docker-builder @@ -167,7 +147,7 @@ _docker-build-image version crate: _create-docker-builder # Builds multiple binaries (for Linux amd64 and arm64 architectures) for a specific crate and version _docker-build-binary-multiarch version crate: _create-docker-builder - docker buildx build --rm --platform=linux/amd64,linux/arm64 -f provisioning/build.Dockerfile --output build/{{version}} --target output --build-arg TARGET_CRATE=commit-boost-{{crate}} . + docker buildx build --rm --platform=linux/amd64,linux/arm64 -f provisioning/build.Dockerfile --output build/{{version}} --target output --build-arg TARGET_CRATE=commit-boost . # Builds a multi-architecture (Linux amd64 and arm64) Docker manifest for a specific crate and version. # Uploads to the custom Docker registry (such as '192.168.1.10:5000') instead of a public registry like GHCR or Docker Hub. @@ -193,3 +173,99 @@ clean: # Runs the suite of tests for all commit-boost crates. test: cargo test --all-features + +# ===================== +# === Test Coverage === +# ===================== + +# Generate an HTML test coverage report and open it in the browser. +# Recompiles the workspace with LLVM coverage instrumentation, runs all tests, +# and writes the report to target/llvm-cov/html/index.html. +# Incremental recompilation works normally — no need to clean between runs. +# If results look wrong after upgrading cargo-llvm-cov, run `just coverage-clean` first. +# Requires: cargo install cargo-llvm-cov && rustup component add llvm-tools-preview +coverage: + cargo llvm-cov --all-features --html --open + +# Print a quick coverage summary to the terminal without opening a browser. +coverage-summary: + cargo llvm-cov --all-features --summary-only + +# Remove all coverage instrumentation artifacts produced by cargo-llvm-cov. +coverage-clean: + cargo llvm-cov clean --workspace + +# ======================= +# === Microbenchmarks === +# ======================= +# +# Development Loop: +# 1. Run the current bench: just bench dev +# 2. Update code +# 3. Re-run the bench, logging the diff from the last run: just bench dev + +# Regression Test: +# 1. Save a baseline on the main branch: just bench main +# 2. On a PR branch, compare against it: just bench-compare main + +[doc(""" + Install tools required by the bench-* commands. + - cargo-criterion: CLI runner for Criterion benchmarks with richer output + - critcmp: baseline diffing tool used by bench-compare +""")] +bench-install-tools: + cargo install cargo-criterion critcmp + +[doc(""" + Run microbenchmarks and save results as a named baseline. Example: just bench main + + Compares against the last benchmark run of any kind, not the previous save of + this baseline name. Useful for tracking incremental changes since your last run. + For accurate baseline comparisons, use bench-compare instead. +""")] +bench baseline: + cargo bench --package cb-bench-micro -- --save-baseline {{baseline}} + +[doc(""" + Run microbenchmarks, save results as "current", then diff against a named baseline. + Example: just bench-compare main +""")] +bench-compare baseline: + cargo bench --package cb-bench-micro -- --save-baseline current + critcmp {{baseline}} current + +# ================= +# === Kurtosis === +# ================= + +# Tear down and clean up all enclaves +kurtosis-clean: + kurtosis clean -a + +# Clean all enclaves and restart the testnet +kurtosis-restart: + just kurtosis-clean + kurtosis run github.com/ethpandaops/ethereum-package \ + --enclave CB-Testnet \ + --args-file provisioning/kurtosis-config.yml + +# Build local docker images and restart testnet +kurtosis-build: + just build-all kurtosis + just kurtosis-restart + +# Inspect running enclave +kurtosis-inspect: + kurtosis enclave inspect CB-Testnet + +# Tail logs for a specific service: just kurtosis-logs +kurtosis-logs service: + kurtosis service logs CB-Testnet {{service}} --follow + +# Shell into a specific service: just kurtosis-shell +kurtosis-shell service: + kurtosis service shell CB-Testnet {{service}} + +# Dump enclave state to disk for post-mortem +kurtosis-dump: + kurtosis enclave dump CB-Testnet ./kurtosis-dump diff --git a/provisioning/kurtosis-config.yml b/provisioning/kurtosis-config.yml new file mode 100644 index 00000000..093534b0 --- /dev/null +++ b/provisioning/kurtosis-config.yml @@ -0,0 +1,104 @@ +# ELs: geth, nethermind, erigon, besu, reth, ethrex +# CLs: nimbus, lighthouse, lodestar, teku, prysm, and grandine +participants: + - el_type: geth + cl_type: nimbus + + - el_type: nethermind + cl_type: lighthouse + + - el_type: erigon + cl_type: lodestar + + - el_type: besu + cl_type: teku + + - el_type: reth + cl_type: prysm + + - el_type: ethrex + cl_type: grandine + +additional_services: + - dora + - spamoor +mev_type: commit-boost + +mev_params: + mev_relay_image: ethpandaops/mev-boost-relay:main + mev_boost_image: commit-boost/pbs:kurtosis + mev_builder_cl_image: sigp/lighthouse:latest + mev_builder_image: ethpandaops/reth-rbuilder:develop + +network_params: + network: kurtosis + network_id: "3151908" + deposit_contract_address: "0x00000000219ab540356cBB839Cbe05303d7705Fa" + seconds_per_slot: 12 + slot_duration_ms: 12000 + num_validator_keys_per_node: 128 + preregistered_validator_keys_mnemonic: + "giant issue aisle success illegal bike spike + question tent bar rely arctic volcano long crawl hungry vocal artwork sniff fantasy + very lucky have athlete" + preregistered_validator_count: 0 + additional_mnemonics: [] + genesis_delay: 20 + genesis_time: 0 + genesis_gaslimit: 60000000 + max_per_epoch_activation_churn_limit: 8 + churn_limit_quotient: 65536 + ejection_balance: 16000000000 + eth1_follow_distance: 2048 + min_validator_withdrawability_delay: 256 + shard_committee_period: 256 + attestation_due_bps_gloas: 2500 + aggregate_due_bps_gloas: 5000 + sync_message_due_bps_gloas: 2500 + contribution_due_bps_gloas: 5000 + payload_attestation_due_bps: 7500 + view_freeze_cutoff_bps: 7500 + inclusion_list_submission_due_bps: 6667 + proposer_inclusion_list_cutoff_bps: 9167 + deneb_fork_epoch: 0 + electra_fork_epoch: 0 + fulu_fork_epoch: 0 + gloas_fork_epoch: 18446744073709551615 + network_sync_base_url: https://snapshots.ethpandaops.io/ + force_snapshot_sync: false + data_column_sidecar_subnet_count: 128 + samples_per_slot: 8 + custody_requirement: 4 + max_blobs_per_block_electra: 9 + max_request_blocks_deneb: 128 + max_request_blob_sidecars_electra: 1152 + target_blobs_per_block_electra: 6 + base_fee_update_fraction_electra: 5007716 + additional_preloaded_contracts: {} + devnet_repo: ethpandaops + prefunded_accounts: {} + bpo_1_epoch: 0 + bpo_1_max_blobs: 15 + bpo_1_target_blobs: 10 + bpo_1_base_fee_update_fraction: 8346193 + bpo_2_epoch: 18446744073709551615 + bpo_2_max_blobs: 21 + bpo_2_target_blobs: 14 + bpo_2_base_fee_update_fraction: 11684671 + bpo_3_epoch: 18446744073709551615 + bpo_3_max_blobs: 0 + bpo_3_target_blobs: 0 + bpo_3_base_fee_update_fraction: 0 + bpo_4_epoch: 18446744073709551615 + bpo_4_max_blobs: 0 + bpo_4_target_blobs: 0 + bpo_4_base_fee_update_fraction: 0 + bpo_5_epoch: 18446744073709551615 + bpo_5_max_blobs: 0 + bpo_5_target_blobs: 0 + bpo_5_base_fee_update_fraction: 0 + withdrawal_type: "0x00" + withdrawal_address: "0x8943545177806ED17B9F23F0a21ee5948eCaa776" + validator_balance: 32 + min_epochs_for_data_column_sidecars_requests: 4096 + min_epochs_for_block_requests: 33024 \ No newline at end of file diff --git a/provisioning/pbs.Dockerfile b/provisioning/pbs.Dockerfile index 6b9496ec..6a4c4646 100644 --- a/provisioning/pbs.Dockerfile +++ b/provisioning/pbs.Dockerfile @@ -1,6 +1,6 @@ FROM debian:bookworm-slim ARG BINARIES_PATH TARGETOS TARGETARCH -COPY ${BINARIES_PATH}/${TARGETOS}_${TARGETARCH}/commit-boost-pbs /usr/local/bin/commit-boost-pbs +COPY ${BINARIES_PATH}/${TARGETOS}_${TARGETARCH}/commit-boost /usr/local/bin/commit-boost RUN apt-get update && apt-get install -y \ openssl \ ca-certificates \ @@ -16,4 +16,5 @@ RUN groupadd -g 10001 commitboost && \ useradd -u 10001 -g commitboost -s /sbin/nologin commitboost USER commitboost -ENTRYPOINT ["/usr/local/bin/commit-boost-pbs"] \ No newline at end of file +ENTRYPOINT ["/usr/local/bin/commit-boost"] +CMD ["pbs"] \ No newline at end of file diff --git a/provisioning/pectra-config.yml b/provisioning/pectra-config.yml deleted file mode 100644 index a78d55a2..00000000 --- a/provisioning/pectra-config.yml +++ /dev/null @@ -1,56 +0,0 @@ -participants: - - el_type: geth - el_image: ethpandaops/geth:prague-devnet-6-c070db6 - el_extra_params: ["--miner.extradata=pawanRocks"] - cl_type: lighthouse - cl_image: ethpandaops/lighthouse:unstable-95cec45 - - el_type: geth - el_image: ethpandaops/geth:prague-devnet-6-c070db6 - el_extra_params: ["--miner.extradata=TekuFromLocal"] - cl_type: teku - cl_image: consensys/teku:develop - - el_type: geth - el_image: ethpandaops/geth:prague-devnet-6-c070db6 - el_extra_params: ["--miner.extradata=lodestarFromLocal"] - cl_type: lodestar - cl_image: ethpandaops/lodestar:unstable-7982031 - - el_type: geth - el_image: ethpandaops/geth:prague-devnet-6-c070db6 - el_extra_params: ["--miner.extradata=prysmFromLocal"] - cl_type: prysm - cl_image: ethpandaops/prysm-beacon-chain:develop-910609a - vc_image: ethpandaops/prysm-validator:develop-910609a - - el_type: geth - el_image: ethpandaops/geth:prague-devnet-6-c070db6 - el_extra_params: ["--miner.extradata=nimbusFromLocal"] - cl_type: nimbus - cl_image: ethpandaops/nimbus-eth2:unstable-dec1cd3 - - el_type: geth - el_image: ethpandaops/geth:prague-devnet-6-c070db6 - el_extra_params: ["--miner.extradata=grandineFromLocal"] - cl_type: grandine - cl_image: ethpandaops/grandine:devnet5-7ba51a4 - -additional_services: - - dora - - tx_spammer - - spamoor_blob -mev_type: commit-boost - -mev_params: - mev_relay_image: jtraglia/mev-boost-relay:electra - mev_boost_image: commitboost_pbs_default # build this locally with scripts/build_local_images.sh - mev_builder_cl_image: ethpandaops/lighthouse:unstable-a1b7d61 - mev_builder_image: ethpandaops/reth-rbuilder:devnet6-fdeb4d6 - -network_params: - electra_fork_epoch: 1 - min_validator_withdrawability_delay: 1 - shard_committee_period: 1 - churn_limit_quotient: 16 - genesis_delay: 120 - -spamoor_blob_params: - throughput: 10 - max_blobs: 2 - max_pending: 40 \ No newline at end of file diff --git a/provisioning/signer.Dockerfile b/provisioning/signer.Dockerfile index 5ea619b2..d6c3d498 100644 --- a/provisioning/signer.Dockerfile +++ b/provisioning/signer.Dockerfile @@ -1,6 +1,6 @@ FROM debian:bookworm-slim ARG BINARIES_PATH TARGETOS TARGETARCH -COPY ${BINARIES_PATH}/${TARGETOS}_${TARGETARCH}/commit-boost-signer /usr/local/bin/commit-boost-signer +COPY ${BINARIES_PATH}/${TARGETOS}_${TARGETARCH}/commit-boost /usr/local/bin/commit-boost RUN apt-get update && apt-get install -y \ openssl \ ca-certificates \ @@ -16,4 +16,5 @@ RUN groupadd -g 10001 commitboost && \ useradd -u 10001 -g commitboost -s /sbin/nologin commitboost USER commitboost -ENTRYPOINT ["/usr/local/bin/commit-boost-signer"] \ No newline at end of file +ENTRYPOINT ["/usr/local/bin/commit-boost"] +CMD ["signer"] \ No newline at end of file diff --git a/tests/tests/pbs_cfg_file_update.rs b/tests/tests/pbs_cfg_file_update.rs index 0699eb27..a1d4b94f 100644 --- a/tests/tests/pbs_cfg_file_update.rs +++ b/tests/tests/pbs_cfg_file_update.rs @@ -28,7 +28,7 @@ async fn test_cfg_file_update() -> Result<()> { let pubkey = signer.public_key(); let chain = Chain::Hoodi; - let pbs_port = 3720; + let pbs_port = 3730; // Start relay 1 let relay1_port = pbs_port + 1; @@ -69,7 +69,7 @@ async fn test_cfg_file_update() -> Result<()> { let cb_config = CommitBoostConfig { chain, pbs: StaticPbsConfig { - docker_image: String::new(), + docker_image: "cb-fake-repo/cb-fake-image:latest".to_string(), pbs_config: pbs_config.clone(), with_signer: false, }, @@ -120,7 +120,11 @@ async fn test_cfg_file_update() -> Result<()> { // Update the config to only have relay 2 let cb_config = CommitBoostConfig { chain, - pbs: StaticPbsConfig { docker_image: String::new(), pbs_config, with_signer: false }, + pbs: StaticPbsConfig { + docker_image: "cb-fake-repo/cb-fake-image:latest".to_string(), + pbs_config, + with_signer: false, + }, muxes: None, modules: None, signer: None, From 7d9e74fe91b6e5e611c2674c419e739c74e068c3 Mon Sep 17 00:00:00 2001 From: ninaiiad Date: Thu, 26 Mar 2026 17:07:07 +0000 Subject: [PATCH 08/11] add get_header auction winner log (#443) --- crates/pbs/src/mev_boost/get_header.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/pbs/src/mev_boost/get_header.rs b/crates/pbs/src/mev_boost/get_header.rs index 1914de8b..08275a76 100644 --- a/crates/pbs/src/mev_boost/get_header.rs +++ b/crates/pbs/src/mev_boost/get_header.rs @@ -27,7 +27,7 @@ use futures::future::join_all; use parking_lot::RwLock; use reqwest::{StatusCode, header::USER_AGENT}; use tokio::time::sleep; -use tracing::{Instrument, debug, error, warn}; +use tracing::{Instrument, debug, error, info, warn}; use tree_hash::TreeHash; use url::Url; @@ -131,7 +131,7 @@ pub async fn get_header( .unwrap_or_default(); RELAY_HEADER_VALUE.with_label_values(&[relay_id]).set(value_gwei); - relay_bids.push(res) + relay_bids.push((relay_id, res)) } Ok(_) => {} Err(err) if err.is_timeout() => error!(err = "Timed Out", relay_id), @@ -139,9 +139,18 @@ pub async fn get_header( } } - let max_bid = relay_bids.into_iter().max_by_key(|bid| *bid.value()); + let max_bid = relay_bids.into_iter().max_by_key(|(_, bid)| *bid.value()); - Ok(max_bid) + if let Some((winning_relay_id, ref bid)) = max_bid { + info!( + relay_id = winning_relay_id, + value_eth = format_ether(*bid.value()), + block_hash = %bid.block_hash(), + "auction winner" + ); + } + + Ok(max_bid.map(|(_, bid)| bid)) } /// Fetch the parent block from the RPC URL for extra validation of the header. @@ -373,7 +382,7 @@ async fn send_one_get_header( } }; - debug!( + info!( relay_id = relay.id.as_ref(), header_size_bytes, latency = ?request_latency, From fc6b2804d969ee4328e6549f411ed4c14ecf693a Mon Sep 17 00:00:00 2001 From: JasonVranek Date: Fri, 10 Apr 2026 22:45:52 +0100 Subject: [PATCH 09/11] Sigp audit fixes (#438) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Manuel Iñaki Bilbao Co-authored-by: Joe Clapis Co-authored-by: eltitanb Co-authored-by: ltitanb <163874448+ltitanb@users.noreply.github.com> --- .cargo/audit.toml | 27 + .gitignore | 1 + Cargo.toml | 7 +- api/signer-api.yml | 532 ++++++++-- bin/src/lib.rs | 3 + bin/tests/binary.rs | 1 + config.example.toml | 31 +- crates/cli/src/docker_init.rs | 456 ++++++++- crates/common/Cargo.toml | 5 + crates/common/src/commit/client.rs | 122 ++- crates/common/src/commit/constants.rs | 10 +- crates/common/src/commit/mod.rs | 1 + crates/common/src/commit/request.rs | 192 ++-- crates/common/src/commit/response.rs | 53 + crates/common/src/config/constants.rs | 9 +- crates/common/src/config/mod.rs | 34 + crates/common/src/config/module.rs | 18 +- crates/common/src/config/pbs.rs | 40 +- crates/common/src/config/signer.rs | 686 ++++++++++++- crates/common/src/config/utils.rs | 199 +++- crates/common/src/signature.rs | 205 +++- crates/common/src/signer/schemes/bls.rs | 24 +- crates/common/src/signer/schemes/ecdsa.rs | 77 +- crates/common/src/signer/store.rs | 12 +- crates/common/src/types.rs | 78 +- crates/common/src/utils.rs | 355 ++++++- crates/pbs/src/mev_boost/get_header.rs | 5 +- crates/signer/Cargo.toml | 2 + crates/signer/src/constants.rs | 4 +- crates/signer/src/error.rs | 10 + crates/signer/src/lib.rs | 1 + crates/signer/src/manager/dirk.rs | 78 +- crates/signer/src/manager/local.rs | 146 ++- crates/signer/src/metrics.rs | 10 +- crates/signer/src/service.rs | 947 ++++++++++++++++-- crates/signer/src/utils.rs | 242 +++++ docs/docs/developing/prop-commit-signing.md | 76 ++ docs/docs/get_started/configuration.md | 104 +- docs/docs/get_started/running/binary.md | 2 + docs/docs/res/img/prop_commit_tree.png | Bin 0 -> 96528 bytes examples/da_commit/src/main.rs | 71 +- justfile | 1 + .../grafana/signer_public_dashboard.json | 24 +- tests/Cargo.toml | 5 +- tests/data/configs/signer.happy.toml | 52 + tests/src/lib.rs | 1 + tests/src/mock_relay.rs | 2 +- tests/src/signer_service.rs | 98 ++ tests/src/utils.rs | 66 +- tests/tests/pbs_cfg_file_update.rs | 4 +- tests/tests/pbs_get_header.rs | 10 +- tests/tests/pbs_get_status.rs | 6 +- tests/tests/pbs_mux.rs | 8 +- tests/tests/pbs_mux_refresh.rs | 4 +- tests/tests/pbs_post_blinded_blocks.rs | 6 +- tests/tests/pbs_post_validators.rs | 8 +- tests/tests/signer_jwt_auth.rs | 199 ++-- tests/tests/signer_jwt_auth_cleanup.rs | 70 ++ tests/tests/signer_request_sig.rs | 197 ++++ tests/tests/signer_tls.rs | 58 ++ 60 files changed, 5025 insertions(+), 670 deletions(-) create mode 100644 .cargo/audit.toml create mode 100644 crates/common/src/commit/response.rs create mode 100644 crates/signer/src/utils.rs create mode 100644 docs/docs/developing/prop-commit-signing.md create mode 100644 docs/docs/res/img/prop_commit_tree.png create mode 100644 tests/data/configs/signer.happy.toml create mode 100644 tests/src/signer_service.rs create mode 100644 tests/tests/signer_jwt_auth_cleanup.rs create mode 100644 tests/tests/signer_request_sig.rs create mode 100644 tests/tests/signer_tls.rs diff --git a/.cargo/audit.toml b/.cargo/audit.toml new file mode 100644 index 00000000..38e462f5 --- /dev/null +++ b/.cargo/audit.toml @@ -0,0 +1,27 @@ +# RUSTSEC-2026-0049: CRL revocation checking bug in rustls-webpki 0.101.7. +# +# Background: CRL (Certificate Revocation List) checking is an optional TLS +# feature where a client fetches a list of revoked certificates from URLs +# embedded in the cert itself, to confirm it hasn't been invalidated since +# issuance. This is distinct from normal certificate validation. +# +# The bug: when a cert lists multiple CRL distribution point URLs, only the +# first URL is checked; the rest are silently ignored. This matters only when +# CRL checking is enabled AND the UnknownStatusPolicy is set to Allow (meaning +# "if I can't determine revocation status, accept the cert anyway"). With that +# combination, a revoked certificate from a compromised CA could be accepted. +# +# Why this does not affect Commit-Boost: the vulnerable code path is never +# reached because no code in this codebase enables CRL checking at all. +# TLS is used in four places: (1) relay communication via reqwest with +# rustls-tls uses default CA validation with no CRL configured; (2) the signer +# server presents a TLS certificate but does not check client revocation; +# (3) the signer client pins a single self-signed certificate via +# add_root_certificate — CRL is irrelevant for self-signed certs; (4) the Dirk +# remote signer uses mTLS with a custom CA but again no CRL. In all cases the +# buggy CRL code in rustls-webpki is never invoked. +# +# Blocked on sigp/lighthouse upgrading past v8.0.1 without a compilation +# regression (SseEventSource missing cfg guard in eth2 error.rs). +[advisories] +ignore = ["RUSTSEC-2026-0049"] \ No newline at end of file diff --git a/.gitignore b/.gitignore index 739e111a..22136e88 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ targets.json .idea/ logs .vscode/ +certs/ # Nix .direnv/ diff --git a/Cargo.toml b/Cargo.toml index 49eb7314..0d7643ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ assert_cmd = "2.1.2" async-trait = "0.1.80" axum = { version = "0.8.1", features = ["macros"] } axum-extra = { version = "0.10.0", features = ["typed-header"] } +axum-server = { version = "0.7.2", features = ["tls-rustls"] } base64 = "0.22.1" bimap = { version = "0.6.3", features = ["serde"] } blsful = "^2.5" @@ -38,6 +39,7 @@ cb-signer = { path = "crates/signer" } cipher = "0.4" clap = { version = "4.5.48", features = ["derive", "env"] } color-eyre = "0.6.3" +const_format = "0.2.34" ctr = "0.9.2" derive_more = { version = "2.0.1", features = ["deref", "display", "from", "into"] } docker-compose-types = "0.16.0" @@ -62,7 +64,10 @@ prometheus = "0.14.0" prost = "0.13.4" rand = { version = "0.9", features = ["os_rng"] } rayon = "1.10.0" -reqwest = { version = "0.13", features = ["json", "stream"] } +reqwest = { version = "0.13", features = ["json", "stream", "rustls-tls"] } +rcgen = "0.13.2" +reqwest-eventsource = "=0.5.0" +rustls = "0.23.23" serde = { version = "1.0.202", features = ["derive"] } serde_json = "1.0.117" serde_yaml = "0.9.33" diff --git a/api/signer-api.yml b/api/signer-api.yml index c876a3a2..be44f8fd 100644 --- a/api/signer-api.yml +++ b/api/signer-api.yml @@ -1,7 +1,7 @@ -openapi: "3.0.2" +openapi: "3.1.1" info: title: Signer API - version: "0.1.0" + version: "0.2.0" description: API that allows commit modules to request generic signatures from validators tags: - name: Signer @@ -10,6 +10,13 @@ paths: /signer/v1/get_pubkeys: get: summary: Get a list of public keys for which signatures may be requested + description: > + This endpoint requires a valid JWT Bearer token. + + The token **must include** the following claims: + - `exp` (integer): Expiration timestamp + - `route` (string): The route being requested (must be `/signer/v1/get_pubkeys` for this endpoint). + - `module` (string): The ID of the module making the request, which must match a module ID in the Commit-Boost configuration file. tags: - Signer security: @@ -58,9 +65,17 @@ paths: type: string example: "Internal error" - /signer/v1/request_signature: + /signer/v1/request_signature/bls: post: - summary: Send a signature request + summary: Request a signature for a 32-byte blob of data (typically a hash), signed by the BLS private key for the requested public key. + description: > + This endpoint requires a valid JWT Bearer token. + + The token **must include** the following claims: + - `exp` (integer): Expiration timestamp + - `module` (string): The ID of the module making the request, which must match a module ID in the Commit-Boost configuration file. + - `route` (string): The route being requested (must be `/signer/v1/request_signature/bls` for this endpoint). + - `payload_hash` (string): The Keccak-256 hash of the JSON-encoded request body, with optional `0x` prefix. This is required to prevent JWT replay attacks. tags: - Signer security: @@ -71,63 +86,366 @@ paths: application/json: schema: type: object - required: [type, object_root] - oneOf: - - required: [pubkey] - - required: [proxy] + required: [pubkey, object_root, nonce] properties: - type: - description: Type of the sign request - type: string - enum: [consensus, proxy_bls, proxy_ecdsa] pubkey: - description: Public key of the validator for consensus signatures + description: The 48-byte BLS public key, with optional `0x` prefix, of the proposer key that you want to request a signature from. $ref: "#/components/schemas/BlsPubkey" + object_root: + description: The 32-byte data you want to sign, with optional `0x` prefix. + $ref: "#/components/schemas/B256" + nonce: + $ref: "#/components/schemas/Nonce" + example: + pubkey: "0xa3ffa9241f78279f1af04644cb8c79c2d8f02bcf0e28e2f186f6dcccac0a869c2be441fda50f0dea895cfce2e53f0989" + object_root: "0x3e9f4a78b5c21d64f0b8e3d9a7f5c02b4d1e67a3c8f29b5d6e4a3b1c8f72e6d9" + responses: + "200": + description: A successful signature response. + content: + application/json: + schema: + $ref: "#/components/schemas/BlsSignatureResponse" + example: + pubkey: "0x883827193f7627cd04e621e1e8d56498362a52b2a30c9a1c72036eb935c4278dee23d38a24d2f7dda62689886f0c39f4" + object_root: "0x0123456789012345678901234567890123456789012345678901234567890123" + module_signing_id: "0x6a33a23ef26a4836979edff86c493a69b26ccf0b4a16491a815a13787657431b" + signature: "0xa43e623f009e615faa3987368f64d6286a4103de70e9a81d82562c50c91eae2d5d6fb9db9fe943aa8ee42fd92d8210c1149f25ed6aa72a557d74a0ed5646fdd0e8255ec58e3e2931695fe913863ba0cdf90d29f651bce0a34169a6f6ce5b3115" + "400": + description: | + This can occur in several scenarios: + - The Commit-Boost configuration file does not contain a signing ID for the module that made the request. + - You requested an operation while using the Dirk signer mode instead of locally-managed signer mode, but Dirk doesn't support that operation. + - Something went wrong while preparing your request; the error text will provide more information. + content: + application/json: + schema: + type: object + required: + - code + - message + properties: + code: + type: number + example: 400 + message: + type: string + example: "Bad request: Invalid pubkey format" + "401": + description: The requesting module did not provide a JWT string in the request's authorization header, or the JWT string was not configured in the signer service's configuration file as belonging to the module. + content: + application/json: + schema: + type: object + required: + - code + - message + properties: + code: + type: number + example: 401 + message: + type: string + example: "Unauthorized" + + "404": + description: You either requested a route that doesn't exist, or you requested a signature from a key that does not exist. + content: + application/json: + schema: + type: object + required: + - code + - message + properties: + code: + type: number + example: 404 + message: + type: string + example: "Unknown pubkey" + "429": + description: Your module attempted and failed JWT authentication too many times recently, and is currently timed out. It cannot make any more requests until the timeout ends. + content: + application/json: + schema: + type: object + required: + - code + - message + properties: + code: + type: number + example: 429 + message: + type: string + example: "Too many requests" + "500": + description: Your request was valid, but something went wrong internally that prevented it from being fulfilled. + content: + application/json: + schema: + type: object + required: + - code + - message + properties: + code: + type: number + example: 500 + message: + type: string + example: "Internal error" + "502": + description: The signer service is running in Dirk signer mode, but Dirk could not be reached. + content: + application/json: + schema: + type: object + required: + - code + - message + properties: + code: + type: number + example: 502 + message: + type: string + example: "Bad gateway: Dirk signer service is unreachable" + + /signer/v1/request_signature/proxy-bls: + post: + summary: Request a signature for a 32-byte blob of data (typically a hash), signed by the BLS private key for the requested proxy public key. + description: > + This endpoint requires a valid JWT Bearer token. + + The token **must include** the following claims: + - `exp` (integer): Expiration timestamp + - `module` (string): The ID of the module making the request, which must match a module ID in the Commit-Boost configuration file. + - `route` (string): The route being requested (must be `/signer/v1/request_signature/proxy-bls` for this endpoint). + - `payload_hash` (string): The Keccak-256 hash of the JSON-encoded request body, with optional `0x` prefix. This is required to prevent JWT replay attacks. + tags: + - Signer + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [proxy, object_root, nonce] + properties: proxy: - description: BLS proxy pubkey or ECDSA address for proxy signatures - oneOf: - - $ref: "#/components/schemas/BlsPubkey" - - $ref: "#/components/schemas/EcdsaAddress" + description: The 48-byte BLS public key (for `proxy_bls` mode) or the 20-byte Ethereum address (for `proxy_ecdsa` mode), with optional `0x` prefix, of the proxy key that you want to request a signature from. + $ref: "#/components/schemas/BlsPubkey" object_root: - description: The root of the object to be signed - type: string - format: hex - pattern: "^0x[a-fA-F0-9]{64}$" - example: "0x3e9f4a78b5c21d64f0b8e3d9a7f5c02b4d1e67a3c8f29b5d6e4a3b1c8f72e6d9" - examples: - Consensus: - value: - type: "consensus" - pubkey: "0xa3ffa9241f78279f1af04644cb8c79c2d8f02bcf0e28e2f186f6dcccac0a869c2be441fda50f0dea895cfce2e53f0989" - object_root: "0x3e9f4a78b5c21d64f0b8e3d9a7f5c02b4d1e67a3c8f29b5d6e4a3b1c8f72e6d9" - ProxyBls: - value: - type: "proxy_bls" - proxy: "0xa3ffa9241f78279f1af04644cb8c79c2d8f02bcf0e28e2f186f6dcccac0a869c2be441fda50f0dea895cfce2e53f0989" - object_root: "0x3e9f4a78b5c21d64f0b8e3d9a7f5c02b4d1e67a3c8f29b5d6e4a3b1c8f72e6d9" - ProxyEcdsa: - value: - type: "proxy_ecdsa" - proxy: "0x71f65e9f6336770e22d148bd5e89b391a1c3b0bb" - object_root: "0x3e9f4a78b5c21d64f0b8e3d9a7f5c02b4d1e67a3c8f29b5d6e4a3b1c8f72e6d9" + description: The 32-byte data you want to sign, with optional `0x` prefix. + $ref: "#/components/schemas/B256" + nonce: + $ref: "#/components/schemas/Nonce" + example: + pubkey: "0xa3ffa9241f78279f1af04644cb8c79c2d8f02bcf0e28e2f186f6dcccac0a869c2be441fda50f0dea895cfce2e53f0989" + object_root: "0x3e9f4a78b5c21d64f0b8e3d9a7f5c02b4d1e67a3c8f29b5d6e4a3b1c8f72e6d9" responses: "200": - description: Success + description: A successful signature response. content: application/json: schema: - oneOf: - - $ref: "#/components/schemas/BlsSignature" - - $ref: "#/components/schemas/EcdsaSignature" - examples: - Consensus: - value: "0xa3ffa9241f78279f1af04644cb8c79c2d8f02bcf0e28e2f186f6dcccac0a869c2be441fda50f0dea895cfce2e53f0989a3ffa9241f78279f1af04644cb8c79c2d8f02bcf0e28e2f186f6dcccac0a869c2be441fda50f0dea895cfce2e53f0989" - ProxyBls: - value: "0xa3ffa9241f78279f1af04644cb8c79c2d8f02bcf0e28e2f186f6dcccac0a869c2be441fda50f0dea895cfce2e53f0989a3ffa9241f78279f1af04644cb8c79c2d8f02bcf0e28e2f186f6dcccac0a869c2be441fda50f0dea895cfce2e53f0989" - ProxyEcdsa: - value: "0x985b495f49d1b96db3bba3f6c5dd1810950317c10d4c2042bd316f338cdbe74359072e209b85e56ac492092d7860063dd096ca31b4e164ef27e3f8d508e656801c" + $ref: "#/components/schemas/BlsSignatureResponse" + example: + pubkey: "0x883827193f7627cd04e621e1e8d56498362a52b2a30c9a1c72036eb935c4278dee23d38a24d2f7dda62689886f0c39f4" + object_root: "0x0123456789012345678901234567890123456789012345678901234567890123" + module_signing_id: "0x6a33a23ef26a4836979edff86c493a69b26ccf0b4a16491a815a13787657431b" + signature: "0xa43e623f009e615faa3987368f64d6286a4103de70e9a81d82562c50c91eae2d5d6fb9db9fe943aa8ee42fd92d8210c1149f25ed6aa72a557d74a0ed5646fdd0e8255ec58e3e2931695fe913863ba0cdf90d29f651bce0a34169a6f6ce5b3115" + "400": + description: | + This can occur in several scenarios: + - The Commit-Boost configuration file does not contain a signing ID for the module that made the request. + - You requested an operation while using the Dirk signer mode instead of locally-managed signer mode, but Dirk doesn't support that operation. + - Something went wrong while preparing your request; the error text will provide more information. + content: + application/json: + schema: + type: object + required: + - code + - message + properties: + code: + type: number + example: 400 + message: + type: string + example: "Bad request: Invalid pubkey format" + "401": + description: The requesting module did not provide a JWT string in the request's authorization header, or the JWT string was not configured in the signer service's configuration file as belonging to the module. + content: + application/json: + schema: + type: object + required: + - code + - message + properties: + code: + type: number + example: 401 + message: + type: string + example: "Unauthorized" + "404": - description: Unknown value (pubkey, etc.) + description: You either requested a route that doesn't exist, or you requested a signature from a key that does not exist. + content: + application/json: + schema: + type: object + required: + - code + - message + properties: + code: + type: number + example: 404 + message: + type: string + example: "Unknown pubkey" + "429": + description: Your module attempted and failed JWT authentication too many times recently, and is currently timed out. It cannot make any more requests until the timeout ends. + content: + application/json: + schema: + type: object + required: + - code + - message + properties: + code: + type: number + example: 429 + message: + type: string + example: "Too many requests" + "500": + description: Your request was valid, but something went wrong internally that prevented it from being fulfilled. + content: + application/json: + schema: + type: object + required: + - code + - message + properties: + code: + type: number + example: 500 + message: + type: string + example: "Internal error" + "502": + description: The signer service is running in Dirk signer mode, but Dirk could not be reached. + content: + application/json: + schema: + type: object + required: + - code + - message + properties: + code: + type: number + example: 502 + message: + type: string + example: "Bad gateway: Dirk signer service is unreachable" + + /signer/v1/request_signature/proxy-ecdsa: + post: + summary: Request a signature for a 32-byte blob of data (typically a hash), signed by the ECDSA private key for the requested proxy Ethereum address. + description: > + This endpoint requires a valid JWT Bearer token. + + The token **must include** the following claims: + - `exp` (integer): Expiration timestamp + - `module` (string): The ID of the module making the request, which must match a module ID in the Commit-Boost configuration file. + - `route` (string): The route being requested (must be `/signer/v1/request_signature/proxy-ecdsa` for this endpoint). + - `payload_hash` (string): The Keccak-256 hash of the JSON-encoded request body, with optional `0x` prefix. This is required to prevent JWT replay attacks. + tags: + - Signer + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [proxy, object_root, nonce] + properties: + proxy: + description: The 20-byte Ethereum address, with optional `0x` prefix, of the proxy key that you want to request a signature from. + $ref: "#/components/schemas/EcdsaAddress" + object_root: + description: The 32-byte data you want to sign, with optional `0x` prefix. + $ref: "#/components/schemas/B256" + nonce: + $ref: "#/components/schemas/Nonce" + example: + proxy: "0x71f65e9f6336770e22d148bd5e89b391a1c3b0bb" + object_root: "0x3e9f4a78b5c21d64f0b8e3d9a7f5c02b4d1e67a3c8f29b5d6e4a3b1c8f72e6d9" + responses: + "200": + description: A successful signature response. + content: + application/json: + schema: + $ref: "#/components/schemas/EcdsaSignatureResponse" + example: + address: "0x71f65e9f6336770e22d148bd5e89b391a1c3b0bb" + object_root: "0x3e9f4a78b5c21d64f0b8e3d9a7f5c02b4d1e67a3c8f29b5d6e4a3b1c8f72e6d9" + module_signing_id: "0x6a33a23ef26a4836979edff86c493a69b26ccf0b4a16491a815a13787657431b" + signature: "0x985b495f49d1b96db3bba3f6c5dd1810950317c10d4c2042bd316f338cdbe74359072e209b85e56ac492092d7860063dd096ca31b4e164ef27e3f8d508e656801c" + "400": + description: | + This can occur in several scenarios: + - The Commit-Boost configuration file does not contain a signing ID for the module that made the request. + - You requested an operation while using the Dirk signer mode instead of locally-managed signer mode, but Dirk doesn't support that operation. + - Something went wrong while preparing your request; the error text will provide more information. + content: + application/json: + schema: + type: object + required: + - code + - message + properties: + code: + type: number + example: 400 + message: + type: string + example: "Bad request: Invalid pubkey format" + "401": + description: The requesting module did not provide a JWT string in the request's authorization header, or the JWT string was not configured in the signer service's configuration file as belonging to the module. + content: + application/json: + schema: + type: object + required: + - code + - message + properties: + code: + type: number + example: 401 + message: + type: string + example: "Unauthorized" + + "404": + description: You either requested a route that doesn't exist, or you requested a signature from a key that does not exist. content: application/json: schema: @@ -142,8 +460,24 @@ paths: message: type: string example: "Unknown pubkey" + "429": + description: Your module attempted and failed JWT authentication too many times recently, and is currently timed out. It cannot make any more requests until the timeout ends. + content: + application/json: + schema: + type: object + required: + - code + - message + properties: + code: + type: number + example: 429 + message: + type: string + example: "Too many requests" "500": - description: Internal error + description: Your request was valid, but something went wrong internally that prevented it from being fulfilled. content: application/json: schema: @@ -158,10 +492,34 @@ paths: message: type: string example: "Internal error" + "502": + description: The signer service is running in Dirk signer mode, but Dirk could not be reached. + content: + application/json: + schema: + type: object + required: + - code + - message + properties: + code: + type: number + example: 502 + message: + type: string + example: "Bad gateway: Dirk signer service is unreachable" /signer/v1/generate_proxy_key: post: summary: Request a proxy key be generated for a specific consensus pubkey + description: > + This endpoint requires a valid JWT Bearer token. + + The token **must include** the following claims: + - `exp` (integer): Expiration timestamp + - `module` (string): The ID of the module making the request, which must match a module ID in the Commit-Boost configuration file. + - `route` (string): The route being requested (must be `/signer/v1/generate_proxy_key` for this endpoint). + - `payload_hash` (string): The Keccak-256 hash of the JSON-encoded request body, with optional `0x` prefix. This is required to prevent JWT replay attacks. tags: - Signer security: @@ -261,20 +619,6 @@ paths: type: string example: "Internal error" - /status: - get: - summary: Get the status of the Signer API module - tags: - - Management - responses: - "200": - description: Success - content: - text/plain: - schema: - type: string - example: "OK" - components: securitySchemes: BearerAuth: @@ -282,6 +626,11 @@ components: scheme: bearer bearerFormat: JWT schemas: + B256: + type: string + format: hex + pattern: "^0x[a-fA-F0-9]{64}$" + example: "0x3e9f4a78b5c21d64f0b8e3d9a7f5c02b4d1e67a3c8f29b5d6e4a3b1c8f72e6d9" BlsPubkey: type: string format: hex @@ -302,3 +651,56 @@ components: format: hex pattern: "^0x[a-fA-F0-9]{130}$" example: "0x985b495f49d1b96db3bba3f6c5dd1810950317c10d4c2042bd316f338cdbe74359072e209b85e56ac492092d7860063dd096ca31b4e164ef27e3f8d508e656801c" + BlsSignatureResponse: + type: object + properties: + pubkey: + description: The BLS public key corresponding to the private key that was used to sign the request + $ref: "#/components/schemas/BlsPubkey" + object_root: + description: The 32-byte data that was signed, with `0x` prefix + $ref: "#/components/schemas/B256" + module_signing_id: + description: The signing ID of the module that requested the signature, as specified in the Commit-Boost configuration + $ref: "#/components/schemas/B256" + nonce: + $ref: "#/components/schemas/Nonce" + chain_id: + description: The chain ID that the signature is valid for, as specified in the Commit-Boost configuration + type: integer + example: 1 + signature: + description: The BLS signature of the Merkle root hash of the provided `object_root` field and the requesting module's Signing ID. For details on this signature, see the [signature structure documentation](https://commit-boost.github.io/commit-boost-client/developing/prop-commit-signing.md#structure-of-a-signature). + $ref: "#/components/schemas/BlsSignature" + EcdsaSignatureResponse: + type: object + properties: + address: + description: The ECDSA address corresponding to the private key that was used to sign the request + $ref: "#/components/schemas/EcdsaAddress" + object_root: + description: The 32-byte data that was signed, with `0x` prefix + $ref: "#/components/schemas/B256" + module_signing_id: + description: The signing ID of the module that requested the signature, as specified in the Commit-Boost configuration + $ref: "#/components/schemas/B256" + nonce: + $ref: "#/components/schemas/Nonce" + chain_id: + description: The chain ID that the signature is valid for, as specified in the Commit-Boost configuration + type: integer + example: 1 + signature: + description: The ECDSA signature (in Ethereum RSV format) of the Merkle root hash of the provided `object_root` field and the requesting module's Signing ID. For details on this signature, see the [signature structure documentation](https://commit-boost.github.io/commit-boost-client/developing/prop-commit-signing.md#structure-of-a-signature). + $ref: "#/components/schemas/EcdsaSignature" + Nonce: + type: integer + description: | + Replay-protection nonce, always mixed into the signing root via `PropCommitSigningInfo`. It + must be an unsigned 64-bit integer between 0 and 2^64-2 (18446744073709551614), inclusive. + + Modules that track nonces for replay protection should use a monotonically increasing value + per key. Modules that do not use replay protection should always send `0`. + minimum: 0 + maximum: 18446744073709551614 + example: 1 diff --git a/bin/src/lib.rs b/bin/src/lib.rs index 487a46ef..0897aa34 100644 --- a/bin/src/lib.rs +++ b/bin/src/lib.rs @@ -9,6 +9,9 @@ pub mod prelude { LogsSettings, PBS_SERVICE_NAME, StartCommitModuleConfig, load_builder_module_config, load_commit_module_config, load_pbs_config, load_pbs_custom_config, }, + signature::{ + verify_proposer_commitment_signature_bls, verify_proposer_commitment_signature_ecdsa, + }, signer::EcdsaSignature, types::{BlsPublicKey, BlsSignature, Chain}, utils::{initialize_tracing_log, utcnow_ms, utcnow_ns, utcnow_sec, utcnow_us}, diff --git a/bin/tests/binary.rs b/bin/tests/binary.rs index c000ed11..6352589e 100644 --- a/bin/tests/binary.rs +++ b/bin/tests/binary.rs @@ -23,6 +23,7 @@ key_path = "/keys/keys.json" id = "DA_COMMIT" type = "commit" docker_image = "test_da_commit" +signing_id = "0x6a33a23ef26a4836979edff86c493a69b26ccf0b4a16491a815a13787657431b" "#; // --------------------------------------------------------------------------- diff --git a/config.example.toml b/config.example.toml index cc065442..41707354 100644 --- a/config.example.toml +++ b/config.example.toml @@ -173,10 +173,10 @@ url = "http://0xa119589bb33ef52acbb8116832bec2b58fca590fe5c85eac5d3230b44d5bc09f # - Dirk: a remote Dirk instance # - Local: a local Signer module # More details on the docs (https://commit-boost.github.io/commit-boost-client/get_started/configuration/#signer-module) -# [signer] +[signer] # Docker image to use for the Signer module. # OPTIONAL, DEFAULT: ghcr.io/commit-boost/signer:latest -# docker_image = "ghcr.io/commit-boost/signer:latest" +docker_image = "ghcr.io/commit-boost/signer:latest" # Host to bind the Signer API server to # OPTIONAL, DEFAULT: 127.0.0.1 host = "127.0.0.1" @@ -186,10 +186,33 @@ port = 20000 # Number of JWT authentication attempts a client can fail before blocking that client temporarily from Signer access # OPTIONAL, DEFAULT: 3 jwt_auth_fail_limit = 3 -# How long to block a client from Signer access, in seconds, if it failed JWT authentication too many times +# How long to block a client from Signer access, in seconds, if it failed JWT authentication too many times. +# This also defines the interval at which failed attempts are regularly checked and expired ones are cleaned up. # OPTIONAL, DEFAULT: 300 jwt_auth_fail_timeout_seconds = 300 +# HTTP header to use to determine the real client IP, if the Signer is behind a proxy (e.g. nginx) +# OPTIONAL. If missing, the client IP will be taken directly from the TCP connection. +# [signer.reverse_proxy] +# Type of reverse proxy configuration. Supported values: +# - unique: use a single HTTP header value as the client IP. +# - rightmost: use the rightmost IP from a comma-separated list of IPs in the HTTP header. +# type = "unique" +# Unique: HTTP header name to use to determine the real client IP. If the header appears multiple times, the request will be rejected. +# header = "X-Real-IP" +# Rightmost: HTTP header name to use to determine the real client IP from a comma-separated list of IPs. If the header appears multiple times, the last value will be used. +# header = "X-Forwarded-For" +# Rightmost: number of trusted proxies in front of the Signer, whose IPs will be skipped when extracting the client IP from the rightmost side of the list. Must be greater than 0. +# trusted_count = 1 + +# [signer.tls_mode] +# How to use TLS for the Signer's HTTP server; two modes are supported: +# - type = "insecure": disable TLS, so the server runs in HTTP mode (not recommended for production). +# - type = "certificate": Use TLS. Include a property named "path" below this with the provided path; `path` should be a directory containing `cert.pem` and `key.pem` files to use. If they don't exist, they'll be automatically generated in self-signed mode. +# OPTIONAL, DEFAULT: +# type = "certificate" +# path = "./certs" + # For Remote signer: # [signer.remote] # URL of the Web3Signer instance @@ -272,6 +295,8 @@ proxy_dir = "./proxies" [[modules]] # Unique ID of the module id = "DA_COMMIT" +# Unique hash that the Signer service will combine with the incoming data in signing requests to generate a signature specific to this module +signing_id = "0x6a33a23ef26a4836979edff86c493a69b26ccf0b4a16491a815a13787657431b" # Type of the module. Supported values: commit type = "commit" # Docker image of the module diff --git a/crates/cli/src/docker_init.rs b/crates/cli/src/docker_init.rs index 3cbde28a..f2c5e2e4 100644 --- a/crates/cli/src/docker_init.rs +++ b/crates/cli/src/docker_init.rs @@ -6,16 +6,17 @@ use std::{ use cb_common::{ config::{ - CHAIN_SPEC_ENV, CONFIG_DEFAULT, CONFIG_ENV, CommitBoostConfig, DIRK_CA_CERT_DEFAULT, - DIRK_CA_CERT_ENV, DIRK_CERT_DEFAULT, DIRK_CERT_ENV, DIRK_DIR_SECRETS_DEFAULT, - DIRK_DIR_SECRETS_ENV, DIRK_KEY_DEFAULT, DIRK_KEY_ENV, JWTS_ENV, LOGS_DIR_DEFAULT, - LOGS_DIR_ENV, LogsSettings, METRICS_PORT_ENV, MODULE_ID_ENV, MODULE_JWT_ENV, ModuleKind, - PBS_ENDPOINT_ENV, PBS_SERVICE_NAME, PROXY_DIR_DEFAULT, PROXY_DIR_ENV, - PROXY_DIR_KEYS_DEFAULT, PROXY_DIR_KEYS_ENV, PROXY_DIR_SECRETS_DEFAULT, + ADMIN_JWT_ENV, CHAIN_SPEC_ENV, CONFIG_DEFAULT, CONFIG_ENV, CommitBoostConfig, + DIRK_CA_CERT_DEFAULT, DIRK_CA_CERT_ENV, DIRK_CERT_DEFAULT, DIRK_CERT_ENV, + DIRK_DIR_SECRETS_DEFAULT, DIRK_DIR_SECRETS_ENV, DIRK_KEY_DEFAULT, DIRK_KEY_ENV, JWTS_ENV, + LOGS_DIR_DEFAULT, LOGS_DIR_ENV, LogsSettings, METRICS_PORT_ENV, MODULE_ID_ENV, + MODULE_JWT_ENV, ModuleKind, PBS_ENDPOINT_ENV, PBS_SERVICE_NAME, PROXY_DIR_DEFAULT, + PROXY_DIR_ENV, PROXY_DIR_KEYS_DEFAULT, PROXY_DIR_KEYS_ENV, PROXY_DIR_SECRETS_DEFAULT, PROXY_DIR_SECRETS_ENV, SIGNER_DEFAULT, SIGNER_DIR_KEYS_DEFAULT, SIGNER_DIR_KEYS_ENV, SIGNER_DIR_SECRETS_DEFAULT, SIGNER_DIR_SECRETS_ENV, SIGNER_ENDPOINT_ENV, SIGNER_KEYS_ENV, - SIGNER_PORT_DEFAULT, SIGNER_SERVICE_NAME, SIGNER_URL_ENV, SignerConfig, SignerType, - StaticModuleConfig, + SIGNER_PORT_DEFAULT, SIGNER_SERVICE_NAME, SIGNER_TLS_CERTIFICATE_NAME, + SIGNER_TLS_CERTIFICATES_PATH_DEFAULT, SIGNER_TLS_CERTIFICATES_PATH_ENV, + SIGNER_TLS_KEY_NAME, SIGNER_URL_ENV, SignerConfig, SignerType, StaticModuleConfig, }, pbs::{BUILDER_V1_API_PATH, GET_STATUS_PATH}, signer::{ProxyStore, SignerLoader}, @@ -130,10 +131,7 @@ pub async fn handle_docker_init(config_path: PathBuf, output_dir: PathBuf) -> Re .as_ref() .map(|m| m.start_port) .unwrap_or_default(); - let needs_signer_module = service_config.config_info.cb_config.pbs.with_signer || - service_config.config_info.cb_config.modules.as_ref().is_some_and(|modules| { - modules.iter().any(|module| matches!(module.kind, ModuleKind::Commit)) - }); + let needs_signer_module = service_config.config_info.cb_config.needs_signer_module(); let signer_config = if needs_signer_module { Some(service_config.config_info.cb_config.signer.clone().ok_or_else(|| { eyre::eyre!( @@ -143,26 +141,21 @@ pub async fn handle_docker_init(config_path: PathBuf, output_dir: PathBuf) -> Re } else { None }; - let signer_server = if let Some(SignerConfig { inner: SignerType::Remote { url }, .. }) = - &service_config.config_info.cb_config.signer - { - url.to_string() - } else { - let signer_port = service_config - .config_info - .cb_config - .signer - .as_ref() - .map(|s| s.port) - .unwrap_or(SIGNER_PORT_DEFAULT); - format!("http://cb_signer:{signer_port}") - }; + let signer_server_url = + service_config.config_info.cb_config.signer_server_url(SIGNER_PORT_DEFAULT); + + // Warn if the certificates path is not set for a TLS signer + if service_config.config_info.cb_config.signer_certs_path().is_none() { + service_config.warnings.push( + "Signer TLS mode is set to Insecure, using HTTP instead of HTTPS for signer communication".to_string(), + ); + } // setup modules if let Some(ref modules_config) = service_config.config_info.cb_config.modules { for module in modules_config.clone() { let (module_cid, module_service) = - create_module_service(&module, signer_server.as_str(), &mut service_config)?; + create_module_service(&module, signer_server_url.as_str(), &mut service_config)?; services.insert(module_cid, Some(module_service)); } }; @@ -296,14 +289,24 @@ fn create_pbs_service(service_config: &mut ServiceCreationInfo) -> eyre::Result< service_config.metrics_port += 1; } - // Logging + // Logging env/volume if cb_config.logs.file.enabled { let (key, val) = get_env_val(LOGS_DIR_ENV, LOGS_DIR_DEFAULT); envs.insert(key, val); } + volumes.extend(get_log_volume(&cb_config.logs, PBS_SERVICE_NAME)?); + + // Certs env/volume + if cb_config.needs_signer_module() && + let Some(certs_path) = cb_config.signer_certs_path() + { + volumes.push(create_cert_binding(certs_path)); + let (key, val) = + get_env_val(SIGNER_TLS_CERTIFICATES_PATH_ENV, SIGNER_TLS_CERTIFICATES_PATH_DEFAULT); + envs.insert(key, val); + } // Create the service - volumes.extend(get_log_volume(&cb_config.logs, PBS_SERVICE_NAME)?); let pbs_service = Service { container_name: Some("cb_pbs".to_owned()), image: Some(cb_config.pbs.docker_image.clone()), @@ -338,8 +341,12 @@ fn create_signer_service_local( let cb_config = &service_config.config_info.cb_config; let config_volume = &service_config.config_info.config_volume; let metrics_port = service_config.metrics_port; - let mut envs = - IndexMap::from([get_env_val(CONFIG_ENV, CONFIG_DEFAULT), get_env_same(JWTS_ENV)]); + let mut envs = IndexMap::from([ + get_env_val(CONFIG_ENV, CONFIG_DEFAULT), + get_env_same(JWTS_ENV), + get_env_same(ADMIN_JWT_ENV), + get_env_val(SIGNER_TLS_CERTIFICATES_PATH_ENV, SIGNER_TLS_CERTIFICATES_PATH_DEFAULT), + ]); let mut volumes = vec![config_volume.clone()]; // Bind the API to 0.0.0.0 @@ -373,7 +380,7 @@ fn create_signer_service_local( service_config.metrics_port += 1; } - // Logging + // Logging envs/volume if cb_config.logs.file.enabled { let (key, val) = get_env_val(LOGS_DIR_ENV, LOGS_DIR_DEFAULT); envs.insert(key, val); @@ -382,6 +389,7 @@ fn create_signer_service_local( // write jwts to env service_config.envs.insert(JWTS_ENV.into(), format_comma_separated(&service_config.jwts)); + service_config.envs.insert(ADMIN_JWT_ENV.into(), random_jwt_secret()); // Signer loader volumes and envs match loader { @@ -441,6 +449,11 @@ fn create_signer_service_local( } } + // Add TLS support if needed + if let Some(certs_path) = cb_config.signer_certs_path() { + add_tls_certs_volume(&mut volumes, certs_path)? + } + // Create the service let signer_networks = vec![SIGNER_NETWORK.to_owned()]; let signer_service = Service { @@ -452,8 +465,8 @@ fn create_signer_service_local( environment: Environment::KvPair(envs), healthcheck: Some(Healthcheck { test: Some(HealthcheckTest::Single(format!( - "curl -f http://localhost:{}/status", - signer_config.port, + "curl -k -f {}/status", + cb_config.signer_server_url(SIGNER_PORT_DEFAULT), ))), interval: Some("30s".into()), timeout: Some("5s".into()), @@ -484,6 +497,8 @@ fn create_signer_service_dirk( let mut envs = IndexMap::from([ get_env_val(CONFIG_ENV, CONFIG_DEFAULT), get_env_same(JWTS_ENV), + get_env_same(ADMIN_JWT_ENV), + get_env_val(SIGNER_TLS_CERTIFICATES_PATH_ENV, SIGNER_TLS_CERTIFICATES_PATH_DEFAULT), get_env_val(DIRK_CERT_ENV, DIRK_CERT_DEFAULT), get_env_val(DIRK_KEY_ENV, DIRK_KEY_DEFAULT), get_env_val(DIRK_DIR_SECRETS_ENV, DIRK_DIR_SECRETS_DEFAULT), @@ -526,7 +541,7 @@ fn create_signer_service_dirk( service_config.metrics_port += 1; } - // Logging + // Logging env/volume if cb_config.logs.file.enabled { let (key, val) = get_env_val(LOGS_DIR_ENV, LOGS_DIR_DEFAULT); envs.insert(key, val); @@ -535,6 +550,7 @@ fn create_signer_service_dirk( // write jwts to env service_config.envs.insert(JWTS_ENV.into(), format_comma_separated(&service_config.jwts)); + service_config.envs.insert(ADMIN_JWT_ENV.into(), random_jwt_secret()); // CA cert volume and env if let Some(ca_cert_path) = ca_cert_path { @@ -560,6 +576,11 @@ fn create_signer_service_dirk( None => {} } + // Add TLS support if needed + if let Some(certs_path) = cb_config.signer_certs_path() { + add_tls_certs_volume(&mut volumes, certs_path)? + } + // Create the service let signer_networks = vec![SIGNER_NETWORK.to_owned()]; let signer_service = Service { @@ -571,8 +592,8 @@ fn create_signer_service_dirk( environment: Environment::KvPair(envs), healthcheck: Some(Healthcheck { test: Some(HealthcheckTest::Single(format!( - "curl -f http://localhost:{}/status", - signer_config.port, + "curl -k -f {}/status", + cb_config.signer_server_url(SIGNER_PORT_DEFAULT), ))), interval: Some("30s".into()), timeout: Some("5s".into()), @@ -614,6 +635,14 @@ fn create_module_service( get_env_val(SIGNER_URL_ENV, signer_server), ]); + if cb_config.signer_uses_tls() { + let env_val = get_env_val( + SIGNER_TLS_CERTIFICATES_PATH_ENV, + SIGNER_TLS_CERTIFICATES_PATH_DEFAULT, + ); + module_envs.insert(env_val.0, env_val.1); + } + // Pass on the env variables if let Some(envs) = &module.env { for (k, v) in envs { @@ -624,6 +653,9 @@ fn create_module_service( // volumes let mut module_volumes = vec![config_volume.clone()]; module_volumes.extend(get_log_volume(&cb_config.logs, &module.id)?); + if let Some(certs_path) = cb_config.signer_certs_path() { + module_volumes.push(create_cert_binding(certs_path)); + } // Chain spec env/volume if let Some(spec) = &service_config.chain_spec { @@ -749,10 +781,6 @@ fn get_env_uval(k: &str, v: u64) -> (String, Option) { (k.into(), Some(SingleValue::Unsigned(v))) } -// fn get_env_bool(k: &str, v: bool) -> (String, Option) { -// (k.into(), Some(SingleValue::Bool(v))) -// } - fn get_log_volume(config: &LogsSettings, module_id: &str) -> eyre::Result> { if !config.file.enabled { return Ok(None); @@ -769,11 +797,47 @@ fn format_comma_separated(map: &IndexMap) -> String { map.iter().map(|(k, v)| format!("{k}={v}")).collect::>().join(",") } +fn create_cert_binding(certs_path: &Path) -> Volumes { + Volumes::Simple(format!( + "{}:{}/{}:ro", + certs_path.join(SIGNER_TLS_CERTIFICATE_NAME).display(), + SIGNER_TLS_CERTIFICATES_PATH_DEFAULT, + SIGNER_TLS_CERTIFICATE_NAME + )) +} + +/// Adds the TLS cert and key bindings to the provided volumes list +fn add_tls_certs_volume(volumes: &mut Vec, certs_path: &Path) -> Result<()> { + if !certs_path.try_exists()? { + std::fs::create_dir(certs_path)?; + } + + if !certs_path.join(SIGNER_TLS_CERTIFICATE_NAME).try_exists()? || + !certs_path.join(SIGNER_TLS_KEY_NAME).try_exists()? + { + return Err(eyre::eyre!( + "Signer TLS certificate or key not found at {}, please provide a valid certificate and key or create them", + certs_path.display() + )); + } + + volumes.push(create_cert_binding(certs_path)); + volumes.push(Volumes::Simple(format!( + "{}:{}/{}:ro", + certs_path.join(SIGNER_TLS_KEY_NAME).display(), + SIGNER_TLS_CERTIFICATES_PATH_DEFAULT, + SIGNER_TLS_KEY_NAME + ))); + + Ok(()) +} + #[cfg(test)] mod tests { use cb_common::{ config::{ CommitBoostConfig, FileLogSettings, LogsSettings, MetricsConfig, StdoutLogSettings, + TlsMode, }, signer::{ProxyStore, SignerLoader}, }; @@ -871,6 +935,13 @@ mod tests { service.volumes.iter().any(|v| matches!(v, Volumes::Simple(s) if s.contains(substr))) } + fn get_healthcheck_cmd(service: &Service) -> Option { + service.healthcheck.as_ref().and_then(|hc| match &hc.test { + Some(HealthcheckTest::Single(cmd)) => Some(cmd.clone()), + _ => None, + }) + } + fn has_port(service: &Service, substr: &str) -> bool { match &service.ports { Ports::Short(ports) => ports.iter().any(|p| p.contains(substr)), @@ -1248,12 +1319,33 @@ mod tests { assert!(env_str(&service, DIRK_CERT_ENV).is_some()); assert!(env_str(&service, DIRK_KEY_ENV).is_some()); assert!(env_str(&service, DIRK_DIR_SECRETS_ENV).is_some()); + assert!(has_env_key(&service, ADMIN_JWT_ENV)); + assert!(has_env_key(&service, SIGNER_TLS_CERTIFICATES_PATH_ENV)); assert!(has_volume(&service, "client.crt")); assert!(has_volume(&service, "client.key")); assert!(has_volume(&service, "dirk_secrets")); Ok(()) } + #[test] + fn test_create_signer_service_dirk_generates_admin_jwt() -> eyre::Result<()> { + let mut sc = minimal_service_config(); + let signer_config = dirk_signer_config(); + create_signer_service_dirk( + &mut sc, + &signer_config, + Path::new("/certs/client.crt"), + Path::new("/certs/client.key"), + Path::new("/dirk_secrets"), + &None, + &None, + )?; + + let admin_jwt = sc.envs.get(ADMIN_JWT_ENV).expect("ADMIN_JWT_ENV must be set"); + assert!(!admin_jwt.is_empty(), "admin JWT secret must not be empty"); + Ok(()) + } + #[test] fn test_create_signer_service_dirk_with_ca_cert() -> eyre::Result<()> { let mut sc = minimal_service_config(); @@ -1344,6 +1436,7 @@ mod tests { id = "DA_COMMIT" type = "commit" docker_image = "test_da_commit" + signing_id = "0x6a33a23ef26a4836979edff86c493a69b26ccf0b4a16491a815a13787657431b" "#, ) .expect("valid module config") @@ -1416,4 +1509,285 @@ mod tests { } Ok(()) } + + // ------------------------------------------------------------------------- + // Helpers for TLS tests + // ------------------------------------------------------------------------- + + fn local_signer_config_with_tls(certs_path: PathBuf) -> SignerConfig { + let mut config = local_signer_config(); + config.tls_mode = TlsMode::Certificate(certs_path); + config + } + + /// Returns a `ServiceCreationInfo` whose CB config has `pbs.with_signer = + /// true` and a local signer with `TlsMode::Certificate(certs_path)`. + fn service_config_with_tls(certs_path: PathBuf) -> ServiceCreationInfo { + let mut sc = minimal_service_config(); + sc.config_info.cb_config.pbs.with_signer = true; + sc.config_info.cb_config.signer = Some(local_signer_config_with_tls(certs_path)); + sc + } + + // ------------------------------------------------------------------------- + // create_cert_binding + // ------------------------------------------------------------------------- + + #[test] + fn test_create_cert_binding_volume_string() { + let certs_path = Path::new("/my/certs"); + let vol = create_cert_binding(certs_path); + let expected = format!( + "/my/certs/{}:{}/{}:ro", + SIGNER_TLS_CERTIFICATE_NAME, + SIGNER_TLS_CERTIFICATES_PATH_DEFAULT, + SIGNER_TLS_CERTIFICATE_NAME + ); + assert_eq!(vol, Volumes::Simple(expected)); + } + + // ------------------------------------------------------------------------- + // add_tls_certs_volume + // ------------------------------------------------------------------------- + + #[test] + fn test_add_tls_certs_volume_happy_path() -> eyre::Result<()> { + let dir = tempfile::tempdir()?; + let certs_path = dir.path(); + std::fs::write(certs_path.join(SIGNER_TLS_CERTIFICATE_NAME), b"cert")?; + std::fs::write(certs_path.join(SIGNER_TLS_KEY_NAME), b"key")?; + + let mut volumes = vec![]; + add_tls_certs_volume(&mut volumes, certs_path)?; + + assert_eq!(volumes.len(), 2); + assert!( + matches!(&volumes[0], Volumes::Simple(s) if s.contains(SIGNER_TLS_CERTIFICATE_NAME)) + ); + assert!(matches!(&volumes[1], Volumes::Simple(s) if s.contains(SIGNER_TLS_KEY_NAME))); + Ok(()) + } + + #[test] + fn test_add_tls_certs_volume_missing_cert_returns_error() -> eyre::Result<()> { + let dir = tempfile::tempdir()?; + let certs_path = dir.path(); + std::fs::write(certs_path.join(SIGNER_TLS_KEY_NAME), b"key")?; + + let result = add_tls_certs_volume(&mut vec![], certs_path); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("certificate or key not found")); + Ok(()) + } + + #[test] + fn test_add_tls_certs_volume_missing_key_returns_error() -> eyre::Result<()> { + let dir = tempfile::tempdir()?; + let certs_path = dir.path(); + std::fs::write(certs_path.join(SIGNER_TLS_CERTIFICATE_NAME), b"cert")?; + + let result = add_tls_certs_volume(&mut vec![], certs_path); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("certificate or key not found")); + Ok(()) + } + + #[test] + fn test_add_tls_certs_volume_missing_both_returns_error() -> eyre::Result<()> { + let dir = tempfile::tempdir()?; + let result = add_tls_certs_volume(&mut vec![], dir.path()); + assert!(result.is_err()); + Ok(()) + } + + #[test] + fn test_add_tls_certs_volume_creates_missing_directory() -> eyre::Result<()> { + let dir = tempfile::tempdir()?; + let certs_path = dir.path().join("new_certs_dir"); + assert!(!certs_path.exists(), "pre-condition: directory must not exist yet"); + + let result = add_tls_certs_volume(&mut vec![], &certs_path); + + // Directory created even though cert/key are absent + assert!(certs_path.exists(), "directory should have been created"); + // cert/key still missing → error + assert!(result.is_err()); + Ok(()) + } + + // ------------------------------------------------------------------------- + // create_pbs_service – TLS cert volume/env + // ------------------------------------------------------------------------- + + #[test] + fn test_create_pbs_service_with_tls_adds_cert_env_and_volume() -> eyre::Result<()> { + let mut sc = service_config_with_tls(PathBuf::from("/my/certs")); + let service = create_pbs_service(&mut sc)?; + + assert!(has_env_key(&service, SIGNER_TLS_CERTIFICATES_PATH_ENV)); + assert!(has_volume(&service, SIGNER_TLS_CERTIFICATE_NAME)); + Ok(()) + } + + #[test] + fn test_create_pbs_service_without_tls_no_cert_env() -> eyre::Result<()> { + let mut sc = minimal_service_config(); + let service = create_pbs_service(&mut sc)?; + + assert!(!has_env_key(&service, SIGNER_TLS_CERTIFICATES_PATH_ENV)); + assert!(!has_volume(&service, SIGNER_TLS_CERTIFICATE_NAME)); + Ok(()) + } + + // ------------------------------------------------------------------------- + // create_signer_service_local – TLS cert volumes + // ------------------------------------------------------------------------- + + #[test] + fn test_create_signer_service_local_with_tls_adds_cert_and_key_volumes() -> eyre::Result<()> { + let dir = tempfile::tempdir()?; + let certs_path = dir.path().to_path_buf(); + std::fs::write(certs_path.join(SIGNER_TLS_CERTIFICATE_NAME), b"cert")?; + std::fs::write(certs_path.join(SIGNER_TLS_KEY_NAME), b"key")?; + + let mut sc = service_config_with_tls(certs_path); + let signer_config = sc.config_info.cb_config.signer.clone().unwrap(); + let loader = SignerLoader::File { key_path: "/keys/keys.json".into() }; + let service = create_signer_service_local(&mut sc, &signer_config, &loader, &None)?; + + assert!(has_volume(&service, SIGNER_TLS_CERTIFICATE_NAME)); + assert!(has_volume(&service, SIGNER_TLS_KEY_NAME)); + Ok(()) + } + + #[test] + fn test_create_signer_service_local_without_tls_no_cert_key_volumes() -> eyre::Result<()> { + let mut sc = minimal_service_config(); + let signer_config = local_signer_config(); + let loader = SignerLoader::File { key_path: "/keys/keys.json".into() }; + let service = create_signer_service_local(&mut sc, &signer_config, &loader, &None)?; + + // SIGNER_TLS_CERTIFICATES_PATH_ENV is always emitted by the signer service, + // but no cert.pem / key.pem volume bindings should exist in insecure mode. + assert!(!has_volume(&service, SIGNER_TLS_CERTIFICATE_NAME)); + assert!(!has_volume(&service, SIGNER_TLS_KEY_NAME)); + Ok(()) + } + + // ------------------------------------------------------------------------- + // create_signer_service_dirk – TLS cert volumes + // ------------------------------------------------------------------------- + + #[test] + fn test_create_signer_service_dirk_with_tls_adds_cert_and_key_volumes() -> eyre::Result<()> { + let dir = tempfile::tempdir()?; + let certs_path = dir.path().to_path_buf(); + std::fs::write(certs_path.join(SIGNER_TLS_CERTIFICATE_NAME), b"cert")?; + std::fs::write(certs_path.join(SIGNER_TLS_KEY_NAME), b"key")?; + + let mut sc = service_config_with_tls(certs_path); + let signer_config = dirk_signer_config(); + let service = create_signer_service_dirk( + &mut sc, + &signer_config, + Path::new("/certs/client.crt"), + Path::new("/certs/client.key"), + Path::new("/dirk_secrets"), + &None, + &None, + )?; + + assert!(has_volume(&service, SIGNER_TLS_CERTIFICATE_NAME)); + assert!(has_volume(&service, SIGNER_TLS_KEY_NAME)); + Ok(()) + } + + #[test] + fn test_create_signer_service_dirk_without_tls_no_cert_key_volumes() -> eyre::Result<()> { + let mut sc = minimal_service_config(); + let signer_config = dirk_signer_config(); + let service = create_signer_service_dirk( + &mut sc, + &signer_config, + Path::new("/certs/client.crt"), + Path::new("/certs/client.key"), + Path::new("/dirk_secrets"), + &None, + &None, + )?; + + assert!(!has_volume(&service, SIGNER_TLS_CERTIFICATE_NAME)); + assert!(!has_volume(&service, SIGNER_TLS_KEY_NAME)); + Ok(()) + } + + #[test] + fn test_create_signer_service_dirk_healthcheck_uses_https_with_tls() -> eyre::Result<()> { + let dir = tempfile::tempdir()?; + let certs_path = dir.path().to_path_buf(); + std::fs::write(certs_path.join(SIGNER_TLS_CERTIFICATE_NAME), b"cert")?; + std::fs::write(certs_path.join(SIGNER_TLS_KEY_NAME), b"key")?; + + let mut sc = service_config_with_tls(certs_path); + let signer_config = dirk_signer_config(); + let service = create_signer_service_dirk( + &mut sc, + &signer_config, + Path::new("/certs/client.crt"), + Path::new("/certs/client.key"), + Path::new("/dirk_secrets"), + &None, + &None, + )?; + + let cmd = get_healthcheck_cmd(&service).expect("healthcheck must be set"); + assert!(cmd.contains("https://"), "healthcheck must use https with TLS: {cmd}"); + assert!(cmd.contains("-k"), "healthcheck must use -k flag for self-signed certs: {cmd}"); + Ok(()) + } + + #[test] + fn test_create_signer_service_dirk_healthcheck_uses_http_without_tls() -> eyre::Result<()> { + let mut sc = minimal_service_config(); + let signer_config = dirk_signer_config(); + let service = create_signer_service_dirk( + &mut sc, + &signer_config, + Path::new("/certs/client.crt"), + Path::new("/certs/client.key"), + Path::new("/dirk_secrets"), + &None, + &None, + )?; + + let cmd = get_healthcheck_cmd(&service).expect("healthcheck must be set"); + assert!(cmd.contains("http://"), "healthcheck must use http without TLS: {cmd}"); + Ok(()) + } + + // ------------------------------------------------------------------------- + // create_module_service – TLS cert env/volume + // ------------------------------------------------------------------------- + + #[test] + fn test_create_module_service_with_signer_tls_adds_cert_env_and_volume() -> eyre::Result<()> { + let module = commit_module(); + let mut sc = service_config_with_tls(PathBuf::from("/my/certs")); + let (_, service) = create_module_service(&module, "https://cb_signer:20000", &mut sc)?; + + assert!(has_env_key(&service, SIGNER_TLS_CERTIFICATES_PATH_ENV)); + assert!(has_volume(&service, SIGNER_TLS_CERTIFICATE_NAME)); + Ok(()) + } + + #[test] + fn test_create_module_service_without_signer_tls_no_cert_env() -> eyre::Result<()> { + let module = commit_module(); + let mut sc = minimal_service_config(); + let (_, service) = create_module_service(&module, "http://cb_signer:20000", &mut sc)?; + + assert!(!has_env_key(&service, SIGNER_TLS_CERTIFICATES_PATH_ENV)); + assert!(!has_volume(&service, SIGNER_TLS_CERTIFICATE_NAME)); + Ok(()) + } } diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 55edb082..9c335cb6 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -17,6 +17,7 @@ base64.workspace = true bimap.workspace = true bytes.workspace = true cipher.workspace = true +const_format.workspace = true ctr.workspace = true derive_more.workspace = true docker-image.workspace = true @@ -52,3 +53,7 @@ tree_hash.workspace = true tree_hash_derive.workspace = true unicode-normalization.workspace = true url.workspace = true +reqwest-eventsource.workspace = true + +[dev-dependencies] + tempfile.workspace = true \ No newline at end of file diff --git a/crates/common/src/commit/client.rs b/crates/common/src/commit/client.rs index 4e8e0961..98d8c26d 100644 --- a/crates/common/src/commit/client.rs +++ b/crates/common/src/commit/client.rs @@ -1,24 +1,29 @@ -use std::time::{Duration, Instant}; +use std::path::PathBuf; use alloy::primitives::Address; use eyre::WrapErr; -use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue}; -use serde::Deserialize; +use reqwest::Certificate; +use serde::{Deserialize, Serialize}; use url::Url; use super::{ - constants::{GENERATE_PROXY_KEY_PATH, GET_PUBKEYS_PATH, REQUEST_SIGNATURE_PATH}, + constants::{GENERATE_PROXY_KEY_PATH, GET_PUBKEYS_PATH}, error::SignerClientError, request::{ EncryptionScheme, GenerateProxyRequest, GetPubkeysResponse, ProxyId, SignConsensusRequest, - SignProxyRequest, SignRequest, SignedProxyDelegation, + SignProxyRequest, SignedProxyDelegation, }, }; use crate::{ DEFAULT_REQUEST_TIMEOUT, - constants::SIGNER_JWT_EXPIRATION, - signer::EcdsaSignature, - types::{BlsPublicKey, BlsSignature, Jwt, ModuleId}, + commit::{ + constants::{ + REQUEST_SIGNATURE_BLS_PATH, REQUEST_SIGNATURE_PROXY_BLS_PATH, + REQUEST_SIGNATURE_PROXY_ECDSA_PATH, + }, + response::{BlsSignResponse, EcdsaSignResponse}, + }, + types::{BlsPublicKey, Jwt, ModuleId}, utils::create_jwt, }; @@ -28,65 +33,51 @@ pub struct SignerClient { /// Url endpoint of the Signer Module url: Url, client: reqwest::Client, - last_jwt_refresh: Instant, module_id: ModuleId, jwt_secret: Jwt, } impl SignerClient { /// Create a new SignerClient - pub fn new(signer_server_url: Url, jwt_secret: Jwt, module_id: ModuleId) -> eyre::Result { - let jwt = create_jwt(&module_id, &jwt_secret)?; - - let mut auth_value = - HeaderValue::from_str(&format!("Bearer {jwt}")).wrap_err("invalid jwt")?; - auth_value.set_sensitive(true); - - let mut headers = HeaderMap::new(); - headers.insert(AUTHORIZATION, auth_value); - - let client = reqwest::Client::builder() - .timeout(DEFAULT_REQUEST_TIMEOUT) - .default_headers(headers) - .build()?; - - Ok(Self { - url: signer_server_url, - client, - last_jwt_refresh: Instant::now(), - module_id, - jwt_secret, - }) - } - - fn refresh_jwt(&mut self) -> Result<(), SignerClientError> { - if self.last_jwt_refresh.elapsed() > Duration::from_secs(SIGNER_JWT_EXPIRATION) { - let jwt = create_jwt(&self.module_id, &self.jwt_secret)?; - - let mut auth_value = - HeaderValue::from_str(&format!("Bearer {jwt}")).wrap_err("invalid jwt")?; - auth_value.set_sensitive(true); - - let mut headers = HeaderMap::new(); - headers.insert(AUTHORIZATION, auth_value); - - self.client = reqwest::Client::builder() - .timeout(DEFAULT_REQUEST_TIMEOUT) - .default_headers(headers) - .build()?; + pub fn new( + signer_server_url: Url, + cert_path: Option, + jwt_secret: Jwt, + module_id: ModuleId, + ) -> eyre::Result { + let mut builder = reqwest::Client::builder().timeout(DEFAULT_REQUEST_TIMEOUT); + + // If a certificate path is provided, use it + if let Some(cert_path) = cert_path { + builder = builder + .use_rustls_tls() + .add_root_certificate(Certificate::from_pem(&std::fs::read(cert_path)?)?); } - Ok(()) + Ok(Self { url: signer_server_url, client: builder.build()?, module_id, jwt_secret }) + } + + fn create_jwt_for_payload( + &mut self, + route: &str, + payload: &T, + ) -> Result { + let payload_vec = serde_json::to_vec(payload)?; + create_jwt(&self.module_id, &self.jwt_secret, route, Some(&payload_vec)) + .wrap_err("failed to create JWT for payload") + .map_err(SignerClientError::JWTError) } /// Request a list of validator pubkeys for which signatures can be /// requested. // TODO: add more docs on how proxy keys work pub async fn get_pubkeys(&mut self) -> Result { - self.refresh_jwt()?; + let jwt = create_jwt(&self.module_id, &self.jwt_secret, GET_PUBKEYS_PATH, None) + .wrap_err("failed to create JWT for payload") + .map_err(SignerClientError::JWTError)?; let url = self.url.join(GET_PUBKEYS_PATH)?; - let res = self.client.get(url).send().await?; + let res = self.client.get(url).bearer_auth(jwt).send().await?; if !res.status().is_success() { return Err(SignerClientError::FailedRequest { @@ -99,14 +90,19 @@ impl SignerClient { } /// Send a signature request - async fn request_signature(&mut self, request: &SignRequest) -> Result + async fn request_signature( + &mut self, + route: &str, + request: &Q, + ) -> Result where + Q: Serialize, T: for<'de> Deserialize<'de>, { - self.refresh_jwt()?; + let jwt = self.create_jwt_for_payload(route, request)?; - let url = self.url.join(REQUEST_SIGNATURE_PATH)?; - let res = self.client.post(url).json(&request).send().await?; + let url = self.url.join(route)?; + let res = self.client.post(url).json(&request).bearer_auth(jwt).send().await?; let status = res.status(); let response_bytes = res.bytes().await?; @@ -126,22 +122,22 @@ impl SignerClient { pub async fn request_consensus_signature( &mut self, request: SignConsensusRequest, - ) -> Result { - self.request_signature(&request.into()).await + ) -> Result { + self.request_signature(REQUEST_SIGNATURE_BLS_PATH, &request).await } pub async fn request_proxy_signature_ecdsa( &mut self, request: SignProxyRequest
, - ) -> Result { - self.request_signature(&request.into()).await + ) -> Result { + self.request_signature(REQUEST_SIGNATURE_PROXY_ECDSA_PATH, &request).await } pub async fn request_proxy_signature_bls( &mut self, request: SignProxyRequest, - ) -> Result { - self.request_signature(&request.into()).await + ) -> Result { + self.request_signature(REQUEST_SIGNATURE_PROXY_BLS_PATH, &request).await } async fn generate_proxy_key( @@ -151,10 +147,10 @@ impl SignerClient { where T: ProxyId + for<'de> Deserialize<'de>, { - self.refresh_jwt()?; + let jwt = self.create_jwt_for_payload(GENERATE_PROXY_KEY_PATH, request)?; let url = self.url.join(GENERATE_PROXY_KEY_PATH)?; - let res = self.client.post(url).json(&request).send().await?; + let res = self.client.post(url).json(&request).bearer_auth(jwt).send().await?; let status = res.status(); let response_bytes = res.bytes().await?; diff --git a/crates/common/src/commit/constants.rs b/crates/common/src/commit/constants.rs index 7c9f948c..f2d5e94c 100644 --- a/crates/common/src/commit/constants.rs +++ b/crates/common/src/commit/constants.rs @@ -1,5 +1,13 @@ +use const_format::concatcp; + pub const GET_PUBKEYS_PATH: &str = "/signer/v1/get_pubkeys"; -pub const REQUEST_SIGNATURE_PATH: &str = "/signer/v1/request_signature"; +pub const REQUEST_SIGNATURE_BASE_PATH: &str = "/signer/v1/request_signature"; +pub const REQUEST_SIGNATURE_BLS_PATH: &str = concatcp!(REQUEST_SIGNATURE_BASE_PATH, "/bls"); +pub const REQUEST_SIGNATURE_PROXY_BLS_PATH: &str = + concatcp!(REQUEST_SIGNATURE_BASE_PATH, "/proxy-bls"); +pub const REQUEST_SIGNATURE_PROXY_ECDSA_PATH: &str = + concatcp!(REQUEST_SIGNATURE_BASE_PATH, "/proxy-ecdsa"); pub const GENERATE_PROXY_KEY_PATH: &str = "/signer/v1/generate_proxy_key"; pub const STATUS_PATH: &str = "/status"; pub const RELOAD_PATH: &str = "/reload"; +pub const REVOKE_MODULE_PATH: &str = "/revoke_jwt"; diff --git a/crates/common/src/commit/mod.rs b/crates/common/src/commit/mod.rs index 205785ff..193db630 100644 --- a/crates/common/src/commit/mod.rs +++ b/crates/common/src/commit/mod.rs @@ -2,3 +2,4 @@ pub mod client; pub mod constants; pub mod error; pub mod request; +pub mod response; diff --git a/crates/common/src/commit/request.rs b/crates/common/src/commit/request.rs index afa01807..cd780446 100644 --- a/crates/common/src/commit/request.rs +++ b/crates/common/src/commit/request.rs @@ -1,21 +1,22 @@ use std::{ + collections::HashMap, fmt::{self, Debug, Display}, str::FromStr, }; use alloy::{ hex, - primitives::{Address, B256}, + primitives::{Address, B256, aliases::B32}, }; -use derive_more::derive::From; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use tree_hash::TreeHash; use tree_hash_derive::TreeHash; use crate::{ + config::decode_string_to_map, constants::COMMIT_BOOST_DOMAIN, signature::verify_signed_message, - types::{BlsPublicKey, BlsSignature, Chain}, + types::{BlsPublicKey, BlsSignature, Chain, ModuleId}, }; pub trait ProxyId: Debug + Clone + TreeHash + Display { @@ -67,7 +68,8 @@ impl SignedProxyDelegation { &self.message.delegator, &self.message, &self.signature, - COMMIT_BOOST_DOMAIN, + None, + &B32::from(COMMIT_BOOST_DOMAIN), ) } } @@ -78,53 +80,24 @@ impl fmt::Display for SignedProxyDelegation { } } -// TODO(David): This struct shouldn't be visible to module authors -#[derive(Debug, Clone, Serialize, Deserialize, From)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum SignRequest { - Consensus(SignConsensusRequest), - ProxyBls(SignProxyRequest), - ProxyEcdsa(SignProxyRequest
), -} - -impl Display for SignRequest { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - SignRequest::Consensus(req) => write!( - f, - "Consensus(pubkey: {}, object_root: {})", - req.pubkey, - hex::encode_prefixed(req.object_root) - ), - SignRequest::ProxyBls(req) => write!( - f, - "BLS(proxy: {}, object_root: {})", - req.proxy, - hex::encode_prefixed(req.object_root) - ), - SignRequest::ProxyEcdsa(req) => write!( - f, - "ECDSA(proxy: {}, object_root: {})", - req.proxy, - hex::encode_prefixed(req.object_root) - ), - } - } -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SignConsensusRequest { pub pubkey: BlsPublicKey, pub object_root: B256, + /// Replay-protection nonce mixed into the signing root via + /// `PropCommitSigningInfo`. Modules that do not track nonces should + /// send `0`. Modules that do track nonces should use a monotonically + /// increasing value per key to prevent signature reuse. + pub nonce: u64, } impl SignConsensusRequest { - pub fn new(pubkey: BlsPublicKey, object_root: B256) -> Self { - Self { pubkey, object_root } + pub fn new(pubkey: BlsPublicKey, object_root: B256, nonce: u64) -> Self { + Self { pubkey, object_root, nonce } } pub fn builder(pubkey: BlsPublicKey) -> Self { - Self::new(pubkey, B256::ZERO) + Self::new(pubkey, B256::ZERO, 0) } pub fn with_root>(self, object_root: R) -> Self { @@ -134,21 +107,42 @@ impl SignConsensusRequest { pub fn with_msg(self, msg: &impl TreeHash) -> Self { self.with_root(msg.tree_hash_root().0) } + + pub fn with_nonce(self, nonce: u64) -> Self { + Self { nonce, ..self } + } +} + +impl Display for SignConsensusRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "Consensus(pubkey: {}, object_root: {}, nonce: {})", + self.pubkey, + hex::encode_prefixed(self.object_root), + self.nonce + ) + } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SignProxyRequest { pub proxy: T, pub object_root: B256, + /// Replay-protection nonce mixed into the signing root via + /// `PropCommitSigningInfo`. Modules that do not track nonces should + /// send `0`. Modules that do track nonces should use a monotonically + /// increasing value per key to prevent signature reuse. + pub nonce: u64, } impl SignProxyRequest { - pub fn new(proxy: T, object_root: B256) -> Self { - Self { proxy, object_root } + pub fn new(proxy: T, object_root: B256, nonce: u64) -> Self { + Self { proxy, object_root, nonce } } pub fn builder(proxy: T) -> Self { - Self::new(proxy, B256::ZERO) + Self::new(proxy, B256::ZERO, 0) } pub fn with_root>(self, object_root: R) -> Self { @@ -158,6 +152,34 @@ impl SignProxyRequest { pub fn with_msg(self, msg: &impl TreeHash) -> Self { self.with_root(msg.tree_hash_root().0) } + + pub fn with_nonce(self, nonce: u64) -> Self { + Self { nonce, ..self } + } +} + +impl Display for SignProxyRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "BLS(proxy: {}, object_root: {}, nonce: {})", + self.proxy, + hex::encode_prefixed(self.object_root), + self.nonce + ) + } +} + +impl Display for SignProxyRequest
{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "ECDSA(proxy: {}, object_root: {}, nonce: {})", + self.proxy, + hex::encode_prefixed(self.object_root), + self.nonce + ) + } } #[derive(Debug, Clone, Copy, Serialize, Deserialize)] @@ -208,6 +230,31 @@ pub struct GetPubkeysResponse { pub keys: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReloadRequest { + #[serde(default, deserialize_with = "deserialize_jwt_secrets")] + pub jwt_secrets: Option>, + pub admin_secret: Option, +} + +pub fn deserialize_jwt_secrets<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + let raw: String = Deserialize::deserialize(deserializer)?; + + decode_string_to_map(&raw) + .map(Some) + .map_err(|_| serde::de::Error::custom("Invalid format".to_string())) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RevokeModuleRequest { + pub module_id: ModuleId, +} + /// Map of consensus pubkeys to proxies #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ConsensusProxyMap { @@ -228,36 +275,6 @@ mod tests { use super::*; use crate::signer::EcdsaSignature; - #[test] - fn test_decode_request_signature() { - let data = r#"{ - "type": "consensus", - "pubkey": "0xa3366b54f28e4bf1461926a3c70cdb0ec432b5c92554ecaae3742d33fb33873990cbed1761c68020e6d3c14d30a22050", - "object_root": "0x5c89913beafa0472168e0ec05e349b4ceb9985d25ab9fa8de53a60208c85b3a5" - }"#; - - let request: SignRequest = serde_json::from_str(data).unwrap(); - assert!(matches!(request, SignRequest::Consensus(..))); - - let data = r#"{ - "type": "proxy_bls", - "proxy": "0xa3366b54f28e4bf1461926a3c70cdb0ec432b5c92554ecaae3742d33fb33873990cbed1761c68020e6d3c14d30a22050", - "object_root": "0x5c89913beafa0472168e0ec05e349b4ceb9985d25ab9fa8de53a60208c85b3a5" - }"#; - - let request: SignRequest = serde_json::from_str(data).unwrap(); - assert!(matches!(request, SignRequest::ProxyBls(..))); - - let data = r#"{ - "type": "proxy_ecdsa", - "proxy": "0x4ca9939a8311a7cab3dde201b70157285fa81a9d", - "object_root": "0x5c89913beafa0472168e0ec05e349b4ceb9985d25ab9fa8de53a60208c85b3a5" - }"#; - - let request: SignRequest = serde_json::from_str(data).unwrap(); - assert!(matches!(request, SignRequest::ProxyEcdsa(..))); - } - #[test] fn test_decode_response_signature() { let data = r#""0xc00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000""#; @@ -298,7 +315,7 @@ mod tests { let _: SignedProxyDelegationBls = serde_json::from_str(data).unwrap(); - let data = r#"{ + let data = r#"{ "message": { "delegator": "0xa3366b54f28e4bf1461926a3c70cdb0ec432b5c92554ecaae3742d33fb33873990cbed1761c68020e6d3c14d30a22050", "proxy": "0x4ca9939a8311a7cab3dde201b70157285fa81a9d" @@ -309,6 +326,29 @@ mod tests { let _: SignedProxyDelegationEcdsa = serde_json::from_str(data).unwrap(); } + #[test] + fn test_reload_request_jwt_secrets_present() { + let data = r#"{"jwt_secrets": "module_a=secret1,module_b=secret2"}"#; + let req: ReloadRequest = serde_json::from_str(data).unwrap(); + let secrets = req.jwt_secrets.expect("should have secrets"); + assert_eq!(secrets.get(&ModuleId("module_a".into())), Some(&"secret1".to_string())); + assert_eq!(secrets.get(&ModuleId("module_b".into())), Some(&"secret2".to_string())); + } + + #[test] + fn test_reload_request_jwt_secrets_absent() { + let data = r#"{}"#; + let req: ReloadRequest = serde_json::from_str(data).unwrap(); + assert!(req.jwt_secrets.is_none()); + } + + #[test] + fn test_reload_request_jwt_secrets_invalid_format() { + // Missing '=' separator — decode_string_to_map should fail + let data = r#"{"jwt_secrets": "bad_value_no_equals"}"#; + assert!(serde_json::from_str::(data).is_err()); + } + #[test] fn test_decode_response_proxy_map() { let data = r#"{ diff --git a/crates/common/src/commit/response.rs b/crates/common/src/commit/response.rs new file mode 100644 index 00000000..0e984144 --- /dev/null +++ b/crates/common/src/commit/response.rs @@ -0,0 +1,53 @@ +use alloy::primitives::{Address, B256, U256}; +use serde::{Deserialize, Serialize}; + +use crate::{ + signer::EcdsaSignature, + types::{BlsPublicKey, BlsSignature}, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct BlsSignResponse { + pub pubkey: BlsPublicKey, + pub object_root: B256, + pub module_signing_id: B256, + pub nonce: u64, + pub chain_id: U256, + pub signature: BlsSignature, +} + +impl BlsSignResponse { + pub fn new( + pubkey: BlsPublicKey, + object_root: B256, + module_signing_id: B256, + nonce: u64, + chain_id: U256, + signature: BlsSignature, + ) -> Self { + Self { pubkey, object_root, module_signing_id, nonce, chain_id, signature } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct EcdsaSignResponse { + pub address: Address, + pub object_root: B256, + pub module_signing_id: B256, + pub nonce: u64, + pub chain_id: U256, + pub signature: EcdsaSignature, +} + +impl EcdsaSignResponse { + pub fn new( + address: Address, + object_root: B256, + module_signing_id: B256, + nonce: u64, + chain_id: U256, + signature: EcdsaSignature, + ) -> Self { + Self { address, object_root, module_signing_id, nonce, chain_id, signature } + } +} diff --git a/crates/common/src/config/constants.rs b/crates/common/src/config/constants.rs index a2942f3a..fb5f3b08 100644 --- a/crates/common/src/config/constants.rs +++ b/crates/common/src/config/constants.rs @@ -44,7 +44,14 @@ pub const SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_DEFAULT: u32 = 5 * 60; /// Comma separated list module_id=jwt_secret pub const JWTS_ENV: &str = "CB_JWTS"; - +pub const ADMIN_JWT_ENV: &str = "CB_SIGNER_ADMIN_JWT"; + +/// Path to the certificates folder where the cert.pem and key.pem files are +/// stored/generated +pub const SIGNER_TLS_CERTIFICATES_PATH_ENV: &str = "CB_SIGNER_TLS_CERTIFICATES"; +pub const SIGNER_TLS_CERTIFICATES_PATH_DEFAULT: &str = "/certs"; +pub const SIGNER_TLS_CERTIFICATE_NAME: &str = "cert.pem"; +pub const SIGNER_TLS_KEY_NAME: &str = "key.pem"; /// Path to json file with plaintext keys (testing only) pub const SIGNER_KEYS_ENV: &str = "CB_SIGNER_LOADER_FILE"; pub const SIGNER_DEFAULT: &str = "/keys.json"; diff --git a/crates/common/src/config/mod.rs b/crates/common/src/config/mod.rs index 340bb888..e0958342 100644 --- a/crates/common/src/config/mod.rs +++ b/crates/common/src/config/mod.rs @@ -128,6 +128,40 @@ impl CommitBoostConfig { Err(_) => None, } } + + /// Helper to return if the signer module is needed based on the config + pub fn needs_signer_module(&self) -> bool { + self.pbs.with_signer || + self.modules.as_ref().is_some_and(|modules| { + modules.iter().any(|module| matches!(module.kind, ModuleKind::Commit)) + }) + } + + pub fn signer_uses_tls(&self) -> bool { + self.signer + .as_ref() + .is_some_and(|signer_config| matches!(signer_config.tls_mode, TlsMode::Certificate(_))) + } + + pub fn signer_server_url(&self, default_port: u16) -> String { + if let Some(SignerConfig { inner: SignerType::Remote { url }, .. }) = &self.signer { + url.to_string() + } else { + let signer_http_prefix = if self.signer_uses_tls() { "https" } else { "http" }; + let port = self.signer.as_ref().map(|s| s.port).unwrap_or(default_port); + format!("{signer_http_prefix}://cb_signer:{port}") + } + } + + pub fn signer_certs_path(&self) -> Option<&PathBuf> { + self.signer + .as_ref() + .map(|config| match &config.tls_mode { + TlsMode::Insecure => None, + TlsMode::Certificate(path) => Some(path), + }) + .unwrap_or_default() + } } /// Helper struct to load the chain spec file diff --git a/crates/common/src/config/module.rs b/crates/common/src/config/module.rs index 332560eb..aec45289 100644 --- a/crates/common/src/config/module.rs +++ b/crates/common/src/config/module.rs @@ -1,5 +1,6 @@ -use std::collections::HashMap; +use std::{collections::HashMap, path::PathBuf}; +use alloy::primitives::B256; use eyre::{ContextCompat, Result}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use toml::Table; @@ -7,6 +8,7 @@ use toml::Table; use crate::{ commit::client::SignerClient, config::{ + SIGNER_TLS_CERTIFICATE_NAME, SIGNER_TLS_CERTIFICATES_PATH_ENV, SignerConfig, TlsMode, constants::{CONFIG_ENV, MODULE_ID_ENV, MODULE_JWT_ENV, SIGNER_URL_ENV}, load_env_var, utils::load_file_from_env, @@ -34,6 +36,8 @@ pub struct StaticModuleConfig { /// Type of the module #[serde(rename = "type")] pub kind: ModuleKind, + /// Signing ID for the module to use when requesting signatures + pub signing_id: B256, } /// Runtime config to start a module @@ -79,6 +83,7 @@ pub fn load_commit_module_config() -> Result { chain: Chain, modules: Vec>, + signer: Option, } // load module config including the extra data (if any) @@ -101,7 +106,16 @@ pub fn load_commit_module_config() -> Result None, + TlsMode::Certificate(path) => Some( + load_env_var(SIGNER_TLS_CERTIFICATES_PATH_ENV) + .map(PathBuf::from) + .unwrap_or(path) + .join(SIGNER_TLS_CERTIFICATE_NAME), + ), + }; + let signer_client = SignerClient::new(signer_server_url, certs_path, module_jwt, module_id)?; Ok(StartCommitModuleConfig { id: module_config.static_config.id, diff --git a/crates/common/src/config/pbs.rs b/crates/common/src/config/pbs.rs index 1021815c..907fbecf 100644 --- a/crates/common/src/config/pbs.rs +++ b/crates/common/src/config/pbs.rs @@ -24,7 +24,8 @@ use crate::{ commit::client::SignerClient, config::{ CONFIG_ENV, MODULE_JWT_ENV, MuxKeysLoader, PBS_IMAGE_DEFAULT, PBS_SERVICE_NAME, PbsMuxes, - SIGNER_URL_ENV, load_env_var, load_file_from_env, + SIGNER_TLS_CERTIFICATE_NAME, SIGNER_TLS_CERTIFICATES_PATH_ENV, SIGNER_URL_ENV, + SignerConfig, TlsMode, load_env_var, load_file_from_env, }, pbs::{ DEFAULT_PBS_PORT, DEFAULT_REGISTRY_REFRESH_SECONDS, DefaultTimeout, LATE_IN_SLOT_TIME_MS, @@ -182,18 +183,16 @@ impl PbsConfig { } if let Some(rpc_url) = &self.rpc_url { - // TODO: remove this once we support chain ids for custom chains - if !matches!(chain, Chain::Custom { .. }) { - let provider = ProviderBuilder::new().connect_http(rpc_url.clone()); - let chain_id = provider.get_chain_id().await?; - ensure!( - chain_id == chain.id(), - "Rpc url is for the wrong chain, expected: {} ({:?}) got {}", - chain.id(), - chain, - chain_id - ); - } + let provider = ProviderBuilder::new().connect_http(rpc_url.clone()); + let chain_id = provider.get_chain_id().await?; + let chain_id_big = U256::from(chain_id); + ensure!( + chain_id_big == chain.id(), + "Rpc url is for the wrong chain, expected: {} ({:?}) got {}", + chain.id(), + chain, + chain_id_big + ); } ensure!( @@ -348,6 +347,7 @@ pub async fn load_pbs_custom_config() -> Result<(PbsModuleC chain: Chain, relays: Vec, pbs: CustomPbsConfig, + signer: Option, muxes: Option, } @@ -404,8 +404,22 @@ pub async fn load_pbs_custom_config() -> Result<(PbsModuleC // if custom pbs requires a signer client, load jwt let module_jwt = Jwt(load_env_var(MODULE_JWT_ENV)?); let signer_server_url = load_env_var(SIGNER_URL_ENV)?.parse()?; + let certs_path = match cb_config + .signer + .ok_or_else(|| eyre::eyre!("with_signer = true but no [signer] section in config"))? + .tls_mode + { + TlsMode::Insecure => None, + TlsMode::Certificate(path) => Some( + load_env_var(SIGNER_TLS_CERTIFICATES_PATH_ENV) + .map(PathBuf::from) + .unwrap_or(path) + .join(SIGNER_TLS_CERTIFICATE_NAME), + ), + }; Some(SignerClient::new( signer_server_url, + certs_path, module_jwt, ModuleId(PBS_SERVICE_NAME.to_string()), )?) diff --git a/crates/common/src/config/signer.rs b/crates/common/src/config/signer.rs index 2aa555e6..95110958 100644 --- a/crates/common/src/config/signer.rs +++ b/crates/common/src/config/signer.rs @@ -1,11 +1,14 @@ use std::{ collections::HashMap, + fmt::Display, net::{Ipv4Addr, SocketAddr}, + num::NonZeroUsize, path::PathBuf, }; +use alloy::primitives::B256; use docker_image::DockerImage; -use eyre::{OptionExt, Result, bail, ensure}; +use eyre::{Context, OptionExt, Result, bail, ensure}; use serde::{Deserialize, Serialize}; use tonic::transport::{Certificate, Identity}; use url::Url; @@ -13,8 +16,9 @@ use url::Url; use super::{ CommitBoostConfig, SIGNER_ENDPOINT_ENV, SIGNER_JWT_AUTH_FAIL_LIMIT_DEFAULT, SIGNER_JWT_AUTH_FAIL_LIMIT_ENV, SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_DEFAULT, - SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_ENV, SIGNER_PORT_DEFAULT, load_jwt_secrets, - load_optional_env_var, utils::load_env_var, + SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_ENV, SIGNER_PORT_DEFAULT, SIGNER_TLS_CERTIFICATE_NAME, + SIGNER_TLS_CERTIFICATES_PATH_ENV, SIGNER_TLS_KEY_NAME, load_jwt_secrets, load_optional_env_var, + utils::load_env_var, }; use crate::{ config::{ @@ -25,6 +29,83 @@ use crate::{ utils::{default_host, default_u16, default_u32}, }; +/// The signing configuration for a commitment module. +#[derive(Clone, Debug, PartialEq)] +pub struct ModuleSigningConfig { + /// Human-readable name of the module. + pub module_name: ModuleId, + + /// The JWT secret for the module to communicate with the signer module. + pub jwt_secret: String, + + /// A unique identifier for the module, which is used when signing requests + /// to generate signatures for this module. Must be a 32-byte hex string. + /// A leading 0x prefix is optional. + pub signing_id: B256, +} + +impl ModuleSigningConfig { + pub fn validate(&self) -> Result<()> { + if self.jwt_secret.is_empty() { + bail!("JWT secret cannot be empty"); + } + + if self.signing_id.is_zero() { + bail!("Signing ID cannot be zero"); + } + + Ok(()) + } +} + +/// Mode to use for TLS support when starting the signer service +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(tag = "type", content = "path", rename_all = "snake_case")] +pub enum TlsMode { + /// Don't use TLS (regular HTTP) + Insecure, + + /// Use TLS with a certificate and key file in the provided directory + Certificate(PathBuf), +} + +/// Reverse proxy setup, used to extract real client's IP +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[serde(rename_all = "snake_case", tag = "type")] +pub enum ReverseProxyHeaderSetup { + #[default] + None, + Unique { + header: String, + }, + Rightmost { + header: String, + trusted_count: NonZeroUsize, + }, +} + +impl Display for ReverseProxyHeaderSetup { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ReverseProxyHeaderSetup::None => write!(f, "None"), + ReverseProxyHeaderSetup::Unique { header } => { + write!(f, "\"{header} (unique)\"") + } + ReverseProxyHeaderSetup::Rightmost { header, trusted_count } => { + let n = trusted_count.get(); + let suffix = match (n % 100, n % 10) { + (11..=13, _) => "th", + (_, 1) => "st", + (_, 2) => "nd", + (_, 3) => "rd", + _ => "th", + }; + write!(f, "\"{header} ({trusted_count}{suffix} from the right)\"") + } + } + } +} + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "snake_case")] pub struct SignerConfig { @@ -45,10 +126,21 @@ pub struct SignerConfig { pub jwt_auth_fail_limit: u32, /// Duration in seconds to rate limit an endpoint after the JWT auth failure - /// limit has been reached + /// limit has been reached. This also defines the interval at which failed + /// attempts are regularly checked and expired ones are cleaned up. #[serde(default = "default_u32::")] pub jwt_auth_fail_timeout_seconds: u32, + /// Mode to use for TLS support. + /// If using Certificate mode, this must include a path to the TLS + /// certificates directory (with a `cert.pem` and a `key.pem` file). + #[serde(default = "default_tls_mode")] + pub tls_mode: TlsMode, + + /// Reverse proxy setup to extract real client's IP + #[serde(default)] + pub reverse_proxy: ReverseProxyHeaderSetup, + /// Inner type-specific configuration #[serde(flatten)] pub inner: SignerType, @@ -75,6 +167,11 @@ fn default_signer_image() -> String { SIGNER_IMAGE_DEFAULT.to_string() } +fn default_tls_mode() -> TlsMode { + TlsMode::Insecure // To make the default use TLS, do + // TlsMode::Certificate(PathBuf::from("./certs")) +} + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "snake_case")] pub struct DirkHostConfig { @@ -137,17 +234,23 @@ pub struct StartSignerConfig { pub loader: Option, pub store: Option, pub endpoint: SocketAddr, - pub jwts: HashMap, + pub mod_signing_configs: HashMap, + pub admin_secret: String, pub jwt_auth_fail_limit: u32, pub jwt_auth_fail_timeout_seconds: u32, pub dirk: Option, + pub tls_certificates: Option<(Vec, Vec)>, + pub reverse_proxy: ReverseProxyHeaderSetup, } impl StartSignerConfig { pub fn load_from_env() -> Result { let (config, _) = CommitBoostConfig::from_env_path()?; - let jwts = load_jwt_secrets()?; + let (admin_secret, jwt_secrets) = load_jwt_secrets()?; + + let mod_signing_configs = load_module_signing_configs(&config, &jwt_secrets) + .wrap_err("Failed to load module signing configs")?; let signer_config = config.signer.ok_or_eyre("Signer config is missing")?; @@ -176,16 +279,35 @@ impl StartSignerConfig { signer_config.jwt_auth_fail_timeout_seconds }; + // Load the TLS certificates if requested, generating self-signed ones if + // necessary + let tls_certificates = match signer_config.tls_mode { + TlsMode::Insecure => None, + TlsMode::Certificate(path) => { + let certs_path = load_env_var(SIGNER_TLS_CERTIFICATES_PATH_ENV) + .map(PathBuf::from) + .unwrap_or(path); + let cert_path = certs_path.join(SIGNER_TLS_CERTIFICATE_NAME); + let key_path = certs_path.join(SIGNER_TLS_KEY_NAME); + Some((std::fs::read(cert_path)?, std::fs::read(key_path)?)) + } + }; + + let reverse_proxy = signer_config.reverse_proxy; + match signer_config.inner { SignerType::Local { loader, store, .. } => Ok(StartSignerConfig { chain: config.chain, loader: Some(loader), endpoint, - jwts, + mod_signing_configs, + admin_secret, jwt_auth_fail_limit, jwt_auth_fail_timeout_seconds, store, dirk: None, + tls_certificates, + reverse_proxy, }), SignerType::Dirk { @@ -211,7 +333,8 @@ impl StartSignerConfig { Ok(StartSignerConfig { chain: config.chain, endpoint, - jwts, + mod_signing_configs, + admin_secret, jwt_auth_fail_limit, jwt_auth_fail_timeout_seconds, loader: None, @@ -231,6 +354,8 @@ impl StartSignerConfig { }, max_response_size_bytes, }), + tls_certificates, + reverse_proxy, }) } @@ -240,3 +365,548 @@ impl StartSignerConfig { } } } + +/// Loads the signing configurations for each module defined in the Commit Boost +/// config, coupling them with their JWT secrets and handling any potential +/// duplicates or missing values. +pub fn load_module_signing_configs( + config: &CommitBoostConfig, + jwt_secrets: &HashMap, +) -> Result> { + let mut mod_signing_configs = HashMap::new(); + let modules = config.modules.as_ref().ok_or_eyre("No modules defined in the config")?; + + let mut seen_jwt_secrets = HashMap::new(); + let mut seen_signing_ids = HashMap::new(); + for module in modules { + ensure!(!module.id.is_empty(), "Module ID cannot be empty"); + + ensure!( + !mod_signing_configs.contains_key(&module.id), + "Duplicate module config detected: ID {} is already used", + module.id + ); + + let jwt_secret = match jwt_secrets.get(&module.id) { + Some(secret) => secret.clone(), + None => bail!("JWT secret for module {} is missing", module.id), + }; + let module_signing_config = ModuleSigningConfig { + module_name: module.id.clone(), + jwt_secret, + signing_id: module.signing_id, + }; + module_signing_config + .validate() + .wrap_err(format!("Invalid signing config for module {}", module.id))?; + + if let Some(existing_module) = + seen_jwt_secrets.insert(module_signing_config.jwt_secret.clone(), &module.id) + { + bail!("Duplicate JWT secret detected for modules {} and {}", existing_module, module.id) + }; + if let Some(existing_module) = + seen_signing_ids.insert(module_signing_config.signing_id, &module.id) + { + bail!("Duplicate signing ID detected for modules {} and {}", existing_module, module.id) + }; + + mod_signing_configs.insert(module.id.clone(), module_signing_config); + } + + Ok(mod_signing_configs) +} + +#[cfg(test)] +mod tests { + use std::num::NonZeroUsize; + + use alloy::primitives::{Uint, b256}; + + use super::*; + use crate::config::{LogsSettings, ModuleKind, PbsConfig, StaticModuleConfig, StaticPbsConfig}; + + // Wrapper needed because TOML requires a top-level struct (can't serialize + // a bare enum). + #[derive(Serialize, Deserialize, Debug)] + struct TlsWrapper { + tls_mode: TlsMode, + } + + fn make_local_signer_config(tls_mode: TlsMode) -> SignerConfig { + SignerConfig { + host: Ipv4Addr::LOCALHOST, + port: 20000, + docker_image: SIGNER_IMAGE_DEFAULT.to_string(), + jwt_auth_fail_limit: 3, + jwt_auth_fail_timeout_seconds: 300, + tls_mode, + reverse_proxy: ReverseProxyHeaderSetup::None, + inner: SignerType::Local { + loader: SignerLoader::File { key_path: PathBuf::from("/keys.json") }, + store: None, + }, + } + } + + async fn get_config_with_signer(tls_mode: TlsMode) -> CommitBoostConfig { + let mut cfg = get_base_config().await; + cfg.signer = Some(make_local_signer_config(tls_mode)); + cfg + } + + async fn get_base_config() -> CommitBoostConfig { + CommitBoostConfig { + chain: Chain::Hoodi, + relays: vec![], + pbs: StaticPbsConfig { + docker_image: String::from("cb-fake-repo/fake-cb:latest"), + pbs_config: PbsConfig { + host: Ipv4Addr::LOCALHOST, + port: 0, + relay_check: false, + wait_all_registrations: false, + timeout_get_header_ms: 0, + timeout_get_payload_ms: 0, + timeout_register_validator_ms: 0, + skip_sigverify: false, + min_bid_wei: Uint::<256, 4>::from(0), + late_in_slot_time_ms: 0, + extra_validation_enabled: false, + rpc_url: None, + http_timeout_seconds: 30, + register_validator_retry_limit: 3, + validator_registration_batch_size: None, + mux_registry_refresh_interval_seconds: 5, + ssv_node_api_url: Url::parse("https://example.net").unwrap(), + ssv_public_api_url: Url::parse("https://example.net").unwrap(), + }, + with_signer: true, + }, + muxes: None, + modules: Some(vec![]), + signer: None, + metrics: None, + logs: LogsSettings::default(), + } + } + + async fn create_module_config(id: ModuleId, signing_id: B256) -> StaticModuleConfig { + StaticModuleConfig { + id: id.clone(), + signing_id, + docker_image: String::from(""), + env: None, + env_file: None, + kind: ModuleKind::Commit, + } + } + + #[tokio::test] + async fn test_good_config() -> Result<()> { + let mut cfg = get_base_config().await; + let first_module_id = ModuleId("test_module".to_string()); + let first_signing_id = + b256!("0101010101010101010101010101010101010101010101010101010101010101"); + let second_module_id = ModuleId("2nd_test_module".to_string()); + let second_signing_id = + b256!("0202020202020202020202020202020202020202020202020202020202020202"); + + cfg.modules = Some(vec![ + create_module_config(first_module_id.clone(), first_signing_id).await, + create_module_config(second_module_id.clone(), second_signing_id).await, + ]); + + let jwts = HashMap::from([ + (first_module_id.clone(), "supersecret".to_string()), + (second_module_id.clone(), "another-secret".to_string()), + ]); + + // Load the mod signing configuration + let mod_signing_configs = load_module_signing_configs(&cfg, &jwts) + .wrap_err("Failed to load module signing configs")?; + assert!(mod_signing_configs.len() == 2, "Expected 2 mod signing configurations"); + + // Check the first module + let module_1 = mod_signing_configs + .get(&first_module_id) + .unwrap_or_else(|| panic!("Missing '{first_module_id}' in mod signing configs")); + assert_eq!(module_1.module_name, first_module_id, "Module name mismatch for 'test_module'"); + assert_eq!( + module_1.jwt_secret, jwts[&first_module_id], + "JWT secret mismatch for '{first_module_id}'" + ); + assert_eq!( + module_1.signing_id, first_signing_id, + "Signing ID mismatch for '{first_module_id}'" + ); + + // Check the second module + let module_2 = mod_signing_configs + .get(&second_module_id) + .unwrap_or_else(|| panic!("Missing '{second_module_id}' in mod signing configs")); + assert_eq!( + module_2.module_name, second_module_id, + "Module name mismatch for '{second_module_id}'" + ); + assert_eq!( + module_2.jwt_secret, jwts[&second_module_id], + "JWT secret mismatch for '{second_module_id}'" + ); + assert_eq!( + module_2.signing_id, second_signing_id, + "Signing ID mismatch for '{second_module_id}'" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_duplicate_module_names() -> Result<()> { + let mut cfg = get_base_config().await; + let first_module_id = ModuleId("test_module".to_string()); + let first_signing_id = + b256!("0101010101010101010101010101010101010101010101010101010101010101"); + let second_module_id = ModuleId("2nd_test_module".to_string()); + let second_signing_id = + b256!("0202020202020202020202020202020202020202020202020202020202020202"); + + cfg.modules = Some(vec![ + create_module_config(first_module_id.clone(), first_signing_id).await, + create_module_config(first_module_id.clone(), second_signing_id).await, /* Duplicate + * module + * name */ + ]); + + let jwts = HashMap::from([ + (first_module_id.clone(), "supersecret".to_string()), + (second_module_id.clone(), "another-secret".to_string()), + ]); + + // Make sure there was an error + let result = load_module_signing_configs(&cfg, &jwts); + assert!(result.is_err(), "Expected error due to duplicate module names"); + if let Err(e) = result { + assert_eq!( + e.to_string(), + format!("Duplicate module config detected: ID {first_module_id} is already used") + ); + } + Ok(()) + } + + #[tokio::test] + async fn test_duplicate_jwt_secrets() -> Result<()> { + let mut cfg = get_base_config().await; + let first_module_id = ModuleId("test_module".to_string()); + let first_signing_id = + b256!("0101010101010101010101010101010101010101010101010101010101010101"); + let second_module_id = ModuleId("2nd_test_module".to_string()); + let second_signing_id = + b256!("0202020202020202020202020202020202020202020202020202020202020202"); + + cfg.modules = Some(vec![ + create_module_config(first_module_id.clone(), first_signing_id).await, + create_module_config(second_module_id.clone(), second_signing_id).await, + ]); + + let jwts = HashMap::from([ + (first_module_id.clone(), "supersecret".to_string()), + (second_module_id.clone(), "supersecret".to_string()), /* Duplicate JWT secret */ + ]); + + // Make sure there was an error + let result = load_module_signing_configs(&cfg, &jwts); + assert!(result.is_err(), "Expected error due to duplicate JWT secrets"); + if let Err(e) = result { + assert_eq!( + e.to_string(), + format!( + "Duplicate JWT secret detected for modules {first_module_id} and {second_module_id}", + ) + ); + } + Ok(()) + } + + #[tokio::test] + async fn test_duplicate_signing_ids() -> Result<()> { + let mut cfg = get_base_config().await; + let first_module_id = ModuleId("test_module".to_string()); + let first_signing_id = + b256!("0101010101010101010101010101010101010101010101010101010101010101"); + let second_module_id = ModuleId("2nd_test_module".to_string()); + + cfg.modules = Some(vec![ + create_module_config(first_module_id.clone(), first_signing_id).await, + create_module_config(second_module_id.clone(), first_signing_id).await, /* Duplicate signing ID */ + ]); + + let jwts = HashMap::from([ + (first_module_id.clone(), "supersecret".to_string()), + (second_module_id.clone(), "another-secret".to_string()), + ]); + + // Make sure there was an error + let result = load_module_signing_configs(&cfg, &jwts); + assert!(result.is_err(), "Expected error due to duplicate signing IDs"); + if let Err(e) = result { + assert_eq!( + e.to_string(), + format!( + "Duplicate signing ID detected for modules {first_module_id} and {second_module_id}", + ) + ); + } + Ok(()) + } + + #[tokio::test] + async fn test_missing_jwt_secret() -> Result<()> { + let mut cfg = get_base_config().await; + let first_module_id = ModuleId("test_module".to_string()); + let first_signing_id = + b256!("0101010101010101010101010101010101010101010101010101010101010101"); + let second_module_id = ModuleId("2nd_test_module".to_string()); + let second_signing_id = + b256!("0202020202020202020202020202020202020202020202020202020202020202"); + + cfg.modules = Some(vec![ + create_module_config(first_module_id.clone(), first_signing_id).await, + create_module_config(second_module_id.clone(), second_signing_id).await, + ]); + + let jwts = HashMap::from([(second_module_id.clone(), "another-secret".to_string())]); + + // Make sure there was an error + let result = load_module_signing_configs(&cfg, &jwts); + assert!(result.is_err(), "Expected error due to missing JWT secret"); + if let Err(e) = result { + assert_eq!( + e.to_string(), + format!("JWT secret for module {first_module_id} is missing") + ); + } + Ok(()) + } + + #[tokio::test] + async fn test_empty_jwt_secret() -> Result<()> { + let mut cfg = get_base_config().await; + let first_module_id = ModuleId("test_module".to_string()); + let first_signing_id = + b256!("0101010101010101010101010101010101010101010101010101010101010101"); + + cfg.modules = + Some(vec![create_module_config(first_module_id.clone(), first_signing_id).await]); + + let jwts = HashMap::from([(first_module_id.clone(), "".to_string())]); + + // Make sure there was an error + let result = load_module_signing_configs(&cfg, &jwts); + assert!(result.is_err(), "Expected error due to empty JWT secret"); + if let Err(e) = result { + assert!(format!("{:?}", e).contains("JWT secret cannot be empty")); + } + + Ok(()) + } + + #[tokio::test] + async fn test_zero_signing_id() -> Result<()> { + let mut cfg = get_base_config().await; + let first_module_id = ModuleId("test_module".to_string()); + let first_signing_id = + b256!("0000000000000000000000000000000000000000000000000000000000000000"); + + cfg.modules = + Some(vec![create_module_config(first_module_id.clone(), first_signing_id).await]); + + let jwts = HashMap::from([(first_module_id.clone(), "supersecret".to_string())]); + + // Make sure there was an error + let result = load_module_signing_configs(&cfg, &jwts); + assert!(result.is_err(), "Expected error due to zero signing ID"); + if let Err(e) = result { + assert!(format!("{:?}", e).contains("Signing ID cannot be zero")); + } + Ok(()) + } + + // ── TlsMode serde ──────────────────────────────────────────────────────── + + #[test] + fn test_tls_mode_insecure_roundtrip() -> Result<()> { + let original = TlsWrapper { tls_mode: TlsMode::Insecure }; + let toml_str = toml::to_string(&original)?; + let parsed: TlsWrapper = toml::from_str(&toml_str)?; + assert!(matches!(parsed.tls_mode, TlsMode::Insecure)); + Ok(()) + } + + #[test] + fn test_tls_mode_certificate_roundtrip() -> Result<()> { + let path = PathBuf::from("/certs"); + let original = TlsWrapper { tls_mode: TlsMode::Certificate(path.clone()) }; + let toml_str = toml::to_string(&original)?; + let parsed: TlsWrapper = toml::from_str(&toml_str)?; + match parsed.tls_mode { + TlsMode::Certificate(p) => assert_eq!(p, path), + TlsMode::Insecure => panic!("Expected Certificate variant"), + } + Ok(()) + } + + #[test] + fn test_tls_mode_insecure_from_toml() -> Result<()> { + let toml_str = r#" + [tls_mode] + type = "insecure" + "#; + let parsed: TlsWrapper = toml::from_str(toml_str)?; + assert!(matches!(parsed.tls_mode, TlsMode::Insecure)); + Ok(()) + } + + #[test] + fn test_tls_mode_certificate_from_toml() -> Result<()> { + let toml_str = r#" + [tls_mode] + type = "certificate" + path = "/custom/certs" + "#; + let parsed: TlsWrapper = toml::from_str(toml_str)?; + match parsed.tls_mode { + TlsMode::Certificate(p) => assert_eq!(p, PathBuf::from("/custom/certs")), + TlsMode::Insecure => panic!("Expected Certificate variant"), + } + Ok(()) + } + + // ── signer_uses_tls ─────────────────────────────────────────────────────── + + #[tokio::test] + async fn test_signer_uses_tls_no_signer() { + let cfg = get_base_config().await; + assert!(!cfg.signer_uses_tls()); + } + + #[tokio::test] + async fn test_signer_uses_tls_insecure() { + let cfg = get_config_with_signer(TlsMode::Insecure).await; + assert!(!cfg.signer_uses_tls()); + } + + #[tokio::test] + async fn test_signer_uses_tls_certificate() { + let cfg = get_config_with_signer(TlsMode::Certificate(PathBuf::from("/certs"))).await; + assert!(cfg.signer_uses_tls()); + } + + // ── signer_certs_path ───────────────────────────────────────────────────── + + #[tokio::test] + async fn test_signer_certs_path_no_signer() { + let cfg = get_base_config().await; + assert!(cfg.signer_certs_path().is_none()); + } + + #[tokio::test] + async fn test_signer_certs_path_insecure() { + let cfg = get_config_with_signer(TlsMode::Insecure).await; + assert!(cfg.signer_certs_path().is_none()); + } + + #[tokio::test] + async fn test_signer_certs_path_certificate() { + let certs_path = PathBuf::from("/my/certs"); + let cfg = get_config_with_signer(TlsMode::Certificate(certs_path.clone())).await; + assert_eq!(cfg.signer_certs_path(), Some(&certs_path)); + } + + // ── signer_server_url ───────────────────────────────────────────────────── + + #[tokio::test] + async fn test_signer_server_url_no_signer_uses_default_port() { + let cfg = get_base_config().await; + assert_eq!(cfg.signer_server_url(12345), "http://cb_signer:12345"); + } + + #[tokio::test] + async fn test_signer_server_url_insecure_uses_http() { + let cfg = get_config_with_signer(TlsMode::Insecure).await; + assert_eq!(cfg.signer_server_url(9999), "http://cb_signer:20000"); + } + + #[tokio::test] + async fn test_signer_server_url_certificate_uses_https() { + let cfg = get_config_with_signer(TlsMode::Certificate(PathBuf::from("/certs"))).await; + assert_eq!(cfg.signer_server_url(9999), "https://cb_signer:20000"); + } + + #[tokio::test] + async fn test_signer_server_url_remote_returned_as_is() { + let remote_url = Url::parse("https://remote-signer.example.com:8080").unwrap(); + let mut cfg = get_base_config().await; + cfg.signer = Some(SignerConfig { + host: Ipv4Addr::new(127, 0, 0, 1), + port: 20000, + docker_image: SIGNER_IMAGE_DEFAULT.to_string(), + jwt_auth_fail_limit: 3, + jwt_auth_fail_timeout_seconds: 300, + tls_mode: TlsMode::Insecure, + reverse_proxy: ReverseProxyHeaderSetup::None, + inner: SignerType::Remote { url: remote_url.clone() }, + }); + assert_eq!(cfg.signer_server_url(9999), remote_url.to_string()); + } + + // ── ReverseProxyHeaderSetup Display ────────────────────────────────────── + + #[test] + fn test_reverse_proxy_display_none() { + assert_eq!(ReverseProxyHeaderSetup::None.to_string(), "None"); + } + + #[test] + fn test_reverse_proxy_display_unique() { + let rp = ReverseProxyHeaderSetup::Unique { header: "X-Forwarded-For".to_string() }; + assert_eq!(rp.to_string(), r#""X-Forwarded-For (unique)""#); + } + + #[test] + fn test_reverse_proxy_display_rightmost_1st() { + let rp = ReverseProxyHeaderSetup::Rightmost { + header: "X-Real-IP".to_string(), + trusted_count: NonZeroUsize::new(1).unwrap(), + }; + assert_eq!(rp.to_string(), r#""X-Real-IP (1st from the right)""#); + } + + #[test] + fn test_reverse_proxy_display_rightmost_2nd() { + let rp = ReverseProxyHeaderSetup::Rightmost { + header: "X-Real-IP".to_string(), + trusted_count: NonZeroUsize::new(2).unwrap(), + }; + assert_eq!(rp.to_string(), r#""X-Real-IP (2nd from the right)""#); + } + + #[test] + fn test_reverse_proxy_display_rightmost_3rd() { + let rp = ReverseProxyHeaderSetup::Rightmost { + header: "X-Real-IP".to_string(), + trusted_count: NonZeroUsize::new(3).unwrap(), + }; + assert_eq!(rp.to_string(), r#""X-Real-IP (3rd from the right)""#); + } + + #[test] + fn test_reverse_proxy_display_rightmost_nth() { + let rp = ReverseProxyHeaderSetup::Rightmost { + header: "CF-Connecting-IP".to_string(), + trusted_count: NonZeroUsize::new(5).unwrap(), + }; + assert_eq!(rp.to_string(), r#""CF-Connecting-IP (5th from the right)""#); + } +} diff --git a/crates/common/src/config/utils.rs b/crates/common/src/config/utils.rs index a8fcbacd..579825b6 100644 --- a/crates/common/src/config/utils.rs +++ b/crates/common/src/config/utils.rs @@ -6,9 +6,8 @@ use std::{ use eyre::{Context, Result, bail}; use serde::de::DeserializeOwned; -use super::JWTS_ENV; use crate::{ - config::MUXER_HTTP_MAX_LENGTH, + config::{ADMIN_JWT_ENV, JWTS_ENV, MUXER_HTTP_MAX_LENGTH}, types::{BlsPublicKey, ModuleId}, utils::read_chunked_body_with_max, }; @@ -37,9 +36,10 @@ pub fn load_file_from_env(env: &str) -> Result<(T, PathBuf) } /// Loads a map of module id -> jwt secret from a json env -pub fn load_jwt_secrets() -> Result> { +pub fn load_jwt_secrets() -> Result<(String, HashMap)> { + let admin_jwt = std::env::var(ADMIN_JWT_ENV).wrap_err(format!("{ADMIN_JWT_ENV} is not set"))?; let jwt_secrets = std::env::var(JWTS_ENV).wrap_err(format!("{JWTS_ENV} is not set"))?; - decode_string_to_map(&jwt_secrets) + decode_string_to_map(&jwt_secrets).map(|secrets| (admin_jwt, secrets)) } /// Reads an HTTP response safely, erroring out if it failed or if the body is @@ -82,7 +82,7 @@ pub fn remove_duplicate_keys(keys: Vec) -> Vec { unique_keys } -fn decode_string_to_map(raw: &str) -> Result> { +pub fn decode_string_to_map(raw: &str) -> Result> { // trim the string and split for comma raw.trim() .split(',') @@ -98,19 +98,68 @@ fn decode_string_to_map(raw: &str) -> Result> { #[cfg(test)] mod tests { + use std::sync::Mutex; + use super::*; use crate::utils::TestRandomSeed; + // Serializes all tests that read/write environment variables. + // std::env::set_var is unsafe (Rust 1.81+) because mutating `environ` + // while another thread reads it is UB at the OS level. Holding this + // lock ensures our Rust threads don't race each other. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + /// Sets or removes env vars for the duration of `f`, then restores the + /// original values. Pass `Some("val")` to set, `None` to ensure absent. + fn with_env(vars: &[(&str, Option<&str>)], f: impl FnOnce() -> R) -> R { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let saved: Vec<(&str, Option)> = + vars.iter().map(|(k, _)| (*k, std::env::var(k).ok())).collect(); + for (k, v) in vars { + match v { + Some(val) => unsafe { std::env::set_var(k, val) }, + None => unsafe { std::env::remove_var(k) }, + } + } + let result = f(); + for (k, old) in &saved { + match old { + Some(v) => unsafe { std::env::set_var(k, v) }, + None => unsafe { std::env::remove_var(k) }, + } + } + result + } + + // Minimal TOML-deserializable type used by load_from_file / load_file_from_env + // tests. + #[derive(serde::Deserialize, Debug, PartialEq)] + struct TestConfig { + value: String, + } + + // ── decode_string_to_map ───────────────────────────────────────────────── + #[test] - fn test_decode_string_to_map() { - let raw = " KEY=VALUE , KEY2=value2 "; + fn test_decode_string_to_map_single_pair() { + let map = decode_string_to_map("ONLY=ONE").unwrap(); + assert_eq!(map.len(), 1); + assert_eq!(map.get(&ModuleId("ONLY".into())), Some(&"ONE".to_string())); + } - let map = decode_string_to_map(raw).unwrap(); + #[test] + fn test_decode_string_to_map_empty_string() { + // An empty string yields one token with no `=`, which is invalid. + assert!(decode_string_to_map("").is_err()); + } - assert_eq!(map.get(&ModuleId("KEY".into())), Some(&"VALUE".to_string())); - assert_eq!(map.get(&ModuleId("KEY2".into())), Some(&"value2".to_string())); + #[test] + fn test_decode_string_to_map_malformed_no_equals() { + assert!(decode_string_to_map("KEYONLY").is_err()); } + // ── remove_duplicate_keys ──────────────────────────────────────────────── + #[test] fn test_remove_duplicate_keys() { let key1 = BlsPublicKey::test_random(); @@ -122,4 +171,134 @@ mod tests { assert!(unique_keys.contains(&key1)); assert!(unique_keys.contains(&key2)); } + + // ── load_env_var ───────────────────────────────────────────────────────── + + #[test] + fn test_load_env_var_present() { + with_env(&[("CB_TEST_LOAD_ENV_VAR", Some("hello"))], || { + assert_eq!(load_env_var("CB_TEST_LOAD_ENV_VAR").unwrap(), "hello"); + }); + } + + #[test] + fn test_load_env_var_absent() { + with_env(&[("CB_TEST_LOAD_ENV_VAR_ABSENT", None)], || { + let err = load_env_var("CB_TEST_LOAD_ENV_VAR_ABSENT").unwrap_err(); + assert!(err.to_string().contains("CB_TEST_LOAD_ENV_VAR_ABSENT")); + }); + } + + // ── load_optional_env_var ──────────────────────────────────────────────── + + #[test] + fn test_load_optional_env_var_present() { + with_env(&[("CB_TEST_OPT_VAR", Some("world"))], || { + assert_eq!(load_optional_env_var("CB_TEST_OPT_VAR"), Some("world".to_string())); + }); + } + + #[test] + fn test_load_optional_env_var_absent() { + with_env(&[("CB_TEST_OPT_VAR_ABSENT", None)], || { + assert_eq!(load_optional_env_var("CB_TEST_OPT_VAR_ABSENT"), None); + }); + } + + // ── load_from_file ─────────────────────────────────────────────────────── + + #[test] + fn test_load_from_file_valid() { + use std::io::Write as _; + let mut file = tempfile::NamedTempFile::new().unwrap(); + file.write_all(b"value = \"hello\"").unwrap(); + let path = file.path().to_path_buf(); + + let (config, returned_path): (TestConfig, _) = load_from_file(&path).unwrap(); + assert_eq!(config.value, "hello"); + assert_eq!(returned_path, path); + } + + #[test] + fn test_load_from_file_missing() { + let result: eyre::Result<(TestConfig, _)> = + load_from_file("/nonexistent/cb_test_path/file.toml"); + assert!(result.is_err()); + } + + #[test] + fn test_load_from_file_invalid_toml() { + use std::io::Write as _; + let mut file = tempfile::NamedTempFile::new().unwrap(); + file.write_all(b"not valid toml !!!{{").unwrap(); + + let result: eyre::Result<(TestConfig, _)> = load_from_file(file.path()); + assert!(result.is_err()); + } + + // ── load_file_from_env ─────────────────────────────────────────────────── + + #[test] + fn test_load_file_from_env_ok() { + use std::io::Write as _; + let mut file = tempfile::NamedTempFile::new().unwrap(); + file.write_all(b"value = \"from_env\"").unwrap(); + let path = file.path().to_str().unwrap().to_owned(); + + with_env(&[("CB_TEST_FILE_ENV", Some(&path))], || { + let (config, _): (TestConfig, _) = load_file_from_env("CB_TEST_FILE_ENV").unwrap(); + assert_eq!(config.value, "from_env"); + }); + } + + #[test] + fn test_load_file_from_env_var_not_set() { + with_env(&[("CB_TEST_FILE_ENV_ABSENT", None)], || { + let result: eyre::Result<(TestConfig, _)> = + load_file_from_env("CB_TEST_FILE_ENV_ABSENT"); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("CB_TEST_FILE_ENV_ABSENT")); + }); + } + + // ── load_jwt_secrets ───────────────────────────────────────────────────── + + #[test] + fn test_load_jwt_secrets_ok() { + with_env( + &[ + (ADMIN_JWT_ENV, Some("admin_secret")), + (JWTS_ENV, Some("MODULE1=secret1,MODULE2=secret2")), + ], + || { + let (admin_jwt, secrets) = load_jwt_secrets().unwrap(); + assert_eq!(admin_jwt, "admin_secret"); + assert_eq!(secrets.get(&ModuleId("MODULE1".into())), Some(&"secret1".to_string())); + assert_eq!(secrets.get(&ModuleId("MODULE2".into())), Some(&"secret2".to_string())); + }, + ); + } + + #[test] + fn test_load_jwt_secrets_missing_admin_jwt() { + with_env(&[(ADMIN_JWT_ENV, None), (JWTS_ENV, Some("MODULE1=secret1"))], || { + let err = load_jwt_secrets().unwrap_err(); + assert!(err.to_string().contains(ADMIN_JWT_ENV)); + }); + } + + #[test] + fn test_load_jwt_secrets_missing_jwts() { + with_env(&[(ADMIN_JWT_ENV, Some("admin_secret")), (JWTS_ENV, None)], || { + let err = load_jwt_secrets().unwrap_err(); + assert!(err.to_string().contains(JWTS_ENV)); + }); + } + + #[test] + fn test_load_jwt_secrets_malformed_jwts() { + with_env(&[(ADMIN_JWT_ENV, Some("admin_secret")), (JWTS_ENV, Some("MALFORMED"))], || { + assert!(load_jwt_secrets().is_err()); + }); + } } diff --git a/crates/common/src/signature.rs b/crates/common/src/signature.rs index d899842b..41631e33 100644 --- a/crates/common/src/signature.rs +++ b/crates/common/src/signature.rs @@ -1,32 +1,44 @@ -use alloy::primitives::B256; +use alloy::primitives::{Address, B256, aliases::B32}; use tree_hash::TreeHash; use tree_hash_derive::TreeHash; use crate::{ constants::{COMMIT_BOOST_DOMAIN, GENESIS_VALIDATORS_ROOT}, - signer::verify_bls_signature, - types::{BlsPublicKey, BlsSecretKey, BlsSignature, Chain}, + signer::{EcdsaSignature, verify_bls_signature, verify_ecdsa_signature}, + types::{self, BlsPublicKey, BlsSecretKey, BlsSignature, Chain, SignatureRequestInfo}, }; pub fn sign_message(secret_key: &BlsSecretKey, msg: B256) -> BlsSignature { secret_key.sign(msg) } -pub fn compute_signing_root(object_root: B256, signing_domain: B256) -> B256 { - #[derive(Default, Debug, TreeHash)] - struct SigningData { - object_root: B256, - signing_domain: B256, +pub fn compute_prop_commit_signing_root( + chain: Chain, + object_root: &B256, + signature_request_info: Option<&SignatureRequestInfo>, + domain_mask: &B32, +) -> B256 { + let domain = compute_domain(chain, domain_mask); + match signature_request_info { + Some(SignatureRequestInfo { module_signing_id, nonce }) => { + let object_root = types::PropCommitSigningInfo { + data: *object_root, + module_signing_id: *module_signing_id, + nonce: *nonce, + chain_id: chain.id(), + } + .tree_hash_root(); + types::SigningData { object_root, signing_domain: domain }.tree_hash_root() + } + None => types::SigningData { object_root: *object_root, signing_domain: domain } + .tree_hash_root(), } - - let signing_data = SigningData { object_root, signing_domain }; - signing_data.tree_hash_root() } // NOTE: this currently works only for builder domain signatures and // verifications // ref: https://github.com/ralexstokes/ethereum-consensus/blob/cf3c404043230559660810bc0c9d6d5a8498d819/ethereum-consensus/src/builder/mod.rs#L26-L29 -pub fn compute_domain(chain: Chain, domain_mask: [u8; 4]) -> B256 { +pub fn compute_domain(chain: Chain, domain_mask: &B32) -> B256 { #[derive(Debug, TreeHash)] struct ForkData { fork_version: [u8; 4], @@ -34,7 +46,7 @@ pub fn compute_domain(chain: Chain, domain_mask: [u8; 4]) -> B256 { } let mut domain = [0u8; 32]; - domain[..4].copy_from_slice(&domain_mask); + domain[..4].copy_from_slice(&domain_mask.0); let fork_version = chain.genesis_fork_version(); let fd = ForkData { fork_version, genesis_validators_root: GENESIS_VALIDATORS_ROOT.into() }; @@ -42,7 +54,7 @@ pub fn compute_domain(chain: Chain, domain_mask: [u8; 4]) -> B256 { domain[4..].copy_from_slice(&fork_data_root[..28]); - domain.into() + B256::from(domain) } pub fn verify_signed_message( @@ -50,65 +62,172 @@ pub fn verify_signed_message( pubkey: &BlsPublicKey, msg: &T, signature: &BlsSignature, - domain_mask: [u8; 4], + signature_request_info: Option<&SignatureRequestInfo>, + domain_mask: &B32, ) -> bool { - let domain = compute_domain(chain, domain_mask); - let signing_root = compute_signing_root(msg.tree_hash_root(), domain); - + let signing_root = compute_prop_commit_signing_root( + chain, + &msg.tree_hash_root(), + signature_request_info, + domain_mask, + ); verify_bls_signature(pubkey, signing_root, signature) } +/// Signs a message with the Beacon builder domain. pub fn sign_builder_message( chain: Chain, secret_key: &BlsSecretKey, msg: &impl TreeHash, ) -> BlsSignature { - sign_builder_root(chain, secret_key, msg.tree_hash_root()) + sign_builder_root(chain, secret_key, &msg.tree_hash_root()) } pub fn sign_builder_root( chain: Chain, secret_key: &BlsSecretKey, - object_root: B256, + object_root: &B256, ) -> BlsSignature { - let domain = chain.builder_domain(); - let signing_root = compute_signing_root(object_root, domain); + let signing_domain = chain.builder_domain(); + let signing_data = + types::SigningData { object_root: object_root.tree_hash_root(), signing_domain }; + let signing_root = signing_data.tree_hash_root(); sign_message(secret_key, signing_root) } pub fn sign_commit_boost_root( chain: Chain, secret_key: &BlsSecretKey, - object_root: B256, + object_root: &B256, + signature_request_info: Option<&SignatureRequestInfo>, ) -> BlsSignature { - let domain = compute_domain(chain, COMMIT_BOOST_DOMAIN); - let signing_root = compute_signing_root(object_root, domain); + let signing_root = compute_prop_commit_signing_root( + chain, + object_root, + signature_request_info, + &B32::from(COMMIT_BOOST_DOMAIN), + ); sign_message(secret_key, signing_root) } +// ============================== +// === Signature Verification === +// ============================== + +/// Verifies that a proposer commitment signature was generated by the given BLS +/// key for the provided message, chain ID, and module signing ID. +pub fn verify_proposer_commitment_signature_bls( + chain: Chain, + pubkey: &BlsPublicKey, + msg: &impl TreeHash, + signature: &BlsSignature, + module_signing_id: &B256, + nonce: u64, +) -> bool { + let signing_domain = compute_domain(chain, &B32::from(COMMIT_BOOST_DOMAIN)); + let object_root = types::PropCommitSigningInfo { + data: msg.tree_hash_root(), + module_signing_id: *module_signing_id, + nonce, + chain_id: chain.id(), + } + .tree_hash_root(); + let signing_root = types::SigningData { object_root, signing_domain }.tree_hash_root(); + verify_bls_signature(pubkey, signing_root, signature) +} + +/// Verifies that a proposer commitment signature was generated by the given +/// ECDSA key for the provided message, chain ID, and module signing ID. +pub fn verify_proposer_commitment_signature_ecdsa( + chain: Chain, + address: &Address, + msg: &impl TreeHash, + signature: &EcdsaSignature, + module_signing_id: &B256, + nonce: u64, +) -> Result<(), eyre::Report> { + let signing_domain = compute_domain(chain, &B32::from(COMMIT_BOOST_DOMAIN)); + let object_root = types::PropCommitSigningInfo { + data: msg.tree_hash_root(), + module_signing_id: *module_signing_id, + nonce, + chain_id: chain.id(), + } + .tree_hash_root(); + let signing_root = types::SigningData { object_root, signing_domain }.tree_hash_root(); + verify_ecdsa_signature(address, &signing_root, signature) +} + +// =============== +// === Testing === +// =============== + #[cfg(test)] mod tests { - use super::compute_domain; - use crate::{constants::APPLICATION_BUILDER_DOMAIN, types::Chain}; + use alloy::primitives::{U256, aliases::B32}; + + use super::{compute_domain, sign_builder_message, verify_signed_message}; + use crate::{ + constants::APPLICATION_BUILDER_DOMAIN, + pbs::{ + BlindedBeaconBlockElectra, BuilderBid, BuilderBidElectra, + ExecutionPayloadHeaderElectra, ExecutionRequests, + }, + types::{BlsSecretKey, Chain}, + utils::TestRandomSeed, + }; #[test] fn test_builder_domains() { - assert_eq!( - compute_domain(Chain::Mainnet, APPLICATION_BUILDER_DOMAIN), - Chain::Mainnet.builder_domain() - ); - assert_eq!( - compute_domain(Chain::Holesky, APPLICATION_BUILDER_DOMAIN), - Chain::Holesky.builder_domain() - ); - assert_eq!( - compute_domain(Chain::Sepolia, APPLICATION_BUILDER_DOMAIN), - Chain::Sepolia.builder_domain() - ); - assert_eq!( - compute_domain(Chain::Hoodi, APPLICATION_BUILDER_DOMAIN), - Chain::Hoodi.builder_domain() - ); + let domain = &B32::from(APPLICATION_BUILDER_DOMAIN); + assert_eq!(compute_domain(Chain::Mainnet, domain), Chain::Mainnet.builder_domain()); + assert_eq!(compute_domain(Chain::Holesky, domain), Chain::Holesky.builder_domain()); + assert_eq!(compute_domain(Chain::Sepolia, domain), Chain::Sepolia.builder_domain()); + assert_eq!(compute_domain(Chain::Hoodi, domain), Chain::Hoodi.builder_domain()); + } + + #[test] + fn test_builder_bid_sign_and_verify() { + let secret_key = BlsSecretKey::test_random(); + let pubkey = secret_key.public_key(); + + let message = BuilderBid::Electra(BuilderBidElectra { + header: ExecutionPayloadHeaderElectra::test_random(), + blob_kzg_commitments: Default::default(), + execution_requests: ExecutionRequests::default(), + value: U256::from(10), + pubkey: pubkey.clone().into(), + }); + + let sig = sign_builder_message(Chain::Mainnet, &secret_key, &message); + + assert!(verify_signed_message( + Chain::Mainnet, + &pubkey, + &message, + &sig, + None, + &B32::from(APPLICATION_BUILDER_DOMAIN), + )); + } + + #[test] + fn test_blinded_block_sign_and_verify() { + let secret_key = BlsSecretKey::test_random(); + let pubkey = secret_key.public_key(); + + let block = BlindedBeaconBlockElectra::test_random(); + + let sig = sign_builder_message(Chain::Mainnet, &secret_key, &block); + + assert!(verify_signed_message( + Chain::Mainnet, + &pubkey, + &block, + &sig, + None, + &B32::from(APPLICATION_BUILDER_DOMAIN), + )); } } diff --git a/crates/common/src/signer/schemes/bls.rs b/crates/common/src/signer/schemes/bls.rs index 8525f015..07f5e6dd 100644 --- a/crates/common/src/signer/schemes/bls.rs +++ b/crates/common/src/signer/schemes/bls.rs @@ -3,7 +3,7 @@ use tree_hash::TreeHash; use crate::{ signature::sign_commit_boost_root, - types::{BlsPublicKey, BlsSecretKey, BlsSignature, Chain}, + types::{BlsPublicKey, BlsSecretKey, BlsSignature, Chain, SignatureRequestInfo}, }; #[derive(Clone)] @@ -28,20 +28,32 @@ impl BlsSigner { } } - pub fn secret(&self) -> [u8; 32] { + pub fn secret(&self) -> B256 { match self { BlsSigner::Local(secret) => secret.serialize().as_bytes().try_into().unwrap(), } } - pub async fn sign(&self, chain: Chain, object_root: B256) -> BlsSignature { + pub async fn sign( + &self, + chain: Chain, + object_root: &B256, + signature_request_info: Option<&SignatureRequestInfo>, + ) -> BlsSignature { match self { - BlsSigner::Local(sk) => sign_commit_boost_root(chain, sk, object_root), + BlsSigner::Local(sk) => { + sign_commit_boost_root(chain, sk, object_root, signature_request_info) + } } } - pub async fn sign_msg(&self, chain: Chain, msg: &impl TreeHash) -> BlsSignature { - self.sign(chain, msg.tree_hash_root()).await + pub async fn sign_msg( + &self, + chain: Chain, + msg: &impl TreeHash, + signature_request_info: Option<&SignatureRequestInfo>, + ) -> BlsSignature { + self.sign(chain, &msg.tree_hash_root(), signature_request_info).await } } diff --git a/crates/common/src/signer/schemes/ecdsa.rs b/crates/common/src/signer/schemes/ecdsa.rs index 37fc18b4..a597200e 100644 --- a/crates/common/src/signer/schemes/ecdsa.rs +++ b/crates/common/src/signer/schemes/ecdsa.rs @@ -1,7 +1,7 @@ use std::{ops::Deref, str::FromStr}; use alloy::{ - primitives::{Address, B256, Signature}, + primitives::{Address, B256, Signature, aliases::B32}, signers::{SignerSync, local::PrivateKeySigner}, }; use eyre::ensure; @@ -9,8 +9,8 @@ use tree_hash::TreeHash; use crate::{ constants::COMMIT_BOOST_DOMAIN, - signature::{compute_domain, compute_signing_root}, - types::Chain, + signature::compute_prop_commit_signing_root, + types::{Chain, SignatureRequestInfo}, }; #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -86,32 +86,37 @@ impl EcdsaSigner { pub async fn sign( &self, chain: Chain, - object_root: B256, + object_root: &B256, + signature_request_info: Option<&SignatureRequestInfo>, ) -> Result { match self { EcdsaSigner::Local(sk) => { - let domain = compute_domain(chain, COMMIT_BOOST_DOMAIN); - let signing_root = compute_signing_root(object_root, domain); + let signing_root = compute_prop_commit_signing_root( + chain, + object_root, + signature_request_info, + &B32::from(COMMIT_BOOST_DOMAIN), + ); sk.sign_hash_sync(&signing_root).map(EcdsaSignature::from) } } } - pub async fn sign_msg( &self, chain: Chain, msg: &impl TreeHash, + signature_request_info: Option<&SignatureRequestInfo>, ) -> Result { - self.sign(chain, msg.tree_hash_root()).await + self.sign(chain, &msg.tree_hash_root(), signature_request_info).await } } pub fn verify_ecdsa_signature( address: &Address, - msg: &[u8; 32], + msg: &B256, signature: &EcdsaSignature, ) -> eyre::Result<()> { - let recovered = signature.recover_address_from_prehash(msg.into())?; + let recovered = signature.recover_address_from_prehash(msg)?; ensure!(recovered == *address, "invalid signature"); Ok(()) } @@ -119,20 +124,25 @@ pub fn verify_ecdsa_signature( #[cfg(test)] mod test { - use alloy::{hex, primitives::bytes}; + use alloy::{ + hex, + primitives::{b256, bytes}, + }; use super::*; + use crate::{signature::compute_domain, types}; #[tokio::test] - async fn test_ecdsa_signer() { + async fn test_ecdsa_signer_noncommit() { let pk = bytes!("88bcd6672d95bcba0d52a3146494ed4d37675af4ed2206905eb161aa99a6c0d1"); let signer = EcdsaSigner::new_from_bytes(&pk).unwrap(); let object_root = B256::from([1; 32]); - let signature = signer.sign(Chain::Holesky, object_root).await.unwrap(); + let signature = signer.sign(Chain::Holesky, &object_root, None).await.unwrap(); - let domain = compute_domain(Chain::Holesky, COMMIT_BOOST_DOMAIN); - let msg = compute_signing_root(object_root, domain); + let domain = compute_domain(Chain::Holesky, &B32::from(COMMIT_BOOST_DOMAIN)); + let signing_data = types::SigningData { object_root, signing_domain: domain }; + let msg = signing_data.tree_hash_root(); assert_eq!(msg, hex!("219ca7a673b2cbbf67bec6c9f60f78bd051336d57b68d1540190f30667e86725")); @@ -140,4 +150,41 @@ mod test { let verified = verify_ecdsa_signature(&address, &msg, &signature); assert!(verified.is_ok()); } + + #[tokio::test] + async fn test_ecdsa_signer_prop_commit() { + let pk = bytes!("88bcd6672d95bcba0d52a3146494ed4d37675af4ed2206905eb161aa99a6c0d1"); + let signer = EcdsaSigner::new_from_bytes(&pk).unwrap(); + + let object_root = B256::from([1; 32]); + let module_signing_id = B256::from([2; 32]); + let nonce = 42; + let signature = signer + .sign( + Chain::Hoodi, + &object_root, + Some(&SignatureRequestInfo { module_signing_id, nonce }), + ) + .await + .unwrap(); + + let signing_domain = compute_domain(Chain::Hoodi, &B32::from(COMMIT_BOOST_DOMAIN)); + let object_root = types::PropCommitSigningInfo { + data: object_root, + module_signing_id, + nonce, + chain_id: Chain::Hoodi.id(), + } + .tree_hash_root(); + let msg = types::SigningData { object_root, signing_domain }.tree_hash_root(); + + assert_eq!( + msg, + b256!("0x0b95fcdb3f003fc6f0fd3238d906f359809e97fe7ec71f56771cb05bee4150bd") + ); + + let address = signer.address(); + let verified = verify_ecdsa_signature(&address, &msg, &signature); + assert!(verified.is_ok()); + } } diff --git a/crates/common/src/signer/store.rs b/crates/common/src/signer/store.rs index 7cc0fc17..d70ea8a0 100644 --- a/crates/common/src/signer/store.rs +++ b/crates/common/src/signer/store.rs @@ -244,14 +244,14 @@ impl ProxyStore { serde_json::from_str(&file_content)?; let signer = EcdsaSigner::new_from_bytes(&key_and_delegation.secret)?; - let pubkey = signer.address(); + let address = signer.address(); let proxy_signer = EcdsaProxySigner { signer, delegation: key_and_delegation.delegation, }; - proxy_signers.ecdsa_signers.insert(pubkey, proxy_signer); - ecdsa_map.entry(module_id.clone()).or_default().push(pubkey); + proxy_signers.ecdsa_signers.insert(address, proxy_signer); + ecdsa_map.entry(module_id.clone()).or_default().push(address); } } } @@ -564,7 +564,8 @@ mod test { delegator: consensus_signer.pubkey(), proxy: proxy_signer.pubkey(), }; - let signature = consensus_signer.sign(Chain::Mainnet, message.tree_hash_root()).await; + let signature = + consensus_signer.sign(Chain::Mainnet, &message.tree_hash_root(), None).await; let delegation = SignedProxyDelegationBls { signature: signature.clone(), message }; let proxy_signer = BlsProxySigner { signer: proxy_signer, delegation }; @@ -679,7 +680,8 @@ mod test { delegator: consensus_signer.pubkey(), proxy: proxy_signer.pubkey(), }; - let signature = consensus_signer.sign(Chain::Mainnet, message.tree_hash_root()).await; + let signature = + consensus_signer.sign(Chain::Mainnet, &message.tree_hash_root(), None).await; let delegation = SignedProxyDelegationBls { signature, message }; let proxy_signer = BlsProxySigner { signer: proxy_signer, delegation }; diff --git a/crates/common/src/types.rs b/crates/common/src/types.rs index 8262f4fd..38354ffc 100644 --- a/crates/common/src/types.rs +++ b/crates/common/src/types.rs @@ -1,10 +1,11 @@ use std::path::PathBuf; -use alloy::primitives::{B256, Bytes, b256, hex}; +use alloy::primitives::{B256, Bytes, U256, aliases::B32, b256, hex}; use derive_more::{Deref, Display, From, Into}; use eyre::{Context, bail}; use lh_types::ForkName; use serde::{Deserialize, Serialize}; +use tree_hash_derive::TreeHash; use crate::{constants::APPLICATION_BUILDER_DOMAIN, signature::compute_domain}; @@ -26,7 +27,17 @@ pub struct Jwt(pub String); #[derive(Debug, Serialize, Deserialize)] pub struct JwtClaims { pub exp: u64, - pub module: String, + pub module: ModuleId, + pub route: String, + pub payload_hash: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct JwtAdminClaims { + pub exp: u64, + pub admin: bool, + pub route: String, + pub payload_hash: Option, } #[derive(Clone, Copy, PartialEq, Eq, Hash)] @@ -40,7 +51,7 @@ pub enum Chain { slot_time_secs: u64, genesis_fork_version: ForkVersion, fulu_fork_slot: u64, - chain_id: u64, + chain_id: U256, }, } @@ -103,7 +114,9 @@ impl std::fmt::Debug for Chain { } impl Chain { - pub fn id(&self) -> u64 { + // Chain IDs are 256-bit unsigned integers because they need to support + // Keccak256 hashes + pub fn id(&self) -> U256 { match self { Chain::Mainnet => KnownChain::Mainnet.id(), Chain::Holesky => KnownChain::Holesky.id(), @@ -119,7 +132,7 @@ impl Chain { Chain::Holesky => KnownChain::Holesky.builder_domain(), Chain::Sepolia => KnownChain::Sepolia.builder_domain(), Chain::Hoodi => KnownChain::Hoodi.builder_domain(), - Chain::Custom { .. } => compute_domain(*self, APPLICATION_BUILDER_DOMAIN), + Chain::Custom { .. } => compute_domain(*self, &B32::from(APPLICATION_BUILDER_DOMAIN)), } } @@ -182,12 +195,12 @@ pub enum KnownChain { // Constants impl KnownChain { - pub fn id(&self) -> u64 { + pub fn id(&self) -> U256 { match self { - KnownChain::Mainnet => 1, - KnownChain::Holesky => 17000, - KnownChain::Sepolia => 11155111, - KnownChain::Hoodi => 560048, + KnownChain::Mainnet => U256::from(1), + KnownChain::Holesky => U256::from(17000), + KnownChain::Sepolia => U256::from(11155111), + KnownChain::Hoodi => U256::from(560048), } } @@ -272,7 +285,7 @@ pub enum ChainLoader { slot_time_secs: u64, genesis_fork_version: Bytes, fulu_fork_slot: u64, - chain_id: u64, + chain_id: U256, }, } @@ -346,13 +359,38 @@ impl<'de> Deserialize<'de> for Chain { } } +/// Structure for signatures used in Beacon chain operations +#[derive(Default, Debug, TreeHash)] +pub struct SigningData { + pub object_root: B256, + pub signing_domain: B256, +} + +/// Structure for signatures used for proposer commitments in Commit Boost. +/// The signing root of this struct must be used as the object_root of a +/// SigningData for signatures. +#[derive(Default, Debug, TreeHash)] +pub struct PropCommitSigningInfo { + pub data: B256, + pub module_signing_id: B256, + pub nonce: u64, // As per https://eips.ethereum.org/EIPS/eip-2681 + pub chain_id: U256, +} + +/// Information about a signature request, including the module signing ID and +/// nonce. +pub struct SignatureRequestInfo { + pub module_signing_id: B256, + pub nonce: u64, +} + /// Returns seconds_per_slot, genesis_fork_version, fulu_fork_epoch, and /// deposit_chain_id from a spec, such as returned by /eth/v1/config/spec ref: https://ethereum.github.io/beacon-APIs/#/Config/getSpec /// Try to load two formats: /// - JSON as return the getSpec endpoint, either with or without the `data` /// field /// - YAML as used e.g. in Kurtosis/Ethereum Package -pub fn load_chain_from_file(path: PathBuf) -> eyre::Result<(u64, ForkVersion, u64, u64)> { +pub fn load_chain_from_file(path: PathBuf) -> eyre::Result<(u64, ForkVersion, u64, U256)> { #[derive(Deserialize)] #[serde(rename_all = "UPPERCASE")] struct QuotedSpecFile { @@ -363,12 +401,12 @@ pub fn load_chain_from_file(path: PathBuf) -> eyre::Result<(u64, ForkVersion, u6 slots_per_epoch: u64, #[serde(with = "serde_utils::quoted_u64")] fulu_fork_epoch: u64, - #[serde(with = "serde_utils::quoted_u64")] - deposit_chain_id: u64, + #[serde(with = "serde_utils::quoted_u256")] + deposit_chain_id: U256, } impl QuotedSpecFile { - fn to_chain(&self) -> eyre::Result<(u64, ForkVersion, u64, u64)> { + fn to_chain(&self) -> eyre::Result<(u64, ForkVersion, u64, U256)> { let genesis_fork_version: ForkVersion = self.genesis_fork_version.as_ref().try_into()?; let fulu_fork_slot = self.fulu_fork_epoch.saturating_mul(self.slots_per_epoch); @@ -388,11 +426,11 @@ pub fn load_chain_from_file(path: PathBuf) -> eyre::Result<(u64, ForkVersion, u6 genesis_fork_version: u32, slots_per_epoch: Option, fulu_fork_epoch: u64, - deposit_chain_id: u64, + deposit_chain_id: U256, } impl SpecFile { - fn to_chain(&self) -> (u64, ForkVersion, u64, u64) { + fn to_chain(&self) -> (u64, ForkVersion, u64, U256) { let genesis_fork_version: ForkVersion = self.genesis_fork_version.to_be_bytes(); let fulu_fork_slot = self.fulu_fork_epoch.saturating_mul(self.slots_per_epoch.unwrap_or(32)); @@ -432,14 +470,14 @@ mod tests { #[test] fn test_load_custom() { - let s = r#"chain = { genesis_time_secs = 1, slot_time_secs = 2, genesis_fork_version = "0x01000000", fulu_fork_slot = 1, chain_id = 123 }"#; + let s = r#"chain = { genesis_time_secs = 1, slot_time_secs = 2, genesis_fork_version = "0x01000000", fulu_fork_slot = 1, chain_id = "123" }"#; let decoded: MockConfig = toml::from_str(s).unwrap(); assert_eq!(decoded.chain, Chain::Custom { genesis_time_secs: 1, slot_time_secs: 2, genesis_fork_version: [1, 0, 0, 0], fulu_fork_slot: 1, - chain_id: 123, + chain_id: U256::from(123), }) } @@ -548,7 +586,7 @@ mod tests { slot_time_secs: 12, genesis_fork_version: hex!("0x10000038"), fulu_fork_slot: 0, - chain_id: 3151908, + chain_id: U256::from(3151908), }) } } diff --git a/crates/common/src/utils.rs b/crates/common/src/utils.rs index 764ab188..e504e477 100644 --- a/crates/common/src/utils.rs +++ b/crates/common/src/utils.rs @@ -5,7 +5,10 @@ use std::{ time::{SystemTime, UNIX_EPOCH}, }; -use alloy::{hex, primitives::U256}; +use alloy::{ + hex, + primitives::{U256, keccak256}, +}; use axum::http::HeaderValue; use futures::StreamExt; use lh_types::test_utils::{SeedableRng, TestRandom, XorShiftRng}; @@ -27,7 +30,7 @@ use crate::{ config::LogsSettings, constants::SIGNER_JWT_EXPIRATION, pbs::HEADER_VERSION_VALUE, - types::{BlsPublicKey, Chain, Jwt, JwtClaims, ModuleId}, + types::{BlsPublicKey, Chain, Jwt, JwtAdminClaims, JwtClaims, ModuleId}, }; const MILLIS_PER_SECOND: u64 = 1_000; @@ -344,12 +347,19 @@ pub fn print_logo() { } /// Create a JWT for the given module id with expiration -pub fn create_jwt(module_id: &ModuleId, secret: &str) -> eyre::Result { +pub fn create_jwt( + module_id: &ModuleId, + secret: &str, + route: &str, + payload: Option<&[u8]>, +) -> eyre::Result { jsonwebtoken::encode( &jsonwebtoken::Header::default(), &JwtClaims { - module: module_id.to_string(), + module: module_id.clone(), + route: route.to_string(), exp: jsonwebtoken::get_current_timestamp() + SIGNER_JWT_EXPIRATION, + payload_hash: payload.map(keccak256), }, &jsonwebtoken::EncodingKey::from_secret(secret.as_ref()), ) @@ -357,36 +367,134 @@ pub fn create_jwt(module_id: &ModuleId, secret: &str) -> eyre::Result { .map(Jwt::from) } -/// Decode a JWT and return the module id. IMPORTANT: This function does not -/// validate the JWT, it only obtains the module id from the claims. -pub fn decode_jwt(jwt: Jwt) -> eyre::Result { +// Creates a JWT for module administration +pub fn create_admin_jwt( + admin_secret: String, + route: &str, + payload: Option<&[u8]>, +) -> eyre::Result { + jsonwebtoken::encode( + &jsonwebtoken::Header::default(), + &JwtAdminClaims { + admin: true, + route: route.to_string(), + exp: jsonwebtoken::get_current_timestamp() + SIGNER_JWT_EXPIRATION, + payload_hash: payload.map(keccak256), + }, + &jsonwebtoken::EncodingKey::from_secret(admin_secret.as_ref()), + ) + .map_err(Into::into) + .map(Jwt::from) +} + +/// Decode a JWT and return the JWT claims. IMPORTANT: This function does not +/// validate the JWT, it only obtains the claims. +pub fn decode_jwt(jwt: Jwt) -> eyre::Result { + let mut validation = jsonwebtoken::Validation::default(); + validation.insecure_disable_signature_validation(); + + let claims = jsonwebtoken::decode::( + jwt.as_str(), + &jsonwebtoken::DecodingKey::from_secret(&[]), + &validation, + )? + .claims; + + Ok(claims) +} + +/// Decode an administrator JWT and return the JWT claims. IMPORTANT: This +/// function does not validate the JWT, it only obtains the claims. +pub fn decode_admin_jwt(jwt: Jwt) -> eyre::Result { let mut validation = jsonwebtoken::Validation::default(); validation.insecure_disable_signature_validation(); - let module = jsonwebtoken::decode::( + let claims = jsonwebtoken::decode::( jwt.as_str(), &jsonwebtoken::DecodingKey::from_secret(&[]), &validation, )? - .claims - .module - .into(); + .claims; - Ok(module) + Ok(claims) } -/// Validate a JWT with the given secret -pub fn validate_jwt(jwt: Jwt, secret: &str) -> eyre::Result<()> { +pub fn validate_jwt( + jwt: Jwt, + secret: &str, + route: &str, + payload: Option<&[u8]>, +) -> eyre::Result<()> { let mut validation = jsonwebtoken::Validation::default(); validation.leeway = 10; - jsonwebtoken::decode::( + let claims = jsonwebtoken::decode::( jwt.as_str(), &jsonwebtoken::DecodingKey::from_secret(secret.as_ref()), &validation, - ) - .map(|_| ()) - .map_err(From::from) + )? + .claims; + + // Validate the route + if claims.route != route { + eyre::bail!("Token route does not match"); + } + + // Validate the payload hash if provided + if let Some(payload_bytes) = payload { + if let Some(expected_hash) = claims.payload_hash { + let actual_hash = keccak256(payload_bytes); + if actual_hash != expected_hash { + eyre::bail!("Payload hash does not match"); + } + } else { + eyre::bail!("JWT does not contain a payload hash"); + } + } else if claims.payload_hash.is_some() { + eyre::bail!("JWT contains a payload hash but no payload was provided"); + } + Ok(()) +} + +pub fn validate_admin_jwt( + jwt: Jwt, + secret: &str, + route: &str, + payload: Option<&[u8]>, +) -> eyre::Result<()> { + let mut validation = jsonwebtoken::Validation::default(); + validation.leeway = 10; + + let claims = jsonwebtoken::decode::( + jwt.as_str(), + &jsonwebtoken::DecodingKey::from_secret(secret.as_ref()), + &validation, + )? + .claims; + + if !claims.admin { + eyre::bail!("Token is not admin") + } + + // Validate the route + if claims.route != route { + eyre::bail!("Token route does not match"); + } + + // Validate the payload hash if provided + if let Some(payload_bytes) = payload { + if let Some(expected_hash) = claims.payload_hash { + let actual_hash = keccak256(payload_bytes); + if actual_hash != expected_hash { + eyre::bail!("Payload hash does not match"); + } + } else { + eyre::bail!("JWT does not contain a payload hash"); + } + } else if claims.payload_hash.is_some() { + eyre::bail!("JWT contains a payload hash but no payload was provided"); + } + Ok(()) } /// Generates a random string @@ -460,28 +568,219 @@ pub fn bls_pubkey_from_hex_unchecked(hex: &str) -> BlsPublicKey { #[cfg(test)] mod test { - use super::{create_jwt, decode_jwt, validate_jwt}; - use crate::types::{Jwt, ModuleId}; + use alloy::primitives::keccak256; + + use super::{ + create_admin_jwt, create_jwt, decode_admin_jwt, decode_jwt, random_jwt_secret, + validate_admin_jwt, validate_jwt, + }; + use crate::{ + constants::SIGNER_JWT_EXPIRATION, + types::{Jwt, JwtAdminClaims, ModuleId}, + }; + + #[test] + fn test_jwt_validation_no_payload_hash() { + // Check valid JWT + let jwt = + create_jwt(&ModuleId("DA_COMMIT".to_string()), "secret", "/test/route", None).unwrap(); + let claims = decode_jwt(jwt.clone()).unwrap(); + let module_id = claims.module; + let payload_hash = claims.payload_hash; + assert_eq!(module_id, ModuleId("DA_COMMIT".to_string())); + assert!(payload_hash.is_none()); + let response = validate_jwt(jwt, "secret", "/test/route", None); + assert!(response.is_ok()); + + // Check expired JWT + let expired_jwt = Jwt::from("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3NTgyOTkxNzIsIm1vZHVsZSI6IkRBX0NPTU1JVCIsInJvdXRlIjoiL3Rlc3Qvcm91dGUiLCJwYXlsb2FkX2hhc2giOm51bGx9._OBsNC67KLkk6f6ZQ2_CDbhYUJ2OtZ9egKAmi1L-ymA".to_string()); + let response = validate_jwt(expired_jwt, "secret", "/test/route", None); + assert!(response.is_err()); + assert_eq!(response.unwrap_err().to_string(), "ExpiredSignature"); + + // Check invalid signature JWT + let invalid_jwt = Jwt::from("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3NTgyOTkxMzQsIm1vZHVsZSI6IkRBX0NPTU1JVCIsInJvdXRlIjoiL3Rlc3Qvcm91dGUiLCJwYXlsb2FkX2hhc2giOm51bGx9.58QXayg2XeX5lXhIPw-a8kl04DWBEj5wBsqsedTeClo".to_string()); + let response = validate_jwt(invalid_jwt, "secret", "/test/route", None); + assert!(response.is_err()); + assert_eq!(response.unwrap_err().to_string(), "InvalidSignature"); + } #[test] - fn test_jwt_validation() { + fn test_jwt_validation_with_payload() { + // Pretend payload + let payload = serde_json::json!({ + "data": "test" + }); + let payload_bytes = serde_json::to_vec(&payload).unwrap(); + // Check valid JWT - let jwt = create_jwt(&ModuleId("DA_COMMIT".to_string()), "secret").unwrap(); - let module_id = decode_jwt(jwt.clone()).unwrap(); + let jwt = create_jwt( + &ModuleId("DA_COMMIT".to_string()), + "secret", + "/test/route", + Some(&payload_bytes), + ) + .unwrap(); + let claims = decode_jwt(jwt.clone()).unwrap(); + let module_id = claims.module; + let payload_hash = claims.payload_hash; assert_eq!(module_id, ModuleId("DA_COMMIT".to_string())); - let response = validate_jwt(jwt, "secret"); + assert_eq!(payload_hash, Some(keccak256(&payload_bytes))); + let response = validate_jwt(jwt, "secret", "/test/route", Some(&payload_bytes)); assert!(response.is_ok()); // Check expired JWT - let expired_jwt = Jwt::from("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3NDI5OTU5NDYsIm1vZHVsZSI6IkRBX0NPTU1JVCJ9.iiq4Z2ed2hk3c3c-cn2QOQJWE5XUOc5BoaIPT-I8q-s".to_string()); - let response = validate_jwt(expired_jwt, "secret"); + let expired_jwt = Jwt::from("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3NTgyOTgzNDQsIm1vZHVsZSI6IkRBX0NPTU1JVCIsInJvdXRlIjoiL3Rlc3Qvcm91dGUiLCJwYXlsb2FkX2hhc2giOiIweGFmODk2MjY0MzUzNTFmYzIwMDBkYmEwM2JiNTlhYjcyZWE0ODJiOWEwMDBmZWQzNmNkMjBlMDU0YjE2NjZmZjEifQ.PYrSxLXadKBgYZlmLam8RBSL32I1T_zAxlZpG6xnnII".to_string()); + let response = validate_jwt(expired_jwt, "secret", "/test/route", Some(&payload_bytes)); assert!(response.is_err()); assert_eq!(response.unwrap_err().to_string(), "ExpiredSignature"); // Check invalid signature JWT - let invalid_jwt = Jwt::from("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3NDI5OTU5NDYsIm1vZHVsZSI6IkRBX0NPTU1JVCJ9.w9WYdDNzgDjYTvjBkk4GGzywGNBYPxnzU2uJWzPUT1s".to_string()); - let response = validate_jwt(invalid_jwt, "secret"); + let invalid_jwt = Jwt::from("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3NTgyOTkwMDAsIm1vZHVsZSI6IkRBX0NPTU1JVCIsInJvdXRlIjoiL3Rlc3Qvcm91dGUiLCJwYXlsb2FkX2hhc2giOiIweGFmODk2MjY0MzUzNTFmYzIwMDBkYmEwM2JiNTlhYjcyZWE0ODJiOWEwMDBmZWQzNmNkMjBlMDU0YjE2NjZmZjEifQ.mnC-AexkLlR9l98SJbln3DmV6r9XyHYdbjcUVcWdi_8".to_string()); + let response = validate_jwt(invalid_jwt, "secret", "/test/route", Some(&payload_bytes)); assert!(response.is_err()); assert_eq!(response.unwrap_err().to_string(), "InvalidSignature"); } + + // ── validate_jwt: route and secret errors ──────────────────────────────── + + #[test] + fn test_validate_jwt_wrong_route() { + let jwt = create_jwt(&ModuleId("MOD".into()), "secret", "/correct/route", None).unwrap(); + let err = validate_jwt(jwt, "secret", "/wrong/route", None).unwrap_err(); + assert!(err.to_string().contains("Token route does not match")); + } + + #[test] + fn test_validate_jwt_wrong_secret() { + let jwt = create_jwt(&ModuleId("MOD".into()), "correct_secret", "/route", None).unwrap(); + let err = validate_jwt(jwt, "wrong_secret", "/route", None).unwrap_err(); + assert_eq!(err.to_string(), "InvalidSignature"); + } + + // ── validate_jwt: payload hash mismatch branches ───────────────────────── + + #[test] + fn test_validate_jwt_payload_hash_mismatch() { + let payload_a = b"payload_a"; + let payload_b = b"payload_b"; + let jwt = create_jwt(&ModuleId("MOD".into()), "secret", "/route", Some(payload_a)).unwrap(); + let err = validate_jwt(jwt, "secret", "/route", Some(payload_b)).unwrap_err(); + assert!(err.to_string().contains("Payload hash does not match")); + } + + #[test] + fn test_validate_jwt_hash_present_but_no_payload_provided() { + let payload = b"some payload"; + let jwt = create_jwt(&ModuleId("MOD".into()), "secret", "/route", Some(payload)).unwrap(); + let err = validate_jwt(jwt, "secret", "/route", None).unwrap_err(); + assert!( + err.to_string().contains("JWT contains a payload hash but no payload was provided") + ); + } + + #[test] + fn test_validate_jwt_no_hash_but_payload_provided() { + let jwt = create_jwt(&ModuleId("MOD".into()), "secret", "/route", None).unwrap(); + let err = validate_jwt(jwt, "secret", "/route", Some(b"unexpected")).unwrap_err(); + assert!(err.to_string().contains("JWT does not contain a payload hash")); + } + + // ── admin JWT roundtrip ────────────────────────────────────────────────── + + #[test] + fn test_admin_jwt_roundtrip_no_payload() { + let jwt = create_admin_jwt("admin_secret".into(), "/admin/route", None).unwrap(); + let claims = decode_admin_jwt(jwt.clone()).unwrap(); + assert!(claims.admin); + assert_eq!(claims.route, "/admin/route"); + assert!(claims.payload_hash.is_none()); + validate_admin_jwt(jwt, "admin_secret", "/admin/route", None).unwrap(); + } + + #[test] + fn test_admin_jwt_roundtrip_with_payload() { + let payload = b"admin payload"; + let jwt = create_admin_jwt("admin_secret".into(), "/admin/route", Some(payload)).unwrap(); + let claims = decode_admin_jwt(jwt.clone()).unwrap(); + assert!(claims.admin); + assert_eq!(claims.payload_hash, Some(keccak256(payload))); + validate_admin_jwt(jwt, "admin_secret", "/admin/route", Some(payload)).unwrap(); + } + + // ── validate_admin_jwt: route, secret, admin flag errors ───────────────── + + #[test] + fn test_validate_admin_jwt_wrong_route() { + let jwt = create_admin_jwt("admin_secret".into(), "/correct/route", None).unwrap(); + let err = validate_admin_jwt(jwt, "admin_secret", "/wrong/route", None).unwrap_err(); + assert!(err.to_string().contains("Token route does not match")); + } + + #[test] + fn test_validate_admin_jwt_wrong_secret() { + let jwt = create_admin_jwt("correct_secret".into(), "/route", None).unwrap(); + let err = validate_admin_jwt(jwt, "wrong_secret", "/route", None).unwrap_err(); + assert_eq!(err.to_string(), "InvalidSignature"); + } + + #[test] + fn test_validate_admin_jwt_admin_false() { + // Craft a JWT whose claims have admin: false — something create_admin_jwt + // never produces — to exercise the explicit admin flag guard. + let claims = JwtAdminClaims { + admin: false, + route: "/route".into(), + exp: jsonwebtoken::get_current_timestamp() + SIGNER_JWT_EXPIRATION, + payload_hash: None, + }; + let token = jsonwebtoken::encode( + &jsonwebtoken::Header::default(), + &claims, + &jsonwebtoken::EncodingKey::from_secret(b"secret"), + ) + .unwrap(); + let jwt = Jwt::from(token); + let err = validate_admin_jwt(jwt, "secret", "/route", None).unwrap_err(); + assert!(err.to_string().contains("Token is not admin")); + } + + // ── validate_admin_jwt: payload hash mismatch branches ─────────────────── + + #[test] + fn test_validate_admin_jwt_payload_hash_mismatch() { + let payload_a = b"admin_payload_a"; + let payload_b = b"admin_payload_b"; + let jwt = create_admin_jwt("secret".into(), "/route", Some(payload_a)).unwrap(); + let err = validate_admin_jwt(jwt, "secret", "/route", Some(payload_b)).unwrap_err(); + assert!(err.to_string().contains("Payload hash does not match")); + } + + #[test] + fn test_validate_admin_jwt_hash_present_but_no_payload_provided() { + let payload = b"admin payload"; + let jwt = create_admin_jwt("secret".into(), "/route", Some(payload)).unwrap(); + let err = validate_admin_jwt(jwt, "secret", "/route", None).unwrap_err(); + assert!( + err.to_string().contains("JWT contains a payload hash but no payload was provided") + ); + } + + #[test] + fn test_validate_admin_jwt_no_hash_but_payload_provided() { + let jwt = create_admin_jwt("secret".into(), "/route", None).unwrap(); + let err = validate_admin_jwt(jwt, "secret", "/route", Some(b"unexpected")).unwrap_err(); + assert!(err.to_string().contains("JWT does not contain a payload hash")); + } + + // ── random_jwt_secret ──────────────────────────────────────────────────── + + #[test] + fn test_random_jwt_secret() { + let secret = random_jwt_secret(); + assert_eq!(secret.len(), 32); + assert!(secret.chars().all(|c| c.is_ascii_alphanumeric())); + // Two calls should produce distinct values with overwhelming probability. + assert_ne!(secret, random_jwt_secret()); + } } diff --git a/crates/pbs/src/mev_boost/get_header.rs b/crates/pbs/src/mev_boost/get_header.rs index 08275a76..c144e2c0 100644 --- a/crates/pbs/src/mev_boost/get_header.rs +++ b/crates/pbs/src/mev_boost/get_header.rs @@ -4,7 +4,7 @@ use std::{ }; use alloy::{ - primitives::{B256, U256, utils::format_ether}, + primitives::{B256, U256, aliases::B32, utils::format_ether}, providers::Provider, rpc::types::Block, }; @@ -533,7 +533,8 @@ fn validate_signature( expected_relay_pubkey, &message, signature, - APPLICATION_BUILDER_DOMAIN, + None, + &B32::from(APPLICATION_BUILDER_DOMAIN), ) { return Err(ValidationError::Sigverify); } diff --git a/crates/signer/Cargo.toml b/crates/signer/Cargo.toml index 569797ac..7c6e63fa 100644 --- a/crates/signer/Cargo.toml +++ b/crates/signer/Cargo.toml @@ -9,6 +9,7 @@ version.workspace = true alloy.workspace = true axum.workspace = true axum-extra.workspace = true +axum-server.workspace = true bimap.workspace = true blsful.workspace = true cb-common.workspace = true @@ -22,6 +23,7 @@ parking_lot.workspace = true prometheus.workspace = true prost.workspace = true rand.workspace = true +rustls.workspace = true thiserror.workspace = true tokio.workspace = true tonic.workspace = true diff --git a/crates/signer/src/constants.rs b/crates/signer/src/constants.rs index 268cd2e2..e5884d27 100644 --- a/crates/signer/src/constants.rs +++ b/crates/signer/src/constants.rs @@ -1,3 +1,5 @@ pub const GET_PUBKEYS_ENDPOINT_TAG: &str = "get_pubkeys"; pub const GENERATE_PROXY_KEY_ENDPOINT_TAG: &str = "generate_proxy_key"; -pub const REQUEST_SIGNATURE_ENDPOINT_TAG: &str = "request_signature"; +pub const REQUEST_SIGNATURE_BLS_ENDPOINT_TAG: &str = "request_signature_bls"; +pub const REQUEST_SIGNATURE_PROXY_BLS_ENDPOINT_TAG: &str = "request_signature_proxy_bls"; +pub const REQUEST_SIGNATURE_PROXY_ECDSA_ENDPOINT_TAG: &str = "request_signature_proxy_ecdsa"; diff --git a/crates/signer/src/error.rs b/crates/signer/src/error.rs index a2a113f3..64a3e5b8 100644 --- a/crates/signer/src/error.rs +++ b/crates/signer/src/error.rs @@ -25,11 +25,17 @@ pub enum SignerModuleError { #[error("Dirk signer does not support this operation")] DirkNotSupported, + #[error("module id not found")] + ModuleIdNotFound, + #[error("internal error: {0}")] Internal(String), #[error("rate limited for {0} more seconds")] RateLimited(f64), + + #[error("request error: {0}")] + RequestError(String), } impl IntoResponse for SignerModuleError { @@ -48,9 +54,13 @@ impl IntoResponse for SignerModuleError { (StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_string()) } SignerModuleError::SignerError(err) => (StatusCode::BAD_REQUEST, err.to_string()), + SignerModuleError::ModuleIdNotFound => (StatusCode::NOT_FOUND, self.to_string()), SignerModuleError::RateLimited(duration) => { (StatusCode::TOO_MANY_REQUESTS, format!("rate limited for {duration:?}")) } + SignerModuleError::RequestError(err) => { + (StatusCode::BAD_REQUEST, format!("bad request: {err}")) + } } .into_response() } diff --git a/crates/signer/src/lib.rs b/crates/signer/src/lib.rs index 4b5e1451..b4b9ecc4 100644 --- a/crates/signer/src/lib.rs +++ b/crates/signer/src/lib.rs @@ -4,3 +4,4 @@ pub mod manager; mod metrics; mod proto; pub mod service; +mod utils; diff --git a/crates/signer/src/manager/dirk.rs b/crates/signer/src/manager/dirk.rs index fe438ef0..45dcc733 100644 --- a/crates/signer/src/manager/dirk.rs +++ b/crates/signer/src/manager/dirk.rs @@ -1,6 +1,9 @@ use std::{collections::HashMap, io::Write, path::PathBuf}; -use alloy::{hex, primitives::B256}; +use alloy::{ + hex, + primitives::{B256, aliases::B32}, +}; use blsful::inner_types::{Field, G2Affine, G2Projective, Group, Scalar}; use cb_common::{ commit::request::{ConsensusProxyMap, ProxyDelegation, SignedProxyDelegation}, @@ -8,7 +11,7 @@ use cb_common::{ constants::COMMIT_BOOST_DOMAIN, signature::compute_domain, signer::ProxyStore, - types::{BlsPublicKey, BlsSignature, Chain, ModuleId}, + types::{self, BlsPublicKey, BlsSignature, Chain, ModuleId, SignatureRequestInfo}, }; use eyre::{OptionExt, bail}; use futures::{FutureExt, StreamExt, future::join_all, stream::FuturesUnordered}; @@ -151,6 +154,11 @@ impl DirkManager { }) } + /// Get the chain config for the manager + pub fn get_chain(&self) -> Chain { + self.chain + } + /// Set the proxy store to use for storing proxy delegations pub fn with_proxy_store(self, store: ProxyStore) -> eyre::Result { if let ProxyStore::ERC2335 { .. } = store { @@ -199,14 +207,16 @@ impl DirkManager { pub async fn request_consensus_signature( &self, pubkey: &BlsPublicKey, - object_root: B256, + object_root: &B256, + signature_request_info: Option<&SignatureRequestInfo>, ) -> Result { match self.consensus_accounts.get(pubkey) { Some(Account::Simple(account)) => { - self.request_simple_signature(account, object_root).await + self.request_simple_signature(account, object_root, signature_request_info).await } Some(Account::Distributed(account)) => { - self.request_distributed_signature(account, object_root).await + self.request_distributed_signature(account, object_root, signature_request_info) + .await } None => Err(SignerModuleError::UnknownConsensusSigner(pubkey.serialize().to_vec())), } @@ -216,14 +226,16 @@ impl DirkManager { pub async fn request_proxy_signature( &self, pubkey: &BlsPublicKey, - object_root: B256, + object_root: &B256, + signature_request_info: Option<&SignatureRequestInfo>, ) -> Result { match self.proxy_accounts.get(pubkey) { Some(ProxyAccount { inner: Account::Simple(account), .. }) => { - self.request_simple_signature(account, object_root).await + self.request_simple_signature(account, object_root, signature_request_info).await } Some(ProxyAccount { inner: Account::Distributed(account), .. }) => { - self.request_distributed_signature(account, object_root).await + self.request_distributed_signature(account, object_root, signature_request_info) + .await } None => Err(SignerModuleError::UnknownProxySigner(pubkey.serialize().to_vec())), } @@ -233,13 +245,28 @@ impl DirkManager { async fn request_simple_signature( &self, account: &SimpleAccount, - object_root: B256, + object_root: &B256, + signature_request_info: Option<&SignatureRequestInfo>, ) -> Result { - let domain = compute_domain(self.chain, COMMIT_BOOST_DOMAIN); + let domain = compute_domain(self.chain, &B32::from(COMMIT_BOOST_DOMAIN)); + + let data = match signature_request_info { + Some(SignatureRequestInfo { module_signing_id, nonce }) => { + types::PropCommitSigningInfo { + data: *object_root, + module_signing_id: *module_signing_id, + nonce: *nonce, + chain_id: self.chain.id(), + } + .tree_hash_root() + .to_vec() + } + None => object_root.to_vec(), + }; let response = SignerClient::new(account.connection.clone()) .sign(SignRequest { - data: object_root.to_vec(), + data, domain: domain.to_vec(), id: Some(sign_request::Id::PublicKey(account.public_key.serialize().to_vec())), }) @@ -263,17 +290,34 @@ impl DirkManager { async fn request_distributed_signature( &self, account: &DistributedAccount, - object_root: B256, + object_root: &B256, + signature_request_info: Option<&SignatureRequestInfo>, ) -> Result { let mut partials = Vec::with_capacity(account.participants.len()); let mut requests = Vec::with_capacity(account.participants.len()); + let data = match signature_request_info { + Some(SignatureRequestInfo { module_signing_id, nonce }) => { + types::PropCommitSigningInfo { + data: *object_root, + module_signing_id: *module_signing_id, + nonce: *nonce, + chain_id: self.chain.id(), + } + .tree_hash_root() + .to_vec() + } + None => object_root.to_vec(), + }; + for (id, channel) in account.participants.iter() { + let data_copy = data.clone(); let request = async move { SignerClient::new(channel.clone()) .sign(SignRequest { - data: object_root.to_vec(), - domain: compute_domain(self.chain, COMMIT_BOOST_DOMAIN).to_vec(), + data: data_copy, + domain: compute_domain(self.chain, &B32::from(COMMIT_BOOST_DOMAIN)) + .to_vec(), id: Some(sign_request::Id::Account(account.name.clone())), }) .map(|res| (res, *id)) @@ -328,9 +372,9 @@ impl DirkManager { pub async fn generate_proxy_key( &mut self, module: &ModuleId, - consensus: BlsPublicKey, + consensus: &BlsPublicKey, ) -> Result, SignerModuleError> { - let proxy_account = match self.consensus_accounts.get(&consensus) { + let proxy_account = match self.consensus_accounts.get(consensus) { Some(Account::Simple(account)) => { self.generate_simple_proxy_account(account, module).await? } @@ -349,7 +393,7 @@ impl DirkManager { proxy: proxy_account.inner.public_key().clone(), }; let delegation_signature = - self.request_consensus_signature(&consensus, message.tree_hash_root()).await?; + self.request_consensus_signature(consensus, &message.tree_hash_root(), None).await?; let delegation = SignedProxyDelegation { message, signature: delegation_signature }; diff --git a/crates/signer/src/manager/local.rs b/crates/signer/src/manager/local.rs index 17832fdf..fc2eabae 100644 --- a/crates/signer/src/manager/local.rs +++ b/crates/signer/src/manager/local.rs @@ -3,14 +3,14 @@ use std::collections::HashMap; use alloy::primitives::{Address, B256}; use cb_common::{ commit::request::{ - ConsensusProxyMap, ProxyDelegationBls, ProxyDelegationEcdsa, ProxyId, - SignedProxyDelegationBls, SignedProxyDelegationEcdsa, + ConsensusProxyMap, ProxyDelegationBls, ProxyDelegationEcdsa, SignedProxyDelegationBls, + SignedProxyDelegationEcdsa, }, signer::{ BlsProxySigner, BlsSigner, ConsensusSigner, EcdsaProxySigner, EcdsaSignature, EcdsaSigner, ProxySigners, ProxyStore, }, - types::{BlsPublicKey, BlsSignature, Chain, ModuleId}, + types::{BlsPublicKey, BlsSignature, Chain, ModuleId, SignatureRequestInfo}, }; use tree_hash::TreeHash; @@ -50,6 +50,11 @@ impl LocalSigningManager { Ok(manager) } + /// Get the chain config for the manager + pub fn get_chain(&self) -> Chain { + self.chain + } + pub fn add_consensus_signer(&mut self, signer: ConsensusSigner) { self.consensus_signers.insert(signer.pubkey(), signer); } @@ -89,13 +94,13 @@ impl LocalSigningManager { pub async fn create_proxy_bls( &mut self, module_id: ModuleId, - delegator: BlsPublicKey, + delegator: &BlsPublicKey, ) -> Result { let signer = BlsSigner::new_random(); let proxy_pubkey = signer.pubkey(); let message = ProxyDelegationBls { delegator: delegator.clone(), proxy: proxy_pubkey }; - let signature = self.sign_consensus(&delegator, message.tree_hash_root()).await?; + let signature = self.sign_consensus(delegator, &message.tree_hash_root(), None).await?; let delegation = SignedProxyDelegationBls { signature, message }; let proxy_signer = BlsProxySigner { signer, delegation: delegation.clone() }; @@ -108,13 +113,13 @@ impl LocalSigningManager { pub async fn create_proxy_ecdsa( &mut self, module_id: ModuleId, - delegator: BlsPublicKey, + delegator: &BlsPublicKey, ) -> Result { let signer = EcdsaSigner::new_random(); let proxy_address = signer.address(); let message = ProxyDelegationEcdsa { delegator: delegator.clone(), proxy: proxy_address }; - let signature = self.sign_consensus(&delegator, message.tree_hash_root()).await?; + let signature = self.sign_consensus(delegator, &message.tree_hash_root(), None).await?; let delegation = SignedProxyDelegationEcdsa { signature, message }; let proxy_signer = EcdsaProxySigner { signer, delegation: delegation.clone() }; @@ -129,13 +134,14 @@ impl LocalSigningManager { pub async fn sign_consensus( &self, pubkey: &BlsPublicKey, - object_root: B256, + object_root: &B256, + signature_request_info: Option<&SignatureRequestInfo>, ) -> Result { let signer = self .consensus_signers .get(pubkey) - .ok_or(SignerModuleError::UnknownConsensusSigner(pubkey.to_bytes()))?; - let signature = signer.sign(self.chain, object_root).await; + .ok_or(SignerModuleError::UnknownConsensusSigner(pubkey.serialize().to_vec()))?; + let signature = signer.sign(self.chain, object_root, signature_request_info).await; Ok(signature) } @@ -143,28 +149,30 @@ impl LocalSigningManager { pub async fn sign_proxy_bls( &self, pubkey: &BlsPublicKey, - object_root: B256, + object_root: &B256, + signature_request_info: Option<&SignatureRequestInfo>, ) -> Result { let bls_proxy = self .proxy_signers .bls_signers .get(pubkey) .ok_or(SignerModuleError::UnknownProxySigner(pubkey.serialize().to_vec()))?; - let signature = bls_proxy.sign(self.chain, object_root).await; + let signature = bls_proxy.sign(self.chain, object_root, signature_request_info).await; Ok(signature) } pub async fn sign_proxy_ecdsa( &self, address: &Address, - object_root: B256, + object_root: &B256, + signature_request_info: Option<&SignatureRequestInfo>, ) -> Result { let ecdsa_proxy = self .proxy_signers .ecdsa_signers .get(address) .ok_or(SignerModuleError::UnknownProxySigner(address.to_vec()))?; - let signature = ecdsa_proxy.sign(self.chain, object_root).await?; + let signature = ecdsa_proxy.sign(self.chain, object_root, signature_request_info).await?; Ok(signature) } @@ -265,7 +273,6 @@ impl LocalSigningManager { #[cfg(test)] mod tests { use alloy::primitives::B256; - use cb_common::signature::compute_signing_root; use lazy_static::lazy_static; use super::*; @@ -287,10 +294,54 @@ mod tests { (signing_manager, consensus_pk) } + mod test_bls { + use alloy::primitives::aliases::B32; + use cb_common::{ + constants::COMMIT_BOOST_DOMAIN, signature::compute_domain, + signer::verify_bls_signature, types, + }; + + use super::*; + + #[tokio::test] + async fn test_key_signs_message() { + let (signing_manager, consensus_pk) = init_signing_manager(); + + let data_root = B256::random(); + let module_signing_id = B256::random(); + let nonce = 43; + + let sig = signing_manager + .sign_consensus( + &consensus_pk, + &data_root, + Some(&SignatureRequestInfo { module_signing_id, nonce }), + ) + .await + .unwrap(); + + // Verify signature + let signing_domain = compute_domain(CHAIN, &B32::from(COMMIT_BOOST_DOMAIN)); + let object_root = types::PropCommitSigningInfo { + data: data_root.tree_hash_root(), + module_signing_id, + nonce, + chain_id: CHAIN.id(), + } + .tree_hash_root(); + let signing_root = types::SigningData { object_root, signing_domain }.tree_hash_root(); + + let validation_result = verify_bls_signature(&consensus_pk, signing_root, &sig); + + assert!(validation_result, "Keypair must produce valid signatures of messages.") + } + } + mod test_proxy_bls { + use alloy::primitives::aliases::B32; use cb_common::{ constants::COMMIT_BOOST_DOMAIN, signature::compute_domain, - signer::verify_bls_signature, utils::TestRandomSeed, + signer::verify_bls_signature, types, utils::TestRandomSeed, }; use super::*; @@ -300,7 +351,7 @@ mod tests { let (mut signing_manager, consensus_pk) = init_signing_manager(); let signed_delegation = - signing_manager.create_proxy_bls(MODULE_ID.clone(), consensus_pk).await.unwrap(); + signing_manager.create_proxy_bls(MODULE_ID.clone(), &consensus_pk).await.unwrap(); let validation_result = signed_delegation.validate(CHAIN); @@ -321,7 +372,7 @@ mod tests { let (mut signing_manager, consensus_pk) = init_signing_manager(); let mut signed_delegation = - signing_manager.create_proxy_bls(MODULE_ID.clone(), consensus_pk).await.unwrap(); + signing_manager.create_proxy_bls(MODULE_ID.clone(), &consensus_pk).await.unwrap(); signed_delegation.signature = BlsSignature::test_random(); @@ -335,16 +386,32 @@ mod tests { let (mut signing_manager, consensus_pk) = init_signing_manager(); let signed_delegation = - signing_manager.create_proxy_bls(MODULE_ID.clone(), consensus_pk).await.unwrap(); + signing_manager.create_proxy_bls(MODULE_ID.clone(), &consensus_pk).await.unwrap(); let proxy_pk = signed_delegation.message.proxy; let data_root = B256::random(); - - let sig = signing_manager.sign_proxy_bls(&proxy_pk, data_root).await.unwrap(); + let module_signing_id = B256::random(); + let nonce = 44; + + let sig = signing_manager + .sign_proxy_bls( + &proxy_pk, + &data_root, + Some(&SignatureRequestInfo { module_signing_id, nonce }), + ) + .await + .unwrap(); // Verify signature - let domain = compute_domain(CHAIN, COMMIT_BOOST_DOMAIN); - let signing_root = compute_signing_root(data_root.tree_hash_root(), domain); + let signing_domain = compute_domain(CHAIN, &B32::from(COMMIT_BOOST_DOMAIN)); + let object_root = types::PropCommitSigningInfo { + data: data_root.tree_hash_root(), + module_signing_id, + nonce, + chain_id: CHAIN.id(), + } + .tree_hash_root(); + let signing_root = types::SigningData { object_root, signing_domain }.tree_hash_root(); let validation_result = verify_bls_signature(&proxy_pk, signing_root, &sig); @@ -353,9 +420,10 @@ mod tests { } mod test_proxy_ecdsa { + use alloy::primitives::aliases::B32; use cb_common::{ constants::COMMIT_BOOST_DOMAIN, signature::compute_domain, - signer::verify_ecdsa_signature, utils::TestRandomSeed, + signer::verify_ecdsa_signature, types, utils::TestRandomSeed, }; use super::*; @@ -365,7 +433,7 @@ mod tests { let (mut signing_manager, consensus_pk) = init_signing_manager(); let signed_delegation = - signing_manager.create_proxy_ecdsa(MODULE_ID.clone(), consensus_pk).await.unwrap(); + signing_manager.create_proxy_ecdsa(MODULE_ID.clone(), &consensus_pk).await.unwrap(); let validation_result = signed_delegation.validate(CHAIN); @@ -386,7 +454,7 @@ mod tests { let (mut signing_manager, consensus_pk) = init_signing_manager(); let mut signed_delegation = - signing_manager.create_proxy_ecdsa(MODULE_ID.clone(), consensus_pk).await.unwrap(); + signing_manager.create_proxy_ecdsa(MODULE_ID.clone(), &consensus_pk).await.unwrap(); signed_delegation.signature = BlsSignature::test_random(); @@ -400,16 +468,32 @@ mod tests { let (mut signing_manager, consensus_pk) = init_signing_manager(); let signed_delegation = - signing_manager.create_proxy_ecdsa(MODULE_ID.clone(), consensus_pk).await.unwrap(); + signing_manager.create_proxy_ecdsa(MODULE_ID.clone(), &consensus_pk).await.unwrap(); let proxy_pk = signed_delegation.message.proxy; let data_root = B256::random(); - - let sig = signing_manager.sign_proxy_ecdsa(&proxy_pk, data_root).await.unwrap(); + let module_signing_id = B256::random(); + let nonce = 45; + + let sig = signing_manager + .sign_proxy_ecdsa( + &proxy_pk, + &data_root, + Some(&SignatureRequestInfo { module_signing_id, nonce }), + ) + .await + .unwrap(); // Verify signature - let domain = compute_domain(CHAIN, COMMIT_BOOST_DOMAIN); - let signing_root = compute_signing_root(data_root.tree_hash_root(), domain); + let signing_domain = compute_domain(CHAIN, &B32::from(COMMIT_BOOST_DOMAIN)); + let object_root = types::PropCommitSigningInfo { + data: data_root.tree_hash_root(), + module_signing_id, + nonce, + chain_id: CHAIN.id(), + } + .tree_hash_root(); + let signing_root = types::SigningData { object_root, signing_domain }.tree_hash_root(); let validation_result = verify_ecdsa_signature(&proxy_pk, &signing_root, &sig); diff --git a/crates/signer/src/metrics.rs b/crates/signer/src/metrics.rs index beaeefce..4110ec72 100644 --- a/crates/signer/src/metrics.rs +++ b/crates/signer/src/metrics.rs @@ -2,13 +2,15 @@ use axum::http::Uri; use cb_common::commit::constants::{ - GENERATE_PROXY_KEY_PATH, GET_PUBKEYS_PATH, REQUEST_SIGNATURE_PATH, + GENERATE_PROXY_KEY_PATH, GET_PUBKEYS_PATH, REQUEST_SIGNATURE_BLS_PATH, + REQUEST_SIGNATURE_PROXY_BLS_PATH, REQUEST_SIGNATURE_PROXY_ECDSA_PATH, }; use lazy_static::lazy_static; use prometheus::{IntCounterVec, Registry, register_int_counter_vec_with_registry}; use crate::constants::{ - GENERATE_PROXY_KEY_ENDPOINT_TAG, GET_PUBKEYS_ENDPOINT_TAG, REQUEST_SIGNATURE_ENDPOINT_TAG, + GENERATE_PROXY_KEY_ENDPOINT_TAG, GET_PUBKEYS_ENDPOINT_TAG, REQUEST_SIGNATURE_BLS_ENDPOINT_TAG, + REQUEST_SIGNATURE_PROXY_BLS_ENDPOINT_TAG, REQUEST_SIGNATURE_PROXY_ECDSA_ENDPOINT_TAG, }; lazy_static! { @@ -28,7 +30,9 @@ pub fn uri_to_tag(uri: &Uri) -> &str { match uri.path() { GET_PUBKEYS_PATH => GET_PUBKEYS_ENDPOINT_TAG, GENERATE_PROXY_KEY_PATH => GENERATE_PROXY_KEY_ENDPOINT_TAG, - REQUEST_SIGNATURE_PATH => REQUEST_SIGNATURE_ENDPOINT_TAG, + REQUEST_SIGNATURE_BLS_PATH => REQUEST_SIGNATURE_BLS_ENDPOINT_TAG, + REQUEST_SIGNATURE_PROXY_BLS_PATH => REQUEST_SIGNATURE_PROXY_BLS_ENDPOINT_TAG, + REQUEST_SIGNATURE_PROXY_ECDSA_PATH => REQUEST_SIGNATURE_PROXY_ECDSA_ENDPOINT_TAG, _ => "unknown endpoint", } } diff --git a/crates/signer/src/service.rs b/crates/signer/src/service.rs index af96c51e..b3a3045b 100644 --- a/crates/signer/src/service.rs +++ b/crates/signer/src/service.rs @@ -5,45 +5,53 @@ use std::{ time::{Duration, Instant}, }; +use alloy::primitives::{Address, B256, U256}; use axum::{ Extension, Json, + body::{Body, to_bytes}, extract::{ConnectInfo, Request, State}, - http::StatusCode, + http::{HeaderMap, StatusCode}, middleware::{self, Next}, response::{IntoResponse, Response}, routing::{get, post}, }; use axum_extra::TypedHeader; +use axum_server::tls_rustls::RustlsConfig; use cb_common::{ commit::{ constants::{ - GENERATE_PROXY_KEY_PATH, GET_PUBKEYS_PATH, RELOAD_PATH, REQUEST_SIGNATURE_PATH, - STATUS_PATH, + GENERATE_PROXY_KEY_PATH, GET_PUBKEYS_PATH, RELOAD_PATH, REQUEST_SIGNATURE_BLS_PATH, + REQUEST_SIGNATURE_PROXY_BLS_PATH, REQUEST_SIGNATURE_PROXY_ECDSA_PATH, + REVOKE_MODULE_PATH, STATUS_PATH, }, request::{ - EncryptionScheme, GenerateProxyRequest, GetPubkeysResponse, SignConsensusRequest, - SignProxyRequest, SignRequest, + EncryptionScheme, GenerateProxyRequest, GetPubkeysResponse, ReloadRequest, + RevokeModuleRequest, SignConsensusRequest, SignProxyRequest, }, + response::{BlsSignResponse, EcdsaSignResponse}, }, - config::StartSignerConfig, + config::{ModuleSigningConfig, ReverseProxyHeaderSetup, StartSignerConfig}, constants::{COMMIT_BOOST_COMMIT, COMMIT_BOOST_VERSION}, - types::{Chain, Jwt, ModuleId}, - utils::{decode_jwt, validate_jwt}, + types::{BlsPublicKey, Chain, Jwt, ModuleId, SignatureRequestInfo}, + utils::{decode_jwt, validate_admin_jwt, validate_jwt}, }; use cb_metrics::provider::MetricsProvider; use eyre::Context; use headers::{Authorization, authorization::Bearer}; use parking_lot::RwLock as ParkingRwLock; -use tokio::{net::TcpListener, sync::RwLock}; +use rustls::crypto::{CryptoProvider, aws_lc_rs}; +use tokio::sync::RwLock; use tracing::{debug, error, info, warn}; -use uuid::Uuid; use crate::{ error::SignerModuleError, manager::{SigningManager, dirk::DirkManager, local::LocalSigningManager}, metrics::{SIGNER_METRICS_REGISTRY, SIGNER_STATUS, uri_to_tag}, + utils::get_true_ip, }; +pub const REQUEST_MAX_BODY_LENGTH: usize = 1024 * 1024; // 1 MB + /// Implements the Signer API and provides a service for signing requests pub struct SigningService; @@ -61,9 +69,12 @@ struct SigningState { /// Manager handling different signing methods manager: Arc>, - /// Map of modules ids to JWT secrets. This also acts as registry of all - /// modules running - jwts: Arc>, + /// Map of modules ids to JWT configurations. This also acts as registry of + /// all modules running + jwts: Arc>>, + + /// Secret for the admin JWT + admin_secret: Arc>, /// Map of JWT failures per peer jwt_auth_failures: Arc>>, @@ -71,23 +82,29 @@ struct SigningState { // JWT auth failure settings jwt_auth_fail_limit: u32, jwt_auth_fail_timeout: Duration, + + /// Header to extract the trusted client IP from + reverse_proxy: ReverseProxyHeaderSetup, } impl SigningService { pub async fn run(config: StartSignerConfig) -> eyre::Result<()> { - if config.jwts.is_empty() { + if config.mod_signing_configs.is_empty() { warn!("Signing service was started but no module is registered. Exiting"); return Ok(()); } - let module_ids: Vec = config.jwts.keys().cloned().map(Into::into).collect(); + let module_ids: Vec = + config.mod_signing_configs.keys().cloned().map(Into::into).collect(); let state = SigningState { manager: Arc::new(RwLock::new(start_manager(config.clone()).await?)), - jwts: config.jwts.into(), + jwts: Arc::new(ParkingRwLock::new(config.mod_signing_configs)), + admin_secret: Arc::new(ParkingRwLock::new(config.admin_secret)), jwt_auth_failures: Arc::new(ParkingRwLock::new(HashMap::new())), jwt_auth_fail_limit: config.jwt_auth_fail_limit, jwt_auth_fail_timeout: Duration::from_secs(config.jwt_auth_fail_timeout_seconds as u64), + reverse_proxy: config.reverse_proxy, }; // Get the signer counts @@ -108,25 +125,91 @@ impl SigningService { loaded_proxies, jwt_auth_fail_limit =? state.jwt_auth_fail_limit, jwt_auth_fail_timeout =? state.jwt_auth_fail_timeout, + reverse_proxy =% state.reverse_proxy, "Starting signing service" ); SigningService::init_metrics(config.chain)?; - let app = axum::Router::new() - .route(REQUEST_SIGNATURE_PATH, post(handle_request_signature)) + let signer_app = axum::Router::new() + .route(REQUEST_SIGNATURE_BLS_PATH, post(handle_request_signature_bls)) + .route(REQUEST_SIGNATURE_PROXY_BLS_PATH, post(handle_request_signature_proxy_bls)) + .route(REQUEST_SIGNATURE_PROXY_ECDSA_PATH, post(handle_request_signature_proxy_ecdsa)) .route(GET_PUBKEYS_PATH, get(handle_get_pubkeys)) .route(GENERATE_PROXY_KEY_PATH, post(handle_generate_proxy)) .route_layer(middleware::from_fn_with_state(state.clone(), jwt_auth)) + .with_state(state.clone()) + .route_layer(middleware::from_fn(log_request)); + + let admin_app = axum::Router::new() .route(RELOAD_PATH, post(handle_reload)) + .route(REVOKE_MODULE_PATH, post(handle_revoke_module)) + .route_layer(middleware::from_fn_with_state(state.clone(), admin_auth)) .with_state(state.clone()) .route_layer(middleware::from_fn(log_request)) - .route(STATUS_PATH, get(handle_status)) - .into_make_service_with_connect_info::(); + .route(STATUS_PATH, get(handle_status)); + + // Run the JWT cleaning task + let jwt_cleaning_task = tokio::spawn(async move { + let mut interval = tokio::time::interval(state.jwt_auth_fail_timeout); + loop { + interval.tick().await; + let mut failures = state.jwt_auth_failures.write(); + let before = failures.len(); + failures + .retain(|_, info| info.last_failure.elapsed() < state.jwt_auth_fail_timeout); + let after = failures.len(); + if before != after { + debug!("Cleaned up {} old JWT auth failure entries", before - after); + } + } + }); + + let server_result = if let Some(tls_config) = config.tls_certificates { + if CryptoProvider::get_default().is_none() { + // Install the AWS-LC provider if no default is set, usually for CI + debug!("Installing AWS-LC as default TLS provider"); + let mut attempts = 0; + loop { + match aws_lc_rs::default_provider().install_default() { + Ok(_) => { + debug!("Successfully installed AWS-LC as default TLS provider"); + break; + } + Err(e) => { + if attempts >= 3 { + return Err(eyre::eyre!( + "Exceeded maximum attempts to install AWS-LC as default TLS provider: {e:?}" + )); + } + error!( + "Failed to install AWS-LC as default TLS provider: {e:?}. Retrying..." + ); + attempts += 1; + } + } + } + } + + let tls_config = RustlsConfig::from_pem(tls_config.0, tls_config.1).await?; + axum_server::bind_rustls(config.endpoint, tls_config) + .serve( + signer_app.merge(admin_app).into_make_service_with_connect_info::(), + ) + .await + } else { + warn!("Running in insecure HTTP mode, no TLS certificates provided"); + axum_server::bind(config.endpoint) + .serve( + signer_app.merge(admin_app).into_make_service_with_connect_info::(), + ) + .await + }; - let listener = TcpListener::bind(config.endpoint).await?; + // Shutdown the JWT cleaning task + jwt_cleaning_task.abort(); - axum::serve(listener, app).await.wrap_err("signer server exited") + server_result.wrap_err("signer service exited") } fn init_metrics(network: Chain) -> eyre::Result<()> { @@ -134,34 +217,55 @@ impl SigningService { } } +/// Marks a JWT authentication failure for a given client IP +fn mark_jwt_failure(state: &SigningState, client_ip: IpAddr) { + let mut failures = state.jwt_auth_failures.write(); + let failure_info = failures + .entry(client_ip) + .or_insert(JwtAuthFailureInfo { failure_count: 0, last_failure: Instant::now() }); + failure_info.failure_count += 1; + failure_info.last_failure = Instant::now(); +} + /// Authentication middleware layer async fn jwt_auth( State(state): State, + req_headers: HeaderMap, TypedHeader(auth): TypedHeader>, addr: ConnectInfo, - mut req: Request, + req: Request, next: Next, ) -> Result { // Check if the request needs to be rate limited - let client_ip = addr.ip(); + let client_ip = get_true_ip(&req_headers, &addr, &state.reverse_proxy).map_err(|e| { + error!("Failed to get client IP: {e}"); + SignerModuleError::RequestError("failed to get client IP".to_string()) + })?; check_jwt_rate_limit(&state, &client_ip)?; + // Clone the request so we can read the body + let (parts, body) = req.into_parts(); + let path = parts.uri.path(); + let bytes = to_bytes(body, REQUEST_MAX_BODY_LENGTH).await.map_err(|e| { + error!("Failed to read request body: {e}"); + SignerModuleError::RequestError(e.to_string()) + })?; + // Process JWT authorization - match check_jwt_auth(&auth, &state) { + match check_jwt_auth(&auth, &state, path, &bytes) { Ok(module_id) => { + let mut req = Request::from_parts(parts, Body::from(bytes)); req.extensions_mut().insert(module_id); Ok(next.run(req).await) } Err(SignerModuleError::Unauthorized) => { - let mut failures = state.jwt_auth_failures.write(); - let failure_info = failures - .entry(client_ip) - .or_insert(JwtAuthFailureInfo { failure_count: 0, last_failure: Instant::now() }); - failure_info.failure_count += 1; - failure_info.last_failure = Instant::now(); + mark_jwt_failure(&state, client_ip); Err(SignerModuleError::Unauthorized) } - Err(err) => Err(err), + Err(err) => { + mark_jwt_failure(&state, client_ip); + Err(err) + } } } @@ -206,26 +310,68 @@ fn check_jwt_rate_limit(state: &SigningState, client_ip: &IpAddr) -> Result<(), fn check_jwt_auth( auth: &Authorization, state: &SigningState, + path: &str, + body: &[u8], ) -> Result { let jwt: Jwt = auth.token().to_string().into(); // We first need to decode it to get the module id and then validate it // with the secret stored in the state - let module_id = decode_jwt(jwt.clone()).map_err(|e| { + let claims = decode_jwt(jwt.clone()).map_err(|e| { error!("Unauthorized request. Invalid JWT: {e}"); SignerModuleError::Unauthorized })?; - let jwt_secret = state.jwts.get(&module_id).ok_or_else(|| { + let guard = state.jwts.read(); + let jwt_config = guard.get(&claims.module).ok_or_else(|| { error!("Unauthorized request. Was the module started correctly?"); SignerModuleError::Unauthorized })?; - validate_jwt(jwt, jwt_secret).map_err(|e| { + let body_bytes = if body.is_empty() { None } else { Some(body) }; + validate_jwt(jwt, &jwt_config.jwt_secret, path, body_bytes).map_err(|e| { + error!("Unauthorized request. Invalid JWT: {e}"); + SignerModuleError::Unauthorized + })?; + + Ok(claims.module) +} + +async fn admin_auth( + State(state): State, + req_headers: HeaderMap, + TypedHeader(auth): TypedHeader>, + addr: ConnectInfo, + req: Request, + next: Next, +) -> Result { + // Check if the request needs to be rate limited + let client_ip = get_true_ip(&req_headers, &addr, &state.reverse_proxy).map_err(|e| { + error!("Failed to get client IP: {e}"); + SignerModuleError::RequestError("failed to get client IP".to_string()) + })?; + check_jwt_rate_limit(&state, &client_ip)?; + + // Clone the request so we can read the body + let (parts, body) = req.into_parts(); + let path = parts.uri.path(); + let bytes = to_bytes(body, REQUEST_MAX_BODY_LENGTH).await.map_err(|e| { + error!("Failed to read request body: {e}"); + SignerModuleError::RequestError(e.to_string()) + })?; + + let jwt: Jwt = auth.token().to_string().into(); + + // Validate the admin JWT + let body_bytes: Option<&[u8]> = if bytes.is_empty() { None } else { Some(&bytes) }; + validate_admin_jwt(jwt, &state.admin_secret.read(), path, body_bytes).map_err(|e| { error!("Unauthorized request. Invalid JWT: {e}"); + mark_jwt_failure(&state, client_ip); SignerModuleError::Unauthorized })?; - Ok(module_id) + + let req = Request::from_parts(parts, Body::from(bytes)); + Ok(next.run(req).await) } /// Requests logging middleware layer @@ -246,9 +392,7 @@ async fn handle_get_pubkeys( Extension(module_id): Extension, State(state): State, ) -> Result { - let req_id = Uuid::new_v4(); - - debug!(event = "get_pubkeys", ?req_id, "New request"); + debug!(event = "get_pubkeys", ?module_id, "New request"); let keys = state .manager @@ -262,62 +406,184 @@ async fn handle_get_pubkeys( Ok((StatusCode::OK, Json(res)).into_response()) } -/// Implements request_signature from the Signer API -async fn handle_request_signature( +/// Validates a BLS key signature request and returns the signature +async fn handle_request_signature_bls( Extension(module_id): Extension, State(state): State, - Json(request): Json, + Json(request): Json, ) -> Result { - let req_id = Uuid::new_v4(); + debug!(event = "bls_request_signature", ?module_id, %request, "New request"); + handle_request_signature_bls_impl( + module_id, + state, + false, + request.pubkey, + request.object_root, + request.nonce, + ) + .await +} - debug!(event = "request_signature", ?module_id, %request, ?req_id, "New request"); +/// Validates a BLS key signature request using a proxy key and returns the +/// signature +async fn handle_request_signature_proxy_bls( + Extension(module_id): Extension, + State(state): State, + Json(request): Json>, +) -> Result { + debug!(event = "proxy_bls_request_signature", ?module_id, %request, "New request"); + handle_request_signature_bls_impl( + module_id, + state, + true, + request.proxy, + request.object_root, + request.nonce, + ) + .await +} - let manager = state.manager.read().await; - let res = match &*manager { - SigningManager::Local(local_manager) => match request { - SignRequest::Consensus(SignConsensusRequest { object_root, pubkey }) => local_manager - .sign_consensus(&pubkey, object_root) - .await - .map(|sig| Json(sig).into_response()), - SignRequest::ProxyBls(SignProxyRequest { object_root, proxy: bls_key }) => { +/// Implementation for handling a BLS signature request +async fn handle_request_signature_bls_impl( + module_id: ModuleId, + state: SigningState, + is_proxy: bool, + signing_pubkey: BlsPublicKey, + object_root: B256, + nonce: u64, +) -> Result { + let Some(signing_id) = state.jwts.read().get(&module_id).map(|m| m.signing_id) else { + error!( + event = "proxy_bls_request_signature", + ?module_id, + %signing_pubkey, + %object_root, + nonce, + "Module signing ID not found" + ); + return Err(SignerModuleError::RequestError("Module signing ID not found".to_string())); + }; + + let (chain_id, signature) = match &*state.manager.read().await { + SigningManager::Local(local_manager) => { + let sig = if is_proxy { local_manager - .sign_proxy_bls(&bls_key, object_root) + .sign_proxy_bls( + &signing_pubkey, + &object_root, + Some(&SignatureRequestInfo { module_signing_id: signing_id, nonce }), + ) .await - .map(|sig| Json(sig).into_response()) - } - SignRequest::ProxyEcdsa(SignProxyRequest { object_root, proxy: ecdsa_key }) => { + } else { local_manager - .sign_proxy_ecdsa(&ecdsa_key, object_root) + .sign_consensus( + &signing_pubkey, + &object_root, + Some(&SignatureRequestInfo { module_signing_id: signing_id, nonce }), + ) .await - .map(|sig| Json(sig).into_response()) - } - }, - SigningManager::Dirk(dirk_manager) => match request { - SignRequest::Consensus(SignConsensusRequest { object_root, pubkey }) => dirk_manager - .request_consensus_signature(&pubkey, object_root) - .await - .map(|sig| Json(sig).into_response()), - SignRequest::ProxyBls(SignProxyRequest { object_root, proxy: bls_key }) => dirk_manager - .request_proxy_signature(&bls_key, object_root) - .await - .map(|sig| Json(sig).into_response()), - SignRequest::ProxyEcdsa(_) => { - error!( - event = "request_signature", - ?module_id, - ?req_id, - "ECDSA proxy sign request not supported with Dirk" - ); - Err(SignerModuleError::DirkNotSupported) - } - }, + }; + (local_manager.get_chain().id(), sig) + } + SigningManager::Dirk(dirk_manager) => { + let sig = if is_proxy { + dirk_manager + .request_proxy_signature( + &signing_pubkey, + &object_root, + Some(&SignatureRequestInfo { module_signing_id: signing_id, nonce }), + ) + .await + } else { + dirk_manager + .request_consensus_signature( + &signing_pubkey, + &object_root, + Some(&SignatureRequestInfo { module_signing_id: signing_id, nonce }), + ) + .await + }; + (dirk_manager.get_chain().id(), sig) + } }; - if let Err(err) = &res { - error!(event = "request_signature", ?module_id, ?req_id, "{err}"); - } + signature + .inspect_err(|err| { + error!(event = "request_signature", ?module_id, %signing_pubkey, %object_root, nonce, "{err}") + }) + .map(|sig| { + Json(BlsSignResponse::new( + signing_pubkey.clone(), + object_root, + signing_id, + nonce, + chain_id, + sig, + )) + .into_response() + }) +} - res +/// Validates an ECDSA key signature request using a proxy key and returns the +/// signature +async fn handle_request_signature_proxy_ecdsa( + Extension(module_id): Extension, + State(state): State, + Json(request): Json>, +) -> Result { + let Some(signing_id) = state.jwts.read().get(&module_id).map(|m| m.signing_id) else { + error!( + event = "proxy_ecdsa_request_signature", + ?module_id, + proxy = %request.proxy, + object_root = %request.object_root, + nonce = request.nonce, + "Module signing ID not found" + ); + return Err(SignerModuleError::RequestError("Module signing ID not found".to_string())); + }; + debug!(event = "proxy_ecdsa_request_signature", ?module_id, %request, "New request"); + + let (chain_id, signature) = match &*state.manager.read().await { + SigningManager::Local(local_manager) => { + let sig = local_manager + .sign_proxy_ecdsa( + &request.proxy, + &request.object_root, + Some(&SignatureRequestInfo { + module_signing_id: signing_id, + nonce: request.nonce, + }), + ) + .await; + (local_manager.get_chain().id(), sig) + } + SigningManager::Dirk(_) => { + // Dirk does not support ECDSA proxy signing + error!( + event = "request_signature", + ?module_id, + proxy = %request.proxy, + object_root = %request.object_root, + nonce = request.nonce, + "ECDSA proxy sign request not supported with Dirk" + ); + (U256::ZERO, Err(SignerModuleError::DirkNotSupported)) + } + }; + signature + .inspect_err(|err| error!(event = "request_signature", ?module_id, proxy = %request.proxy, object_root = %request.object_root, nonce = request.nonce, "{err}")) + .map(|sig| { + Json(EcdsaSignResponse::new( + request.proxy, + request.object_root, + signing_id, + request.nonce, + chain_id, + sig, + )) + .into_response() + }) } async fn handle_generate_proxy( @@ -325,25 +591,23 @@ async fn handle_generate_proxy( State(state): State, Json(request): Json, ) -> Result { - let req_id = Uuid::new_v4(); - - debug!(event = "generate_proxy", ?module_id, scheme=?request.scheme, pubkey=%request.consensus_pubkey, ?req_id, "New request"); + debug!(event = "generate_proxy", ?module_id, scheme=?request.scheme, pubkey=%request.consensus_pubkey, "New request"); let mut manager = state.manager.write().await; let res = match &mut *manager { SigningManager::Local(local_manager) => match request.scheme { EncryptionScheme::Bls => local_manager - .create_proxy_bls(module_id.clone(), request.consensus_pubkey) + .create_proxy_bls(module_id.clone(), &request.consensus_pubkey) .await .map(|proxy_delegation| Json(proxy_delegation).into_response()), EncryptionScheme::Ecdsa => local_manager - .create_proxy_ecdsa(module_id.clone(), request.consensus_pubkey) + .create_proxy_ecdsa(module_id.clone(), &request.consensus_pubkey) .await .map(|proxy_delegation| Json(proxy_delegation).into_response()), }, SigningManager::Dirk(dirk_manager) => match request.scheme { EncryptionScheme::Bls => dirk_manager - .generate_proxy_key(&module_id, request.consensus_pubkey) + .generate_proxy_key(&module_id, &request.consensus_pubkey) .await .map(|proxy_delegation| Json(proxy_delegation).into_response()), EncryptionScheme::Ecdsa => { @@ -354,40 +618,97 @@ async fn handle_generate_proxy( }; if let Err(err) = &res { - error!(event = "generate_proxy", module_id=?module_id, ?req_id, "{err}"); + error!(event = "generate_proxy", ?module_id, scheme=?request.scheme, pubkey=%request.consensus_pubkey, "{err}"); } res } async fn handle_reload( - State(mut state): State, + State(state): State, + Json(request): Json, ) -> Result { - let req_id = Uuid::new_v4(); - - debug!(event = "reload", ?req_id, "New request"); + debug!(event = "reload", "New request"); let config = match StartSignerConfig::load_from_env() { Ok(config) => config, Err(err) => { - error!(event = "reload", ?req_id, error = ?err, "Failed to reload config"); + error!(event = "reload", error = ?err, "Failed to reload config"); return Err(SignerModuleError::Internal("failed to reload config".to_string())); } }; + // Extract the fields we need before start_manager consumes the config + let new_signing_configs = config.mod_signing_configs.clone(); + let new_admin_secret = config.admin_secret.clone(); + let new_manager = match start_manager(config).await { Ok(manager) => manager, Err(err) => { - error!(event = "reload", ?req_id, error = ?err, "Failed to reload manager"); + error!(event = "reload", error = ?err, "Failed to reload manager"); return Err(SignerModuleError::Internal("failed to reload config".to_string())); } }; - state.manager = Arc::new(RwLock::new(new_manager)); + apply_reload(state, request, new_manager, new_signing_configs, new_admin_secret).await +} + +/// Applies a reload request to the signing state. Separated from +/// `handle_reload` so the business logic can be tested without requiring a +/// live environment (config file, env vars, keystore on disk). +/// +/// The reload follows a two-layer approach: +/// 1. Sync the baseline from the freshly loaded config (adds new modules, +/// removes deleted ones, resets secrets to their env var / config values). +/// 2. Apply any optional overrides from the request body on top (for remote +/// secret rotation without editing config files). +async fn apply_reload( + state: SigningState, + request: ReloadRequest, + new_manager: SigningManager, + new_signing_configs: HashMap, + new_admin_secret: String, +) -> Result { + // Build the new JWT map from config, then apply any body overrides. + let mut new_jwts = new_signing_configs; + + if let Some(jwt_secrets) = request.jwt_secrets { + // Validate all overrides before applying any + for module_id in jwt_secrets.keys() { + if !new_jwts.contains_key(module_id) { + let error_message = + format!("Module {module_id} not found in config, cannot override JWT secret"); + error!(event = "reload", module_id = %module_id, error = %error_message); + return Err(SignerModuleError::RequestError(error_message)); + } + } + + for (module_id, jwt_secret) in jwt_secrets { + if let Some(cfg) = new_jwts.get_mut(&module_id) { + cfg.jwt_secret = jwt_secret; + } + } + } + + *state.jwts.write() = new_jwts; + *state.admin_secret.write() = request.admin_secret.unwrap_or(new_admin_secret); + + *state.manager.write().await = new_manager; Ok(StatusCode::OK) } +async fn handle_revoke_module( + State(state): State, + Json(request): Json, +) -> Result { + let mut guard = state.jwts.write(); + guard + .remove(&request.module_id) + .ok_or(SignerModuleError::ModuleIdNotFound) + .map(|_| StatusCode::OK) +} + async fn start_manager(config: StartSignerConfig) -> eyre::Result { let proxy_store = if let Some(store) = config.store.clone() { Some(store.init_from_env()?) @@ -418,3 +739,435 @@ async fn start_manager(config: StartSignerConfig) -> eyre::Result ModuleSigningConfig { + ModuleSigningConfig { + module_name: ModuleId(module_name.to_string()), + jwt_secret: secret.to_string(), + signing_id, + } + } + + fn make_state(jwts: HashMap) -> SigningState { + SigningState { + manager: Arc::new(RwLock::new(SigningManager::Local( + LocalSigningManager::new(Chain::Holesky, None).unwrap(), + ))), + jwts: Arc::new(ParkingRwLock::new(jwts)), + admin_secret: Arc::new(ParkingRwLock::new("admin".to_string())), + jwt_auth_failures: Arc::new(ParkingRwLock::new(HashMap::new())), + jwt_auth_fail_limit: 3, + jwt_auth_fail_timeout: Duration::from_secs(60), + reverse_proxy: ReverseProxyHeaderSetup::None, + } + } + + fn empty_manager() -> SigningManager { + SigningManager::Local(LocalSigningManager::new(Chain::Holesky, None).unwrap()) + } + + /// Helper to call apply_reload with the config baseline matching the + /// current state (simulates a reload where the config file hasn't changed). + async fn reload_with_same_config( + state: &SigningState, + request: ReloadRequest, + ) -> Result { + let new_signing_configs = state.jwts.read().clone(); + let new_admin_secret = state.admin_secret.read().clone(); + apply_reload(state.clone(), request, empty_manager(), new_signing_configs, new_admin_secret) + .await + } + + /// Partial reload must update only the provided modules and leave omitted + /// modules with their config-baseline secrets. + #[tokio::test] + async fn test_partial_reload_preserves_omitted_modules() { + let module_a = ModuleId("module-a".to_string()); + let module_b = ModuleId("module-b".to_string()); + let signing_id_a = + b256!("0101010101010101010101010101010101010101010101010101010101010101"); + let signing_id_b = + b256!("0202020202020202020202020202020202020202020202020202020202020202"); + + let state = make_state(HashMap::from([ + (module_a.clone(), make_signing_config("module-a", "secret-a", signing_id_a)), + (module_b.clone(), make_signing_config("module-b", "secret-b", signing_id_b)), + ])); + + let request = ReloadRequest { + jwt_secrets: Some(HashMap::from([(module_a.clone(), "rotated-secret-a".to_string())])), + admin_secret: None, + }; + + let result = reload_with_same_config(&state, request).await; + assert!(result.is_ok(), "apply_reload should succeed"); + + let jwts = state.jwts.read(); + assert_eq!( + jwts[&module_a].jwt_secret, "rotated-secret-a", + "module_a secret should be updated" + ); + assert_eq!( + jwts[&module_b].jwt_secret, "secret-b", + "module_b secret must be preserved when omitted" + ); + } + + /// A full reload (all modules provided) should update every module. + #[tokio::test] + async fn test_full_reload_updates_all_modules() { + let module_a = ModuleId("module-a".to_string()); + let module_b = ModuleId("module-b".to_string()); + let signing_id_a = + b256!("0101010101010101010101010101010101010101010101010101010101010101"); + let signing_id_b = + b256!("0202020202020202020202020202020202020202020202020202020202020202"); + + let state = make_state(HashMap::from([ + (module_a.clone(), make_signing_config("module-a", "secret-a", signing_id_a)), + (module_b.clone(), make_signing_config("module-b", "secret-b", signing_id_b)), + ])); + + let request = ReloadRequest { + jwt_secrets: Some(HashMap::from([ + (module_a.clone(), "new-secret-a".to_string()), + (module_b.clone(), "new-secret-b".to_string()), + ])), + admin_secret: None, + }; + + reload_with_same_config(&state, request).await.unwrap(); + + let jwts = state.jwts.read(); + assert_eq!(jwts[&module_a].jwt_secret, "new-secret-a"); + assert_eq!(jwts[&module_b].jwt_secret, "new-secret-b"); + } + + /// Override for a module not in the config should return an error. + /// The baseline config has already been applied, but overrides are + /// validated against it. + #[tokio::test] + async fn test_reload_override_unknown_module_returns_error() { + let module_a = ModuleId("module-a".to_string()); + let signing_id_a = + b256!("0101010101010101010101010101010101010101010101010101010101010101"); + + let config_baseline = HashMap::from([( + module_a.clone(), + make_signing_config("module-a", "secret-a", signing_id_a), + )]); + + let state = make_state(config_baseline.clone()); + + let request = ReloadRequest { + jwt_secrets: Some(HashMap::from([( + ModuleId("unknown-module".to_string()), + "some-secret".to_string(), + )])), + admin_secret: None, + }; + + let result = apply_reload( + state.clone(), + request, + empty_manager(), + config_baseline, + "admin".to_string(), + ) + .await; + assert!(result.is_err(), "unknown module override should return an error"); + + // Nothing should have been applied — old state is preserved + let jwts = state.jwts.read(); + assert_eq!(jwts[&module_a].jwt_secret, "secret-a"); + } + + /// An override request containing both a valid and an unknown module must + /// fail atomically — the config baseline is applied but no overrides take + /// effect. + #[tokio::test] + async fn test_reload_mixed_known_and_unknown_override_is_atomic() { + let module_a = ModuleId("module-a".to_string()); + let module_b = ModuleId("module-b".to_string()); + let signing_id_a = + b256!("0101010101010101010101010101010101010101010101010101010101010101"); + let signing_id_b = + b256!("0202020202020202020202020202020202020202020202020202020202020202"); + + let config_baseline = HashMap::from([ + (module_a.clone(), make_signing_config("module-a", "secret-a", signing_id_a)), + (module_b.clone(), make_signing_config("module-b", "secret-b", signing_id_b)), + ]); + + let state = make_state(config_baseline.clone()); + + let request = ReloadRequest { + jwt_secrets: Some(HashMap::from([ + (module_a.clone(), "rotated-secret-a".to_string()), + (ModuleId("unknown-module".to_string()), "some-secret".to_string()), + ])), + admin_secret: None, + }; + + let result = apply_reload( + state.clone(), + request, + empty_manager(), + config_baseline, + "admin".to_string(), + ) + .await; + assert!(result.is_err(), "mixed known+unknown should return an error"); + + // Nothing should have been applied — old state is fully preserved + let jwts = state.jwts.read(); + assert_eq!( + jwts[&module_a].jwt_secret, "secret-a", + "module_a must retain its original secret" + ); + assert_eq!( + jwts[&module_b].jwt_secret, "secret-b", + "module_b must retain its original secret" + ); + } + + /// Reload with no jwt_secrets should reset all secrets to config baseline. + #[tokio::test] + async fn test_reload_without_jwt_secrets_resets_to_config() { + let module_a = ModuleId("module-a".to_string()); + let signing_id_a = + b256!("0101010101010101010101010101010101010101010101010101010101010101"); + + // State has a previously-rotated secret + let state = make_state(HashMap::from([( + module_a.clone(), + make_signing_config("module-a", "rotated-secret", signing_id_a), + )])); + + // Config baseline has the original secret + let config_baseline = HashMap::from([( + module_a.clone(), + make_signing_config("module-a", "config-secret", signing_id_a), + )]); + + let request = ReloadRequest { jwt_secrets: None, admin_secret: None }; + + apply_reload(state.clone(), request, empty_manager(), config_baseline, "admin".to_string()) + .await + .unwrap(); + + let jwts = state.jwts.read(); + assert_eq!( + jwts[&module_a].jwt_secret, "config-secret", + "secret should be reset to config baseline" + ); + } + + /// A new module added to the config should appear in state after reload. + #[tokio::test] + async fn test_reload_adds_new_module_from_config() { + let module_a = ModuleId("module-a".to_string()); + let module_b = ModuleId("module-b".to_string()); + let signing_id_a = + b256!("0101010101010101010101010101010101010101010101010101010101010101"); + let signing_id_b = + b256!("0202020202020202020202020202020202020202020202020202020202020202"); + + // Old state only has module-a + let state = make_state(HashMap::from([( + module_a.clone(), + make_signing_config("module-a", "secret-a", signing_id_a), + )])); + + // Config now includes module-b as well + let config_baseline = HashMap::from([ + (module_a.clone(), make_signing_config("module-a", "secret-a", signing_id_a)), + (module_b.clone(), make_signing_config("module-b", "secret-b", signing_id_b)), + ]); + + let request = ReloadRequest { jwt_secrets: None, admin_secret: None }; + + apply_reload(state.clone(), request, empty_manager(), config_baseline, "admin".to_string()) + .await + .unwrap(); + + let jwts = state.jwts.read(); + assert_eq!(jwts.len(), 2, "should have both modules"); + assert_eq!(jwts[&module_a].jwt_secret, "secret-a"); + assert_eq!(jwts[&module_b].jwt_secret, "secret-b"); + } + + /// A module removed from config should be dropped from state after reload. + #[tokio::test] + async fn test_reload_removes_module_not_in_config() { + let module_a = ModuleId("module-a".to_string()); + let module_b = ModuleId("module-b".to_string()); + let signing_id_a = + b256!("0101010101010101010101010101010101010101010101010101010101010101"); + let signing_id_b = + b256!("0202020202020202020202020202020202020202020202020202020202020202"); + + // Old state has both modules + let state = make_state(HashMap::from([ + (module_a.clone(), make_signing_config("module-a", "secret-a", signing_id_a)), + (module_b.clone(), make_signing_config("module-b", "secret-b", signing_id_b)), + ])); + + // Config only has module-a now + let config_baseline = HashMap::from([( + module_a.clone(), + make_signing_config("module-a", "secret-a", signing_id_a), + )]); + + let request = ReloadRequest { jwt_secrets: None, admin_secret: None }; + + apply_reload(state.clone(), request, empty_manager(), config_baseline, "admin".to_string()) + .await + .unwrap(); + + let jwts = state.jwts.read(); + assert_eq!(jwts.len(), 1, "should only have module-a"); + assert!(jwts.contains_key(&module_a)); + assert!(!jwts.contains_key(&module_b), "module-b should be removed"); + } + + /// Body override should work on a newly added module from config. + #[tokio::test] + async fn test_reload_override_on_newly_added_module() { + let module_a = ModuleId("module-a".to_string()); + let module_b = ModuleId("module-b".to_string()); + let signing_id_a = + b256!("0101010101010101010101010101010101010101010101010101010101010101"); + let signing_id_b = + b256!("0202020202020202020202020202020202020202020202020202020202020202"); + + // Old state only has module-a + let state = make_state(HashMap::from([( + module_a.clone(), + make_signing_config("module-a", "secret-a", signing_id_a), + )])); + + // Config adds module-b + let config_baseline = HashMap::from([ + (module_a.clone(), make_signing_config("module-a", "secret-a", signing_id_a)), + (module_b.clone(), make_signing_config("module-b", "config-secret-b", signing_id_b)), + ]); + + // Override the new module's secret via body + let request = ReloadRequest { + jwt_secrets: Some(HashMap::from([( + module_b.clone(), + "overridden-secret-b".to_string(), + )])), + admin_secret: None, + }; + + apply_reload(state.clone(), request, empty_manager(), config_baseline, "admin".to_string()) + .await + .unwrap(); + + let jwts = state.jwts.read(); + assert_eq!(jwts[&module_a].jwt_secret, "secret-a"); + assert_eq!( + jwts[&module_b].jwt_secret, "overridden-secret-b", + "new module secret should be overridden by body" + ); + } + + /// Admin secret should be synced from config, then overridden by body. + #[tokio::test] + async fn test_reload_admin_secret_config_then_override() { + let state = make_state(HashMap::new()); + + // Config has new admin secret + let config_baseline = HashMap::new(); + + // Body overrides it further + let request = ReloadRequest { + jwt_secrets: None, + admin_secret: Some("body-admin-secret".to_string()), + }; + + apply_reload( + state.clone(), + request, + empty_manager(), + config_baseline, + "config-admin-secret".to_string(), + ) + .await + .unwrap(); + + assert_eq!( + *state.admin_secret.read(), + "body-admin-secret", + "body override should take precedence over config" + ); + } + + /// Admin secret should be reset to config value when no body override. + #[tokio::test] + async fn test_reload_admin_secret_resets_to_config() { + let state = make_state(HashMap::new()); + *state.admin_secret.write() = "old-rotated-admin".to_string(); + + let request = ReloadRequest { jwt_secrets: None, admin_secret: None }; + + apply_reload( + state.clone(), + request, + empty_manager(), + HashMap::new(), + "config-admin-secret".to_string(), + ) + .await + .unwrap(); + + assert_eq!( + *state.admin_secret.read(), + "config-admin-secret", + "admin secret should reset to config baseline" + ); + } + + #[tokio::test] + async fn test_revoke_module() { + let module_a = ModuleId("module-a".to_string()); + let signing_id_a = + b256!("0101010101010101010101010101010101010101010101010101010101010101"); + + let state = make_state(HashMap::from([( + module_a.clone(), + make_signing_config("module-a", "secret-a", signing_id_a), + )])); + + { + let jwts = state.jwts.read(); + assert_eq!(jwts[&module_a].jwt_secret, "secret-a"); + } + + let revoke_request = RevokeModuleRequest { module_id: module_a.clone() }; + + let revoke_response = + handle_revoke_module(State(state.clone()), Json(revoke_request)).await; + + assert!(revoke_response.is_ok(), "revoke_module should succeed"); + + { + let jwts = state.jwts.read(); + assert!(!jwts.contains_key(&module_a), "module-a should be removed from JWT configs"); + } + } +} diff --git a/crates/signer/src/utils.rs b/crates/signer/src/utils.rs new file mode 100644 index 00000000..bfc28f9f --- /dev/null +++ b/crates/signer/src/utils.rs @@ -0,0 +1,242 @@ +use std::net::{IpAddr, SocketAddr}; + +use axum::http::HeaderMap; +use cb_common::config::ReverseProxyHeaderSetup; + +#[derive(Debug, thiserror::Error)] +pub enum IpError { + #[error("header `{0}` is not present")] + NotPresent(String), + #[error("header value has invalid characters")] + HasInvalidCharacters, + #[error("header value is not a valid IP address")] + InvalidValue, + #[error("header `{0}` appears multiple times but expected to be unique")] + NotUnique(String), + #[error("header does not contain enough values: found {found}, required {required}")] + NotEnoughValues { found: usize, required: usize }, +} + +/// Get the true client IP from the request headers or fallback to the socket +/// address +pub fn get_true_ip( + headers: &HeaderMap, + addr: &SocketAddr, + reverse_proxy: &ReverseProxyHeaderSetup, +) -> Result { + match reverse_proxy { + ReverseProxyHeaderSetup::None => Ok(addr.ip()), + ReverseProxyHeaderSetup::Unique { header } => get_ip_from_unique_header(headers, header), + ReverseProxyHeaderSetup::Rightmost { header, trusted_count } => { + get_ip_from_rightmost_value(headers, header, trusted_count.get()) + } + } +} + +fn get_ip_from_unique_header(headers: &HeaderMap, header_name: &str) -> Result { + let mut values = headers.get_all(header_name).iter(); + + let first_value = values.next().ok_or(IpError::NotPresent(header_name.to_string()))?; + + if values.next().is_some() { + return Err(IpError::NotUnique(header_name.to_string())); + } + + let ip = first_value + .to_str() + .map_err(|_| IpError::HasInvalidCharacters)? + .parse::() + .map_err(|_| IpError::InvalidValue)?; + + Ok(ip) +} + +fn get_ip_from_rightmost_value( + headers: &HeaderMap, + header_name: &str, + trusted_count: usize, +) -> Result { + let joined_values = headers + .get_all(header_name) + .iter() + .map(|x| x.to_str().map_err(|_| IpError::HasInvalidCharacters)) + .collect::, IpError>>()? + .join(","); + + if joined_values.is_empty() { + return Err(IpError::NotPresent(header_name.to_string())) + } + + // Selecting the first untrusted IP from the right according to: + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-For#selecting_an_ip_address + joined_values + .rsplit(",") + .nth(trusted_count - 1) + .ok_or(IpError::NotEnoughValues { + found: joined_values.split(",").count(), + required: trusted_count, + })? + .trim() + .parse::() + .map_err(|_| IpError::InvalidValue) +} + +#[cfg(test)] +mod tests { + use std::net::Ipv4Addr; + + use super::*; + + #[test] + fn test_unique_header_pass() { + let header_name = "X-Real-IP"; + let real_ip = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)); + + let mut headers = HeaderMap::new(); + headers.insert(header_name, real_ip.to_string().parse().unwrap()); + + let ip = get_ip_from_unique_header(&headers, header_name).unwrap(); + assert_eq!(ip, real_ip); + } + + #[test] + fn test_unique_header_duplicated() { + let header_name = "X-Real-IP"; + let real_ip = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)); + let fake_ip = IpAddr::V4(Ipv4Addr::new(2, 2, 2, 2)); + + let mut headers = HeaderMap::new(); + headers.insert(header_name, real_ip.to_string().parse().unwrap()); + headers.append(header_name, fake_ip.to_string().parse().unwrap()); + + let err = get_ip_from_unique_header(&headers, header_name) + .expect_err("Not unique header should fail"); + assert!(matches!(err, IpError::NotUnique(_))); + } + #[test] + fn test_unique_header_not_present() { + let header_name = "X-Real-IP"; + let headers = HeaderMap::new(); + + let err = get_ip_from_unique_header(&headers, header_name) + .expect_err("Missing header should fail"); + assert!(matches!(err, IpError::NotPresent(_))); + } + + #[test] + fn test_unique_header_invalid_value() { + let header_name = "X-Real-IP"; + let mut headers = HeaderMap::new(); + headers.insert(header_name, "invalid-ip".parse().unwrap()); + + let err = + get_ip_from_unique_header(&headers, header_name).expect_err("Invalid IP should fail"); + assert!(matches!(err, IpError::InvalidValue)); + } + + #[test] + fn test_unique_header_empty_value() { + let header_name = "X-Real-IP"; + let mut headers = HeaderMap::new(); + headers.insert(header_name, "".parse().unwrap()); + + let err = + get_ip_from_unique_header(&headers, header_name).expect_err("Invalid IP should fail"); + assert!(matches!(err, IpError::InvalidValue)); + } + + #[test] + fn test_rightmost_header_comma_separated() { + let header_name = "X-Forwarded-For"; + let ip1 = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)); + let ip2 = IpAddr::V4(Ipv4Addr::new(2, 2, 2, 2)); + let ip3 = IpAddr::V4(Ipv4Addr::new(3, 3, 3, 3)); + + let mut headers = HeaderMap::new(); + headers.insert(header_name, format!("{},{},{}", ip1, ip2, ip3).parse().unwrap()); + + let ip = get_ip_from_rightmost_value(&headers, header_name, 1).unwrap(); + assert_eq!(ip, ip3); + + let ip = get_ip_from_rightmost_value(&headers, header_name, 2).unwrap(); + assert_eq!(ip, ip2); + + let ip = get_ip_from_rightmost_value(&headers, header_name, 3).unwrap(); + assert_eq!(ip, ip1); + + let err = get_ip_from_rightmost_value(&headers, header_name, 4) + .expect_err("Not enough values should fail"); + assert!(matches!(err, IpError::NotEnoughValues { .. })); + } + + #[test] + fn test_rightmost_header_comma_space_separated() { + let header_name = "X-Forwarded-For"; + let ip1 = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)); + let ip2 = IpAddr::V4(Ipv4Addr::new(2, 2, 2, 2)); + let ip3 = IpAddr::V4(Ipv4Addr::new(3, 3, 3, 3)); + + let mut headers = HeaderMap::new(); + headers.insert(header_name, format!("{}, {}, {}", ip1, ip2, ip3).parse().unwrap()); + + let ip = get_ip_from_rightmost_value(&headers, header_name, 1).unwrap(); + assert_eq!(ip, ip3); + + let ip = get_ip_from_rightmost_value(&headers, header_name, 2).unwrap(); + assert_eq!(ip, ip2); + + let ip = get_ip_from_rightmost_value(&headers, header_name, 3).unwrap(); + assert_eq!(ip, ip1); + + let err = get_ip_from_rightmost_value(&headers, header_name, 4) + .expect_err("Not enough values should fail"); + assert!(matches!(err, IpError::NotEnoughValues { .. })); + } + + #[test] + fn test_rightmost_header_duplicated() { + // If the header appears multiple times, they should be joined together + // as if they were a single value. + let header_name = "X-Forwarded-For"; + let ip1 = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)); + let ip2 = IpAddr::V4(Ipv4Addr::new(2, 2, 2, 2)); + let ip3 = IpAddr::V4(Ipv4Addr::new(3, 3, 3, 3)); + let ip4 = IpAddr::V4(Ipv4Addr::new(4, 4, 4, 4)); + let ip5 = IpAddr::V4(Ipv4Addr::new(5, 5, 5, 5)); + + let mut headers = HeaderMap::new(); + headers.insert(header_name, format!("{},{},{}", ip1, ip2, ip3).parse().unwrap()); + headers.append(header_name, format!("{},{}", ip4, ip5).parse().unwrap()); + + let ip = get_ip_from_rightmost_value(&headers, header_name, 1).unwrap(); + assert_eq!(ip, ip5); + + let ip = get_ip_from_rightmost_value(&headers, header_name, 5).unwrap(); + assert_eq!(ip, ip1); + + let err = get_ip_from_rightmost_value(&headers, header_name, 6) + .expect_err("Not enough values should fail"); + assert!(matches!(err, IpError::NotEnoughValues { .. })); + } + + #[test] + fn test_rightmost_header_not_present() { + let header_name = "X-Forwarded-For"; + let headers = HeaderMap::new(); + + let err = get_ip_from_rightmost_value(&headers, header_name, 1) + .expect_err("Missing header should fail"); + assert!(matches!(err, IpError::NotPresent(_))); + } + + #[test] + fn test_rightmost_header_invalid_value() { + let header_name = "X-Forwarded-For"; + let mut headers = HeaderMap::new(); + headers.insert(header_name, "invalid-ip".parse().unwrap()); + + let err = get_ip_from_rightmost_value(&headers, header_name, 1) + .expect_err("Invalid IP should fail"); + assert!(matches!(err, IpError::InvalidValue)); + } +} diff --git a/docs/docs/developing/prop-commit-signing.md b/docs/docs/developing/prop-commit-signing.md new file mode 100644 index 00000000..30f70413 --- /dev/null +++ b/docs/docs/developing/prop-commit-signing.md @@ -0,0 +1,76 @@ +# Requesting Proposer Commitment Signatures with Commit-Boost + +When you create a new validator on the Ethereum network, one of the steps is the generation of a new BLS private key (commonly known as the "validator key" or the "signer key") and its corresponding BLS public key (the "validator pubkey", used as an identifier). Typically this private key will be used by an Ethereum consensus client to sign things such as attestations and blocks for publication on the Beacon chain. These signatures prove that you, as the owner of that private key, approve of the data being signed. However, as general-purpose private keys, they can also be used to sign *other* arbitrary messages not destined for the Beacon chain. + +Commit-Boost takes advantage of this by offering a standard known as **proposer commitments**. These are arbitrary messages (albeit with some important rules), similar to the kind used on the Beacon chain, that have been signed by one of the owner's private keys. Modules interested in leveraging Commit-Boost's proposer commitments can construct their own data in whatever format they like and request that Commit-Boost's **signer service** generate a signature for it with a particular private key. The module can then use that signature to verify the data was signed by that user. + +Commit-Boost supports proposer commitment signatures for both BLS private keys (identified by their public key) and ECDSA private keys (identified by their Ethereum address). + + +## Rules of Proposer Commitment Signatures + +Proposer commitment signatures produced by Commit-Boost's signer service conform to the following rules: + +- Signatures are **unique** to a given EVM chain (identified by its [chain ID](https://chainlist.org/)). Signatures generated for one chain will not work on a different chain. +- Signatures are **unique** to Commit-Boost proposer commitments. The signer service **cannot** be used to create signatures that could be used for other applications, such as for attestations on the Beacon chain. While the signer service has access to the same validator private keys used to attest on the Beacon chain, it cannot create signatures that would get you slashed on the Beacon chain. +- Signatures are **unique** to a particular module. One module cannot, for example, request an identical payload as another module and effectively "forge" a signature for the second module; identical payloads from two separate modules will result in two separate signatures. +- The data payload being signed must be a **32-byte array**, typically serializd as a 64-character hex string with an optional `0x` prefix. The value itself is arbitrary, as long as it has meaning to the requester - though it is typically the 256-bit hash of some kind of data. +- If requesting a signature from a BLS key, the resulting signature will be a standard BLS signature (96 bytes in length). +- If requesting a signature from an ECDSA key, the resulting signature will be a standard Ethereum RSV signature (65 bytes in length). +- Signatures **may** be **unique** per request, using the optional `nonce` field in their requests to indicate a unique sequence that this signature belongs to. + + +## Configuring a Module for Proposer Commitments + +Commit-Boost's signer service must be configured prior to launching to expect requests from your module. There are two main parts: + +1. An entry for your module into [Commit-Boost's configuration file](../get_started/configuration.md#custom-module). This must include a unique ID for your module, the line `type = "commit"`, and include a unique [signing ID](#the-signing-id) for your module. Generally you should provide values for these in your documentation, so your users can reference it when configuring their own Commit-Boost node. + +2. A JWT secret used by your module to authenticate with the signer in HTTP requests. This must be a string that both the Commit-Boost signer can read and your module can read, but no other modules should be allowed to access it. The user should be responsible for determining an appropriate secret and providing it to the Commit-Boost signer service securely; your module will need some way to accept this, typically via a command line argument that accepts a path to a file with the secret or as an environment variable. + +Once the user has configured both Commit-Boost and your module with these settings, your module will be able to authenticate with the signer service and request signatures. + + +## The Signing ID + +Your module's signing ID is a 32-byte value that is used as a unique identifier within the signing process. Proposer commitment signatures incorporate this value along with the data being signed as a way to create signatures that are exclusive to your module, so other modules can't maliciously construct signatures that appear to be from your module. Your module must have this ID incorporated into itself ahead of time, and the user must include this same ID within their Commit-Boost configuration file section for your module. Commit-Boost does not maintain a global registry of signing IDs, so this is a value you should provide to your users in your documentation. + +The Signing ID is decoupled from your module's human-readable name (the `module_id` field in the Commit-Boost configuration file) so that any changes to your module name will not invalidate signatures from previous versions. Similarly, if you don't change the module ID but *want* to invalidate previous signatures, you can modify the signing ID and it will do so. Just ensure your users are made aware of the change, so they can update it in their Commit-Boost configuration files accordingly. + + +## Nonces + +Your module has the option of using **Nonces** for each of its signature requests. Nonces are intended to be unique values that establish a sequence of signature requests, distinguishing one signature from another - even if all of their other payload information is identical. When making a request for a signature, you may include a unique nonce as part of the request; the signature will include it in its data, ensuring that things like replay attacks cannot be used for that signature. + +If you want to use them within your module, your module (or whatever remote backend system it connects to) **will be responsible** for storing, comparing, validating, and otherwise using the nonces. Commit-Boost's signer service by itself **does not** store nonces or track which ones have already been used by a given module. + +In terms of implementation, the nonce format conforms to the specification in [EIP-2681](https://eips.ethereum.org/EIPS/eip-2681). It is an unsigned 64-bit integer, with a minimum value of 0 and a maximum value of `2^64-2`. The field is required and is always mixed into the signing root. Modules that do not use nonces for replay protection should always send `0`; modules that do should use a monotonically increasing value per key. + + +## Structure of a Signature + +The form proposer commitment signatures take depends on the type of signature being requested. BLS signatures take the [standard form](https://eth2book.info/latest/part2/building_blocks/signatures/) (96-byte values). ECDSA (Ethereum EL) signatures take the [standard Ethereum ECDSA `r,s,v` signature form](https://forum.openzeppelin.com/t/sign-it-like-you-mean-it-creating-and-verifying-ethereum-signatures/697). In both cases, the data being signed is a 32-byte hash - the root hash of a composite two-stage [SSZ Merkle tree](https://thogiti.github.io/2024/05/02/Merkleization.html), described below: + +
+ + + +
+ +where, for the sub-tree in blue: + +- `Request Data` is a 32-byte array that serves as the data you want to sign. This is typically a hash of some more complex data on its own that your module constructs. + +- `Signing ID` is your module's 32-byte signing ID. The signer service will load this for your module from its configuration file. + +- `Nonce` is the nonce value for the signature request. This field is required. Modules that do not use replay protection should always send `0`; modules that do should use a monotonically increasing value per key. Conforming with the tree specification, it must be added as a 256-bit unsigned little-endian integer. Most libraries will be able to do this conversion automatically if you specify the field as the language's primitive for 64-bit unsigned integers (e.g., `uint64`, `u64`, `ulong`, etc.). + +- `Chain ID` is the ID of the chain that the Signer service is currently configured to use, as indicated by the [Commit-Boost configuration file](../get_started/configuration.md). This must also be a 256-bit unsigned little-endian integer. + +A Merkle tree must be constructed from these four leaf nodes, and its root hash calculated according to the standard SSZ hash computation rules. This result will be called the "sub-tree root". With this, a second Merkle tree is created using this sub-tree root and a value called the Domain: + +- `Domain` is the 32-byte output of the [compute_domain()](https://eth2book.info/capella/part2/building_blocks/signatures/#domain-separation-and-forks) function in the Beacon specification. The 4-byte domain type in this case is not a standard Beacon domain type, but rather Commit Boost's own domain type: `0x6D6D6F43`. + +The data signed in a proposer commitment is the 32-byte hash root of this new tree (the green `Root` box). + +Many languages provide libraries for computing the root of an SSZ Merkle tree, such as [fastssz for Go](https://github.com/ferranbt/fastssz) or [tree_hash for Rust](https://docs.rs/tree_hash/latest/tree_hash/). When verifying proposer commitment signatures, use a library that supports Merkle tree root hashing, the `compute_domain()` operation, and validation for signatures generated by your key of choice. diff --git a/docs/docs/get_started/configuration.md b/docs/docs/get_started/configuration.md index 9764f821..7eefb277 100644 --- a/docs/docs/get_started/configuration.md +++ b/docs/docs/get_started/configuration.md @@ -358,6 +358,70 @@ Delegation signatures will be stored in files with the format `/deleg A full example of a config file with Dirk can be found [here](https://github.com/Commit-Boost/commit-boost-client/blob/main/examples/configs/dirk_signer.toml). + +### TLS + +By default, the Signer service runs in **insecure** mode, so its API service uses HTTP without any TLS encryption. This is sufficient for testing or if you're running locally within your machine's isolated Docker network and only intend to access it within the confines of your machine. However, for larger production setups, it's recommended to enable TLS - especially for traffic that spans across multiple machines. + +The Signer service in TLS mode supports **TLS 1.2** and **TLS 1.3**. Older protocol versions are not supported. + +To enable TLS, you must first create a **certificate / key pair**. We **strongly advise** using a well-known Certificate Authority to create and sign the certificate, such as [Let's Encrypt](https://letsencrypt.org/getting-started/) (a free service) or [Bluehost](https://www.bluehost.com/help/article/how-to-set-up-an-ssl-certificate-for-website-security) (free but requires an account). We do not recommend using a self-signed ceriticate / key pair for production environments. + +When configuring TLS support, the Signer service expects a single folder (which you can specify) that contains the following two files: +- `cert.pem`: The SSL certificate file signed by a certificate authority, in PEM format +- `key.pem`: The private key corresponding to `cert.pem` that will be used for signing TLS traffic, in PEM format + +Specifying it is done within Commit-Boost's configuration file using the `[signer.tls_mode]` table as follows: + +```toml +[pbs] +... +with_signer = true + +[signer] +port = 20000 +... + +[signer.tls_mode] +type = "certificate" +path = "path/to/your/cert/folder" +``` + +Where `path` is the aforementioned folder. It defaults to `./certs` but can be replaced with whichever directory your certificate and private key file reside in, as long as they're readable by the Signer service (or its Docker container, if using Docker). + +### Rate limit + +The Signer service implements a rate limit system of 3 failed authentications every 5 minutes. These values can be modified in the config file: + +```toml +[signer] +... +jwt_auth_fail_limit = 3 # The amount of failed requests allowed +jwt_auth_fail_timeout_seconds = 300 # The time window in seconds +``` + +The rate limit is applied to the IP address of the client making the request. By default, the IP is extracted directly from the TCP connection. If you're running the Signer service behind a reverse proxy (e.g. Nginx), you can configure it to extract the IP from a custom HTTP header instead. There're two options: + +- unique: Provides an HTTP header that contains the IP. This header is expected to appear only once in the request. This is common when using `X-Real-IP`, `True-Client-IP`, etc. If a request has multiple values for this header, it will be considered invalid and rejected. +- `rightmost`: Provides an HTTP header that contains a comma-separated list of IPs. The nth rightmost IP in the list is used. If the header appears multiple times, the last occurrence is used. This is common when using `X-Forwarded-For`. + +Examples: + +```toml +[signer.reverse_proxy] +type = "unique" +header = "X-Real-IP" +``` + +```toml +[signer.reverse_proxy] +type = "rightmost" +header = "X-Forwarded-For" +trusted_count = 1 +``` + +Note: `trusted_count` is the number of trusted proxies in front of the Signer service, but the last proxy won't add its address, so the number of skipped IPs is `trusted_count - 1`. See [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-For#trusted_proxy_count) for more info. + ## Custom module We currently provide a test module that needs to be built locally. To build the module run: @@ -396,16 +460,18 @@ enabled = true id = "DA_COMMIT" type = "commit" docker_image = "test_da_commit" +signing_id = "0x6a33a23ef26a4836979edff86c493a69b26ccf0b4a16491a815a13787657431b" sleep_secs = 5 ``` A few things to note: - We now added a `signer` section which will be used to create the Signer module. -- There is now a `[[modules]]` section which at a minimum needs to specify the module `id`, `type` and `docker_image`. Additional parameters needed for the business logic of the module will also be here, +- There is now a `[[modules]]` section which at a minimum needs to specify the module `id`, `type` and `docker_image`. For modules with type `commit`, which will be used to access the Signer service and request signatures for preconfs, you will also need to specify the module's unique `signing_id` (see [the propser commitment documentation](../developing/prop-commit-signing.md)). Additional parameters needed for the business logic of the module will also be here. To learn more about developing modules, check out [here](/category/developing). + ## Vouch [Vouch](https://github.com/attestantio/vouch) is a multi-node validator client built by [Attestant](https://www.attestant.io/). Vouch is particular in that it also integrates an MEV-Boost client to interact with relays. The Commit-Boost PBS module is compatible with the Vouch `blockrelay` since it implements the same Builder-API as relays. For example, depending on your setup and preference, you may want to fetch headers from a given relay using Commit-Boost vs using the built-in Vouch `blockrelay`. @@ -452,6 +518,42 @@ Commit-Boost supports hot-reloading the configuration file. This means that you docker compose -f cb.docker-compose.yml exec cb_signer curl -X POST http://localhost:20000/reload ``` +### Signer module reload + +When the signer receives a reload request it re-reads the configuration file and environment variables, rebuilding its internal state to match: + +- **New modules** added to the config are registered with the signer. +- **Removed modules** are dropped from the signer's access list. +- **JWT secrets and admin secret** are reset to their current values from the environment variables. +- Any runtime changes from previous `/revoke_jwt` or `/reload` calls are reverted. + +The request body accepts 2 optional override parameters, applied on top of the config: + +- `jwt_secrets`: a comma-separated list of `=` pairs to override specific module secrets. Only modules present in the config can be overridden. +- `admin_secret`: a string to override the admin JWT secret. + +If the body is empty, the signer state is simply synced to match the config. + +#### Common patterns + +**Add a new module without restarting:** +1. Add the `[[modules]]` entry to `cb-config.toml`. +2. Set the new module's JWT secret in the signer's environment (`CB_SIGNER_JWT_SECRETS`). +3. Send `POST /reload` with an empty body. The signer picks up the new module from config. +4. Start the new module container with the matching JWT secret. + +**Rotate a JWT secret remotely:** +Send `POST /reload` with the new secret in the body. The module must already exist in the config. This is useful for scripted rotation without SSH access to edit config files. + +**Revoke a compromised module immediately:** +Send `POST /revoke_jwt` with the module ID. This removes the module from the signer's access list without touching the config. The next `/reload` will restore the module if it is still in the config, so remove it from config as well if the revocation should be permanent. + +#### Footguns + +- **Body overrides are not persisted.** If the signer crashes or restarts after a body-based secret rotation, it falls back to the config/environment values. The module container will still have the rotated secret and authentication will fail. To avoid this, update the environment variable to match after rotating via the body. +- **Reload reverts revocations.** If you revoke a module with `/revoke_jwt` but leave it in the config, the next `/reload` without a body override will re-add it from the config baseline. Always remove revoked modules from the config to make the revocation permanent. +- **Override validation is strict.** If the body references a module ID that does not exist in the config, the entire reload request is rejected and no changes are applied. This prevents typos from silently failing. + ### Notes - The hot reload feature is available for PBS modules (both default and custom) and signer module. diff --git a/docs/docs/get_started/running/binary.md b/docs/docs/get_started/running/binary.md index 74a09373..8f51fe65 100644 --- a/docs/docs/get_started/running/binary.md +++ b/docs/docs/get_started/running/binary.md @@ -28,7 +28,9 @@ Modules need some environment variables to work correctly. ### Signer Module +- `CB_SIGNER_ADMIN_JWT`: secret to use for admin JWT. - `CB_SIGNER_ENDPOINT`: optional, override to specify the `IP:port` endpoint to bind the signer server to. +- `CB_SIGNER_TLS_CERTIFICATES`: path to the TLS certificates for the server. - For loading keys we currently support: - `CB_SIGNER_LOADER_FILE`: path to a `.json` with plaintext keys (for testing purposes only). - `CB_SIGNER_LOADER_FORMAT`, `CB_SIGNER_LOADER_KEYS_DIR` and `CB_SIGNER_LOADER_SECRETS_DIR`: paths to the `keys` and `secrets` directories or files (ERC-2335 style keystores, see [Signer config](../configuration/#signer-module) for more info). diff --git a/docs/docs/res/img/prop_commit_tree.png b/docs/docs/res/img/prop_commit_tree.png new file mode 100644 index 0000000000000000000000000000000000000000..2c0b181578513700d3e51c1c77f40aa5ec17e47f GIT binary patch literal 96528 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 literal 0 HcmV?d00001 diff --git a/examples/da_commit/src/main.rs b/examples/da_commit/src/main.rs index 27b5ce86..646ace45 100644 --- a/examples/da_commit/src/main.rs +++ b/examples/da_commit/src/main.rs @@ -1,6 +1,6 @@ use std::time::Duration; -use alloy::primitives::Address; +use alloy::primitives::{Address, B256, b256}; use commit_boost::prelude::*; use eyre::{OptionExt, Result}; use lazy_static::lazy_static; @@ -9,6 +9,13 @@ use serde::Deserialize; use tokio::time::sleep; use tracing::{error, info}; +// This is the signing ID used for the DA Commit module. +// Signatures produced by the signer service will incorporate this ID as part of +// the signature, preventing other modules from using the same signature for +// different purposes. +pub const DA_COMMIT_SIGNING_ID: B256 = + b256!("0x6a33a23ef26a4836979edff86c493a69b26ccf0b4a16491a815a13787657431b"); + // You can define custom metrics and a custom registry for the business logic of // your module. These will be automatically scaped by the Prometheus server lazy_static! { @@ -25,6 +32,7 @@ struct Datagram { struct DaCommitService { config: StartCommitModuleConfig, + nonce: u64, } // Extra configurations parameters can be set here and will be automatically @@ -84,26 +92,65 @@ impl DaCommitService { ) -> Result<()> { let datagram = Datagram { data }; - let request = SignConsensusRequest::builder(pubkey).with_msg(&datagram); - let signature = self.config.signer_client.request_consensus_signature(request).await?; - - info!("Proposer commitment (consensus): {}", signature); + // Request a signature directly from a BLS key + let request = SignConsensusRequest::builder(pubkey.clone()).with_msg(&datagram); + let response = self.config.signer_client.request_consensus_signature(request).await?; + info!("Proposer commitment (consensus): {}", response.signature); + if verify_proposer_commitment_signature_bls( + self.config.chain, + &pubkey, + &datagram, + &response.signature, + &DA_COMMIT_SIGNING_ID, + self.nonce, + ) { + info!("Signature verified successfully"); + } else { + error!("Signature verification failed"); + } + self.nonce += 1; - let proxy_request_bls = SignProxyRequest::builder(proxy_bls).with_msg(&datagram); - let proxy_signature_bls = + // Request a signature from a proxy BLS key + let proxy_request_bls = SignProxyRequest::builder(proxy_bls.clone()).with_msg(&datagram); + let proxy_response_bls = self.config.signer_client.request_proxy_signature_bls(proxy_request_bls).await?; + info!("Proposer commitment (proxy BLS): {}", proxy_response_bls.signature); + if verify_proposer_commitment_signature_bls( + self.config.chain, + &proxy_bls, + &datagram, + &proxy_response_bls.signature, + &DA_COMMIT_SIGNING_ID, + self.nonce, + ) { + info!("Signature verified successfully"); + } else { + error!("Signature verification failed"); + } + self.nonce += 1; - info!("Proposer commitment (proxy BLS): {}", proxy_signature_bls); - + // If ECDSA keys are enabled, request a signature from a proxy ECDSA key if let Some(proxy_ecdsa) = proxy_ecdsa { let proxy_request_ecdsa = SignProxyRequest::builder(proxy_ecdsa).with_msg(&datagram); - let proxy_signature_ecdsa = self + let proxy_response_ecdsa = self .config .signer_client .request_proxy_signature_ecdsa(proxy_request_ecdsa) .await?; - info!("Proposer commitment (proxy ECDSA): {}", proxy_signature_ecdsa); + info!("Proposer commitment (proxy ECDSA): {}", proxy_response_ecdsa.signature); + match verify_proposer_commitment_signature_ecdsa( + self.config.chain, + &proxy_ecdsa, + &datagram, + &proxy_response_ecdsa.signature, + &DA_COMMIT_SIGNING_ID, + self.nonce, + ) { + Ok(_) => info!("Signature verified successfully"), + Err(err) => error!(%err, "Signature verification failed"), + }; } + self.nonce += 1; SIG_RECEIVED_COUNTER.inc(); @@ -131,7 +178,7 @@ async fn main() -> Result<()> { "Starting module with custom data" ); - let mut service = DaCommitService { config }; + let mut service = DaCommitService { config, nonce: 0 }; if let Err(err) = service.run().await { error!(%err, "Service failed"); diff --git a/justfile b/justfile index a0df34d6..b4bd1b14 100644 --- a/justfile +++ b/justfile @@ -17,6 +17,7 @@ checklist: just fmt just clippy just test + cargo audit # =================================== # === Build Commands for Services === diff --git a/provisioning/grafana/signer_public_dashboard.json b/provisioning/grafana/signer_public_dashboard.json index 327d48bb..4b904b1a 100644 --- a/provisioning/grafana/signer_public_dashboard.json +++ b/provisioning/grafana/signer_public_dashboard.json @@ -539,13 +539,19 @@ "list": [ { "current": { - "text": "$__all", + "selected": true, + "text": "All", "value": "$__all" }, "description": "SignerAPI endpoint", "includeAll": true, "name": "endpoint", "options": [ + { + "selected": true, + "text": "All", + "value": "$__all" + }, { "selected": false, "text": "get_pubkeys", @@ -558,11 +564,21 @@ }, { "selected": false, - "text": "request_signature", - "value": "request_signature" + "text": "request_signature_bls", + "value": "request_signature_bls" + }, + { + "selected": false, + "text": "request_signature_proxy_bls", + "value": "request_signature_proxy_bls" + }, + { + "selected": false, + "text": "request_signature_proxy_ecdsa", + "value": "request_signature_proxy_ecdsa" } ], - "query": "get_pubkeys, generate_proxy_key, request_signature", + "query": "get_pubkeys, generate_proxy_key, request_signature_bls, request_signature_proxy_bls, request_signature_proxy_ecdsa", "type": "custom" } ] diff --git a/tests/Cargo.toml b/tests/Cargo.toml index cceba3bc..c1c51f58 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -11,7 +11,9 @@ cb-common.workspace = true cb-pbs.workspace = true cb-signer.workspace = true eyre.workspace = true +jsonwebtoken.workspace = true lh_types.workspace = true +rcgen.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true @@ -25,4 +27,5 @@ tree_hash.workspace = true url.workspace = true [dev-dependencies] -cb-common = { path = "../crates/common", features = ["testing-flags"] } \ No newline at end of file +tracing-test.workspace = true +cb-common = { path = "../crates/common", features = ["testing-flags"] } diff --git a/tests/data/configs/signer.happy.toml b/tests/data/configs/signer.happy.toml new file mode 100644 index 00000000..6fb76445 --- /dev/null +++ b/tests/data/configs/signer.happy.toml @@ -0,0 +1,52 @@ +chain = "Hoodi" + +[pbs] +docker_image = "ghcr.io/commit-boost/pbs:latest" +with_signer = true +host = "127.0.0.1" +port = 18550 +relay_check = true +wait_all_registrations = true +timeout_get_header_ms = 950 +timeout_get_payload_ms = 4000 +timeout_register_validator_ms = 3000 +skip_sigverify = false +min_bid_eth = 0.5 +late_in_slot_time_ms = 2000 +extra_validation_enabled = false +rpc_url = "https://ethereum-holesky-rpc.publicnode.com" + +[[relays]] +id = "example-relay" +url = "http://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@abc.xyz" +headers = { X-MyCustomHeader = "MyCustomHeader" } +enable_timing_games = false +target_first_request_ms = 200 +frequency_get_header_ms = 300 + +[signer] +docker_image = "ghcr.io/commit-boost/signer:latest" +host = "127.0.0.1" +port = 20000 +jwt_auth_fail_limit = 3 +jwt_auth_fail_timeout_seconds = 300 + +[signer.local.loader] +key_path = "./tests/data/keys.example.json" + +[signer.local.store] +proxy_dir = "./proxies" + +[[modules]] +id = "test-module" +signing_id = "0x6a33a23ef26a4836979edff86c493a69b26ccf0b4a16491a815a13787657431b" +type = "commit" +docker_image = "test_da_commit" +env_file = ".cb.env" + +[[modules]] +id = "another-module" +signing_id = "0x61fe00135d7b4912a8c63ada215ac2e62326e6e7b30f49a29fcf9779d7ad800d" +type = "commit" +docker_image = "test_da_commit" +env_file = ".cb.env" diff --git a/tests/src/lib.rs b/tests/src/lib.rs index d332711f..42eec95a 100644 --- a/tests/src/lib.rs +++ b/tests/src/lib.rs @@ -2,4 +2,5 @@ pub mod mock_relay; pub mod mock_ssv_node; pub mod mock_ssv_public; pub mod mock_validator; +pub mod signer_service; pub mod utils; diff --git a/tests/src/mock_relay.rs b/tests/src/mock_relay.rs index 75532666..4d7f0fc1 100644 --- a/tests/src/mock_relay.rs +++ b/tests/src/mock_relay.rs @@ -153,7 +153,7 @@ async fn handle_get_header( }); let object_root = message.tree_hash_root(); - let signature = sign_builder_root(state.chain, &state.signer, object_root); + let signature = sign_builder_root(state.chain, &state.signer, &object_root); let response = SignedBuilderBid { message, signature }; let response = GetHeaderResponse { diff --git a/tests/src/signer_service.rs b/tests/src/signer_service.rs new file mode 100644 index 00000000..550ac4ce --- /dev/null +++ b/tests/src/signer_service.rs @@ -0,0 +1,98 @@ +use std::{collections::HashMap, time::Duration}; + +use cb_common::{ + commit::{constants::STATUS_PATH, request::GetPubkeysResponse}, + config::{ModuleSigningConfig, StartSignerConfig}, + signer::{SignerLoader, ValidatorKeysFormat}, + types::{Chain, ModuleId}, + utils::bls_pubkey_from_hex, +}; +use cb_signer::service::SigningService; +use eyre::Result; +use reqwest::{Certificate, Response, StatusCode}; +use tracing::info; + +use crate::utils::{get_signer_config, get_start_signer_config}; + +// Starts the signer moduler server on a separate task and returns its +// configuration +pub async fn start_server( + port: u16, + mod_signing_configs: &HashMap, + admin_secret: String, + use_tls: bool, +) -> Result { + let chain = Chain::Hoodi; + + // Create a signer config + let loader = SignerLoader::ValidatorsDir { + keys_path: "data/keystores/keys".into(), + secrets_path: "data/keystores/secrets".into(), + format: ValidatorKeysFormat::Lighthouse, + }; + let mut config = get_signer_config(loader, use_tls); + config.port = port; + config.jwt_auth_fail_limit = 3; // Set a low fail limit for testing + config.jwt_auth_fail_timeout_seconds = 3; // Set a short timeout for testing + let start_config = get_start_signer_config(config, chain, mod_signing_configs, admin_secret); + + // Run the Signer + let server_handle = tokio::spawn(SigningService::run(start_config.clone())); + + // Wait for the server to start + let (url, client) = match start_config.tls_certificates { + Some(ref certificates) => { + let url = format!("https://{}{}", start_config.endpoint, STATUS_PATH); + let client = reqwest::Client::builder() + .add_root_certificate(Certificate::from_pem(&certificates.0)?) + .build()?; + (url, client) + } + None => { + let url = format!("http://{}{}", start_config.endpoint, STATUS_PATH); + (url, reqwest::Client::new()) + } + }; + + let sleep_duration = Duration::from_millis(100); + for i in 0..100 { + // 10 second max wait + if i > 0 { + tokio::time::sleep(sleep_duration).await; + } + match client.get(&url).send().await { + Ok(_) => { + return Ok(start_config); + } + Err(e) => { + info!("Waiting for signer service to start: {}", e); + } + } + } + Err(eyre::eyre!("Signer service failed to start: {}", server_handle.await.unwrap_err())) +} + +// Verifies that the pubkeys returned by the server match the pubkeys in the +// test data +pub async fn verify_pubkeys(response: Response) -> Result<()> { + // Verify the expected pubkeys are returned + assert!(response.status() == StatusCode::OK); + let pubkey_json = response.json::().await?; + assert_eq!(pubkey_json.keys.len(), 2); + let expected_pubkeys = vec![ + bls_pubkey_from_hex( + "883827193f7627cd04e621e1e8d56498362a52b2a30c9a1c72036eb935c4278dee23d38a24d2f7dda62689886f0c39f4", + )?, + bls_pubkey_from_hex( + "b3a22e4a673ac7a153ab5b3c17a4dbef55f7e47210b20c0cbb0e66df5b36bb49ef808577610b034172e955d2312a61b9", + )?, + ]; + for expected in expected_pubkeys { + assert!( + pubkey_json.keys.iter().any(|k| k.consensus == expected), + "Expected pubkey not found: {expected}" + ); + info!("Server returned expected pubkey: {:?}", expected); + } + Ok(()) +} diff --git a/tests/src/utils.rs b/tests/src/utils.rs index 253007c7..e079ef11 100644 --- a/tests/src/utils.rs +++ b/tests/src/utils.rs @@ -1,15 +1,18 @@ use std::{ collections::HashMap, net::{Ipv4Addr, SocketAddr}, + path::PathBuf, sync::{Arc, Once}, }; -use alloy::primitives::U256; +use alloy::primitives::{B256, U256}; use cb_common::{ config::{ - PbsConfig, PbsModuleConfig, RelayConfig, SIGNER_IMAGE_DEFAULT, + CommitBoostConfig, LogsSettings, ModuleKind, ModuleSigningConfig, PbsConfig, + PbsModuleConfig, RelayConfig, ReverseProxyHeaderSetup, SIGNER_IMAGE_DEFAULT, SIGNER_JWT_AUTH_FAIL_LIMIT_DEFAULT, SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_DEFAULT, - SIGNER_PORT_DEFAULT, SignerConfig, SignerType, StartSignerConfig, + SIGNER_PORT_DEFAULT, SignerConfig, SignerType, StartSignerConfig, StaticModuleConfig, + StaticPbsConfig, TlsMode, }, pbs::{RelayClient, RelayEntry}, signer::SignerLoader, @@ -17,6 +20,7 @@ use cb_common::{ utils::{bls_pubkey_from_hex, default_host}, }; use eyre::Result; +use rcgen::generate_simple_self_signed; use url::Url; pub fn get_local_address(port: u16) -> String { @@ -66,7 +70,7 @@ pub fn generate_mock_relay_with_batch_size( RelayClient::new(config) } -pub fn get_pbs_static_config(port: u16) -> PbsConfig { +pub fn get_pbs_config(port: u16) -> PbsConfig { PbsConfig { host: Ipv4Addr::UNSPECIFIED, port, @@ -89,6 +93,23 @@ pub fn get_pbs_static_config(port: u16) -> PbsConfig { } } +pub fn get_pbs_static_config(pbs_config: PbsConfig) -> StaticPbsConfig { + StaticPbsConfig { docker_image: String::from(""), pbs_config, with_signer: true } +} + +pub fn get_commit_boost_config(pbs_static_config: StaticPbsConfig) -> CommitBoostConfig { + CommitBoostConfig { + chain: Chain::Hoodi, + relays: vec![], + pbs: pbs_static_config, + muxes: None, + modules: Some(vec![]), + signer: None, + metrics: None, + logs: LogsSettings::default(), + } +} + pub fn to_pbs_config( chain: Chain, pbs_config: PbsConfig, @@ -106,7 +127,7 @@ pub fn to_pbs_config( } } -pub fn get_signer_config(loader: SignerLoader) -> SignerConfig { +pub fn get_signer_config(loader: SignerLoader, tls: bool) -> SignerConfig { SignerConfig { host: default_host(), port: SIGNER_PORT_DEFAULT, @@ -114,29 +135,60 @@ pub fn get_signer_config(loader: SignerLoader) -> SignerConfig { jwt_auth_fail_limit: SIGNER_JWT_AUTH_FAIL_LIMIT_DEFAULT, jwt_auth_fail_timeout_seconds: SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_DEFAULT, inner: SignerType::Local { loader, store: None }, + tls_mode: if tls { TlsMode::Certificate(PathBuf::new()) } else { TlsMode::Insecure }, + reverse_proxy: ReverseProxyHeaderSetup::None, } } pub fn get_start_signer_config( signer_config: SignerConfig, chain: Chain, - jwts: HashMap, + mod_signing_configs: &HashMap, + admin_secret: String, ) -> StartSignerConfig { + let tls_certificates = match signer_config.tls_mode { + TlsMode::Insecure => None, + TlsMode::Certificate(_) => Some( + generate_simple_self_signed(vec![signer_config.host.to_string()]) + .map(|x| { + ( + x.cert.pem().as_bytes().to_vec(), + x.key_pair.serialize_pem().as_bytes().to_vec(), + ) + }) + .expect("Failed to generate TLS certificate"), + ), + }; + match signer_config.inner { SignerType::Local { loader, .. } => StartSignerConfig { chain, loader: Some(loader), store: None, endpoint: SocketAddr::new(signer_config.host.into(), signer_config.port), - jwts, + mod_signing_configs: mod_signing_configs.clone(), + admin_secret, jwt_auth_fail_limit: signer_config.jwt_auth_fail_limit, jwt_auth_fail_timeout_seconds: signer_config.jwt_auth_fail_timeout_seconds, dirk: None, + tls_certificates, + reverse_proxy: ReverseProxyHeaderSetup::None, }, _ => panic!("Only local signers are supported in tests"), } } +pub fn create_module_config(id: ModuleId, signing_id: B256) -> StaticModuleConfig { + StaticModuleConfig { + id, + signing_id, + docker_image: String::from(""), + env: None, + env_file: None, + kind: ModuleKind::Commit, + } +} + pub fn bls_pubkey_from_hex_unchecked(hex: &str) -> BlsPublicKey { bls_pubkey_from_hex(hex).unwrap() } diff --git a/tests/tests/pbs_cfg_file_update.rs b/tests/tests/pbs_cfg_file_update.rs index a1d4b94f..770421a3 100644 --- a/tests/tests/pbs_cfg_file_update.rs +++ b/tests/tests/pbs_cfg_file_update.rs @@ -11,7 +11,7 @@ use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; use cb_tests::{ mock_relay::{MockRelayState, start_mock_relay_service}, mock_validator::MockValidator, - utils::{generate_mock_relay, get_pbs_static_config, setup_test_env, to_pbs_config}, + utils::{generate_mock_relay, get_pbs_config, setup_test_env, to_pbs_config}, }; use eyre::Result; use reqwest::StatusCode; @@ -102,7 +102,7 @@ async fn test_cfg_file_update() -> Result<()> { std::fs::write(config_path.clone(), config_toml.as_bytes())?; // Run the PBS service - let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), vec![relay1.clone()]); + let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![relay1.clone()]); let state = PbsState::new(config, config_path.clone()); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); diff --git a/tests/tests/pbs_get_header.rs b/tests/tests/pbs_get_header.rs index 5ae4b656..1cfdc3bb 100644 --- a/tests/tests/pbs_get_header.rs +++ b/tests/tests/pbs_get_header.rs @@ -12,7 +12,7 @@ use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; use cb_tests::{ mock_relay::{MockRelayState, start_mock_relay_service}, mock_validator::MockValidator, - utils::{generate_mock_relay, get_pbs_static_config, setup_test_env, to_pbs_config}, + utils::{generate_mock_relay, get_pbs_config, setup_test_env, to_pbs_config}, }; use eyre::Result; use lh_types::ForkName; @@ -36,7 +36,7 @@ async fn test_get_header() -> Result<()> { tokio::spawn(start_mock_relay_service(mock_state.clone(), relay_port)); // Run the PBS service - let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), vec![mock_relay.clone()]); + let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay.clone()]); let state = PbsState::new(config, PathBuf::new()); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); @@ -59,7 +59,7 @@ async fn test_get_header() -> Result<()> { assert_eq!(res.data.message.header().timestamp(), timestamp_of_slot_start_sec(0, chain)); assert_eq!( res.data.signature, - sign_builder_root(chain, &mock_state.signer, res.data.message.tree_hash_root()) + sign_builder_root(chain, &mock_state.signer, &res.data.message.tree_hash_root()) ); Ok(()) } @@ -82,7 +82,7 @@ async fn test_get_header_returns_204_if_relay_down() -> Result<()> { // tokio::spawn(start_mock_relay_service(mock_state.clone(), relay_port)); // Run the PBS service - let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), vec![mock_relay.clone()]); + let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay.clone()]); let state = PbsState::new(config, PathBuf::new()); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); @@ -114,7 +114,7 @@ async fn test_get_header_returns_400_if_request_is_invalid() -> Result<()> { tokio::spawn(start_mock_relay_service(mock_state.clone(), relay_port)); // Run the PBS service - let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), vec![mock_relay.clone()]); + let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay.clone()]); let state = PbsState::new(config, PathBuf::new()); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); diff --git a/tests/tests/pbs_get_status.rs b/tests/tests/pbs_get_status.rs index 9dc8615f..cd2ab51d 100644 --- a/tests/tests/pbs_get_status.rs +++ b/tests/tests/pbs_get_status.rs @@ -5,7 +5,7 @@ use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; use cb_tests::{ mock_relay::{MockRelayState, start_mock_relay_service}, mock_validator::MockValidator, - utils::{generate_mock_relay, get_pbs_static_config, setup_test_env, to_pbs_config}, + utils::{generate_mock_relay, get_pbs_config, setup_test_env, to_pbs_config}, }; use eyre::Result; use reqwest::StatusCode; @@ -30,7 +30,7 @@ async fn test_get_status() -> Result<()> { tokio::spawn(start_mock_relay_service(mock_state.clone(), relay_0_port)); tokio::spawn(start_mock_relay_service(mock_state.clone(), relay_1_port)); - let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), relays.clone()); + let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays.clone()); let state = PbsState::new(config, PathBuf::new()); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); @@ -63,7 +63,7 @@ async fn test_get_status_returns_502_if_relay_down() -> Result<()> { // Don't start the relay // tokio::spawn(start_mock_relay_service(mock_state.clone(), relay_port)); - let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), relays.clone()); + let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays.clone()); let state = PbsState::new(config, PathBuf::new()); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); diff --git a/tests/tests/pbs_mux.rs b/tests/tests/pbs_mux.rs index 34da1dc7..4f842d56 100644 --- a/tests/tests/pbs_mux.rs +++ b/tests/tests/pbs_mux.rs @@ -21,7 +21,7 @@ use cb_tests::{ mock_ssv_public::{PublicSsvMockState, TEST_HTTP_TIMEOUT, create_mock_public_ssv_server}, mock_validator::MockValidator, utils::{ - bls_pubkey_from_hex_unchecked, generate_mock_relay, get_pbs_static_config, setup_test_env, + bls_pubkey_from_hex_unchecked, generate_mock_relay, get_pbs_config, setup_test_env, to_pbs_config, }, }; @@ -214,7 +214,7 @@ async fn test_mux() -> Result<()> { // Register all relays in PBS config let relays = vec![default_relay.clone()]; - let mut config = to_pbs_config(chain, get_pbs_static_config(pbs_port), relays); + let mut config = to_pbs_config(chain, get_pbs_config(pbs_port), relays); config.all_relays = vec![mux_relay_1.clone(), mux_relay_2.clone(), default_relay.clone()]; // Configure mux for two relays @@ -333,7 +333,7 @@ async fn test_ssv_multi_with_node() -> Result<()> { }; // Set up the PBS config - let mut pbs_config = get_pbs_static_config(pbs_port); + let mut pbs_config = get_pbs_config(pbs_port); pbs_config.ssv_node_api_url = ssv_node_url.clone(); pbs_config.ssv_public_api_url = ssv_public_url.clone(); pbs_config.mux_registry_refresh_interval_seconds = 1; // Refresh the mux every second @@ -429,7 +429,7 @@ async fn test_ssv_multi_with_public() -> Result<()> { }; // Set up the PBS config - let mut pbs_config = get_pbs_static_config(pbs_port); + let mut pbs_config = get_pbs_config(pbs_port); pbs_config.ssv_node_api_url = ssv_node_url.clone(); pbs_config.ssv_public_api_url = ssv_public_url.clone(); pbs_config.mux_registry_refresh_interval_seconds = 1; // Refresh the mux every second diff --git a/tests/tests/pbs_mux_refresh.rs b/tests/tests/pbs_mux_refresh.rs index ceb688cb..1d590a49 100644 --- a/tests/tests/pbs_mux_refresh.rs +++ b/tests/tests/pbs_mux_refresh.rs @@ -11,7 +11,7 @@ use cb_tests::{ mock_relay::{MockRelayState, start_mock_relay_service}, mock_ssv_public::{PublicSsvMockState, create_mock_public_ssv_server}, mock_validator::MockValidator, - utils::{generate_mock_relay, get_pbs_static_config, to_pbs_config}, + utils::{generate_mock_relay, get_pbs_config, to_pbs_config}, }; use eyre::Result; use reqwest::StatusCode; @@ -87,7 +87,7 @@ async fn test_auto_refresh() -> Result<()> { }; // Set up the PBS config - let mut pbs_config = get_pbs_static_config(pbs_port); + let mut pbs_config = get_pbs_config(pbs_port); pbs_config.ssv_public_api_url = ssv_api_url.clone(); pbs_config.mux_registry_refresh_interval_seconds = 1; // Refresh the mux every second let (mux_lookup, registry_muxes) = muxes.validate_and_fill(chain, &pbs_config).await?; diff --git a/tests/tests/pbs_post_blinded_blocks.rs b/tests/tests/pbs_post_blinded_blocks.rs index b5854829..bf4703c2 100644 --- a/tests/tests/pbs_post_blinded_blocks.rs +++ b/tests/tests/pbs_post_blinded_blocks.rs @@ -9,7 +9,7 @@ use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; use cb_tests::{ mock_relay::{MockRelayState, start_mock_relay_service}, mock_validator::{MockValidator, load_test_signed_blinded_block}, - utils::{generate_mock_relay, get_pbs_static_config, setup_test_env, to_pbs_config}, + utils::{generate_mock_relay, get_pbs_config, setup_test_env, to_pbs_config}, }; use eyre::Result; use reqwest::{Response, StatusCode}; @@ -70,7 +70,7 @@ async fn test_submit_block_too_large() -> Result<()> { let mock_state = Arc::new(MockRelayState::new(chain, signer).with_large_body()); tokio::spawn(start_mock_relay_service(mock_state.clone(), pbs_port + 1)); - let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), relays); + let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays); let state = PbsState::new(config, PathBuf::new()); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); @@ -112,7 +112,7 @@ async fn submit_block_impl( tokio::spawn(start_mock_relay_service(mock_state.clone(), pbs_port + 1)); // Run the PBS service - let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), relays); + let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays); let state = PbsState::new(config, PathBuf::new()); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); diff --git a/tests/tests/pbs_post_validators.rs b/tests/tests/pbs_post_validators.rs index ef2ac40b..12601cda 100644 --- a/tests/tests/pbs_post_validators.rs +++ b/tests/tests/pbs_post_validators.rs @@ -9,7 +9,7 @@ use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; use cb_tests::{ mock_relay::{MockRelayState, start_mock_relay_service}, mock_validator::MockValidator, - utils::{generate_mock_relay, get_pbs_static_config, setup_test_env, to_pbs_config}, + utils::{generate_mock_relay, get_pbs_config, setup_test_env, to_pbs_config}, }; use eyre::Result; use reqwest::StatusCode; @@ -30,7 +30,7 @@ async fn test_register_validators() -> Result<()> { tokio::spawn(start_mock_relay_service(mock_state.clone(), pbs_port + 1)); // Run the PBS service - let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), relays); + let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays); let state = PbsState::new(config, PathBuf::new()); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); @@ -79,7 +79,7 @@ async fn test_register_validators_does_not_retry_on_429() -> Result<()> { tokio::spawn(start_mock_relay_service(mock_state.clone(), pbs_port + 1)); // Run the PBS service - let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), relays); + let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays); let state = PbsState::new(config, PathBuf::new()); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state.clone())); @@ -131,7 +131,7 @@ async fn test_register_validators_retries_on_500() -> Result<()> { tokio::spawn(start_mock_relay_service(mock_state.clone(), pbs_port + 1)); // Set retry limit to 3 - let mut pbs_config = get_pbs_static_config(pbs_port); + let mut pbs_config = get_pbs_config(pbs_port); pbs_config.register_validator_retry_limit = 3; let config = to_pbs_config(chain, pbs_config, relays); diff --git a/tests/tests/signer_jwt_auth.rs b/tests/tests/signer_jwt_auth.rs index 683a268a..d1b65b3f 100644 --- a/tests/tests/signer_jwt_auth.rs +++ b/tests/tests/signer_jwt_auth.rs @@ -1,29 +1,51 @@ use std::{collections::HashMap, time::Duration}; +use alloy::primitives::b256; use cb_common::{ - commit::{constants::GET_PUBKEYS_PATH, request::GetPubkeysResponse}, - config::StartSignerConfig, - signer::{SignerLoader, ValidatorKeysFormat}, - types::{Chain, ModuleId}, - utils::{bls_pubkey_from_hex, create_jwt}, + commit::{ + constants::{GET_PUBKEYS_PATH, REVOKE_MODULE_PATH}, + request::RevokeModuleRequest, + }, + config::{ModuleSigningConfig, load_module_signing_configs}, + types::ModuleId, + utils::{create_admin_jwt, create_jwt}, +}; +use cb_tests::{ + signer_service::{start_server, verify_pubkeys}, + utils::{self, setup_test_env}, }; -use cb_signer::service::SigningService; -use cb_tests::utils::{get_signer_config, get_start_signer_config, setup_test_env}; use eyre::Result; -use reqwest::{Response, StatusCode}; +use reqwest::StatusCode; use tracing::info; const JWT_MODULE: &str = "test-module"; const JWT_SECRET: &str = "test-jwt-secret"; +const ADMIN_SECRET: &str = "test-admin-secret"; + +async fn create_mod_signing_configs() -> HashMap { + let mut cfg = + utils::get_commit_boost_config(utils::get_pbs_static_config(utils::get_pbs_config(0))); + + let module_id = ModuleId(JWT_MODULE.to_string()); + let signing_id = b256!("0101010101010101010101010101010101010101010101010101010101010101"); + + cfg.modules = Some(vec![utils::create_module_config(module_id.clone(), signing_id)]); + + let jwts = HashMap::from([(module_id.clone(), JWT_SECRET.to_string())]); + + load_module_signing_configs(&cfg, &jwts).unwrap() +} #[tokio::test] async fn test_signer_jwt_auth_success() -> Result<()> { setup_test_env(); let module_id = ModuleId(JWT_MODULE.to_string()); - let start_config = start_server(20100).await?; + let mod_cfgs = create_mod_signing_configs().await; + let start_config = start_server(20100, &mod_cfgs, ADMIN_SECRET.to_string(), false).await?; + let jwt_config = mod_cfgs.get(&module_id).expect("JWT config for test module not found"); // Run a pubkeys request - let jwt = create_jwt(&module_id, JWT_SECRET)?; + let jwt = create_jwt(&module_id, &jwt_config.jwt_secret, GET_PUBKEYS_PATH, None)?; let client = reqwest::Client::new(); let url = format!("http://{}{}", start_config.endpoint, GET_PUBKEYS_PATH); let response = client.get(&url).bearer_auth(&jwt).send().await?; @@ -38,10 +60,11 @@ async fn test_signer_jwt_auth_success() -> Result<()> { async fn test_signer_jwt_auth_fail() -> Result<()> { setup_test_env(); let module_id = ModuleId(JWT_MODULE.to_string()); - let start_config = start_server(20200).await?; + let mod_cfgs = create_mod_signing_configs().await; + let start_config = start_server(20101, &mod_cfgs, ADMIN_SECRET.to_string(), false).await?; // Run a pubkeys request - this should fail due to invalid JWT - let jwt = create_jwt(&module_id, "incorrect secret")?; + let jwt = create_jwt(&module_id, "incorrect secret", GET_PUBKEYS_PATH, None)?; let client = reqwest::Client::new(); let url = format!("http://{}{}", start_config.endpoint, GET_PUBKEYS_PATH); let response = client.get(&url).bearer_auth(&jwt).send().await?; @@ -58,10 +81,12 @@ async fn test_signer_jwt_auth_fail() -> Result<()> { async fn test_signer_jwt_rate_limit() -> Result<()> { setup_test_env(); let module_id = ModuleId(JWT_MODULE.to_string()); - let start_config = start_server(20300).await?; + let mod_cfgs = create_mod_signing_configs().await; + let start_config = start_server(20102, &mod_cfgs, ADMIN_SECRET.to_string(), false).await?; + let mod_cfg = mod_cfgs.get(&module_id).expect("JWT config for test module not found"); // Run as many pubkeys requests as the fail limit - let jwt = create_jwt(&module_id, "incorrect secret")?; + let jwt = create_jwt(&module_id, "incorrect secret", GET_PUBKEYS_PATH, None)?; let client = reqwest::Client::new(); let url = format!("http://{}{}", start_config.endpoint, GET_PUBKEYS_PATH); for _ in 0..start_config.jwt_auth_fail_limit { @@ -70,7 +95,7 @@ async fn test_signer_jwt_rate_limit() -> Result<()> { } // Run another request - this should fail due to rate limiting now - let jwt = create_jwt(&module_id, JWT_SECRET)?; + let jwt = create_jwt(&module_id, &mod_cfg.jwt_secret, GET_PUBKEYS_PATH, None)?; let response = client.get(&url).bearer_auth(&jwt).send().await?; assert!(response.status() == StatusCode::TOO_MANY_REQUESTS); @@ -85,65 +110,101 @@ async fn test_signer_jwt_rate_limit() -> Result<()> { Ok(()) } -// Starts the signer moduler server on a separate task and returns its -// configuration -async fn start_server(port: u16) -> Result { +#[tokio::test] +async fn test_signer_revoked_jwt_fail() -> Result<()> { setup_test_env(); - let chain = Chain::Hoodi; - - // Mock JWT secrets + let admin_secret = ADMIN_SECRET.to_string(); let module_id = ModuleId(JWT_MODULE.to_string()); - let mut jwts = HashMap::new(); - jwts.insert(module_id.clone(), JWT_SECRET.to_string()); - - // Create a signer config - let loader = SignerLoader::ValidatorsDir { - keys_path: "data/keystores/keys".into(), - secrets_path: "data/keystores/secrets".into(), - format: ValidatorKeysFormat::Lighthouse, - }; - let mut config = get_signer_config(loader); - config.port = port; - config.jwt_auth_fail_limit = 3; // Set a low fail limit for testing - config.jwt_auth_fail_timeout_seconds = 3; // Set a short timeout for testing - let start_config = get_start_signer_config(config, chain, jwts); - - // Run the Signer - let server_handle = tokio::spawn(SigningService::run(start_config.clone())); - - // Make sure the server is running - tokio::time::sleep(Duration::from_millis(100)).await; - if server_handle.is_finished() { - return Err(eyre::eyre!( - "Signer service failed to start: {}", - server_handle.await.unwrap_err() - )); - } - Ok(start_config) + let mod_cfgs = create_mod_signing_configs().await; + let start_config = start_server(20400, &mod_cfgs, admin_secret.clone(), false).await?; + + // Run as many pubkeys requests as the fail limit + let jwt = create_jwt(&module_id, JWT_SECRET, GET_PUBKEYS_PATH, None)?; + let client = reqwest::Client::new(); + + // At first, test module should be allowed to request pubkeys + let url = format!("http://{}{}", start_config.endpoint, GET_PUBKEYS_PATH); + let response = client.get(&url).bearer_auth(&jwt).send().await?; + assert!(response.status() == StatusCode::OK); + + let revoke_body = RevokeModuleRequest { module_id: ModuleId(JWT_MODULE.to_string()) }; + let body_bytes = serde_json::to_vec(&revoke_body)?; + let admin_jwt = create_admin_jwt(admin_secret, REVOKE_MODULE_PATH, Some(&body_bytes))?; + + let revoke_url = format!("http://{}{}", start_config.endpoint, REVOKE_MODULE_PATH); + let response = + client.post(&revoke_url).json(&revoke_body).bearer_auth(&admin_jwt).send().await?; + assert!(response.status() == StatusCode::OK); + + // After revoke, test module shouldn't be allowed anymore + let response = client.get(&url).bearer_auth(&jwt).send().await?; + assert!(response.status() == StatusCode::UNAUTHORIZED); + + Ok(()) } -// Verifies that the pubkeys returned by the server match the pubkeys in the -// test data -async fn verify_pubkeys(response: Response) -> Result<()> { - // Verify the expected pubkeys are returned +#[tokio::test] +async fn test_signer_only_admin_can_revoke() -> Result<()> { + setup_test_env(); + let admin_secret = ADMIN_SECRET.to_string(); + let module_id = ModuleId(JWT_MODULE.to_string()); + let mod_cfgs = create_mod_signing_configs().await; + let start_config = start_server(20500, &mod_cfgs, admin_secret.clone(), false).await?; + + let revoke_body = RevokeModuleRequest { module_id: ModuleId(JWT_MODULE.to_string()) }; + let body_bytes = serde_json::to_vec(&revoke_body)?; + + // Run as many pubkeys requests as the fail limit + let jwt = create_jwt(&module_id, JWT_SECRET, REVOKE_MODULE_PATH, Some(&body_bytes))?; + let client = reqwest::Client::new(); + let url = format!("http://{}{}", start_config.endpoint, REVOKE_MODULE_PATH); + + // Module JWT shouldn't be able to revoke modules + let response = client.post(&url).json(&revoke_body).bearer_auth(&jwt).send().await?; + assert!(response.status() == StatusCode::UNAUTHORIZED); + + // Admin should be able to revoke modules + let admin_jwt = create_admin_jwt(admin_secret, REVOKE_MODULE_PATH, Some(&body_bytes))?; + let response = client.post(&url).json(&revoke_body).bearer_auth(&admin_jwt).send().await?; assert!(response.status() == StatusCode::OK); - let pubkey_json = response.json::().await?; - assert_eq!(pubkey_json.keys.len(), 2); - let expected_pubkeys = vec![ - bls_pubkey_from_hex( - "883827193f7627cd04e621e1e8d56498362a52b2a30c9a1c72036eb935c4278dee23d38a24d2f7dda62689886f0c39f4", - )?, - bls_pubkey_from_hex( - "b3a22e4a673ac7a153ab5b3c17a4dbef55f7e47210b20c0cbb0e66df5b36bb49ef808577610b034172e955d2312a61b9", - )?, - ]; - for expected in expected_pubkeys { - assert!( - pubkey_json.keys.iter().any(|k| k.consensus == expected), - "Expected pubkey not found: {:?}", - expected - ); - info!("Server returned expected pubkey: {:?}", expected); + + Ok(()) +} + +#[tokio::test] +async fn test_signer_admin_jwt_rate_limit() -> Result<()> { + setup_test_env(); + let admin_secret = ADMIN_SECRET.to_string(); + let module_id = ModuleId(JWT_MODULE.to_string()); + let mod_cfgs = create_mod_signing_configs().await; + let start_config = start_server(20510, &mod_cfgs, admin_secret.clone(), false).await?; + + let revoke_body = RevokeModuleRequest { module_id: ModuleId(JWT_MODULE.to_string()) }; + let body_bytes = serde_json::to_vec(&revoke_body)?; + + // Run as many pubkeys requests as the fail limit + let jwt = create_jwt(&module_id, JWT_SECRET, REVOKE_MODULE_PATH, Some(&body_bytes))?; + let client = reqwest::Client::new(); + let url = format!("http://{}{}", start_config.endpoint, REVOKE_MODULE_PATH); + + // Module JWT shouldn't be able to revoke modules + for _ in 0..start_config.jwt_auth_fail_limit { + let response = client.post(&url).json(&revoke_body).bearer_auth(&jwt).send().await?; + assert!(response.status() == StatusCode::UNAUTHORIZED); } + + // Run another request - this should fail due to rate limiting now + let admin_jwt = create_admin_jwt(admin_secret, REVOKE_MODULE_PATH, Some(&body_bytes))?; + let response = client.post(&url).json(&revoke_body).bearer_auth(&admin_jwt).send().await?; + assert!(response.status() == StatusCode::TOO_MANY_REQUESTS); + + // Wait for the rate limit timeout + tokio::time::sleep(Duration::from_secs(start_config.jwt_auth_fail_timeout_seconds as u64)) + .await; + + // Now the next request should succeed + let response = client.post(&url).json(&revoke_body).bearer_auth(&admin_jwt).send().await?; + assert!(response.status() == StatusCode::OK); + Ok(()) } diff --git a/tests/tests/signer_jwt_auth_cleanup.rs b/tests/tests/signer_jwt_auth_cleanup.rs new file mode 100644 index 00000000..d6fde2a4 --- /dev/null +++ b/tests/tests/signer_jwt_auth_cleanup.rs @@ -0,0 +1,70 @@ +use std::{collections::HashMap, time::Duration}; + +use alloy::primitives::b256; +use cb_common::{ + commit::constants::GET_PUBKEYS_PATH, + config::{ModuleSigningConfig, load_module_signing_configs}, + types::ModuleId, + utils::create_jwt, +}; +use cb_tests::{ + signer_service::start_server, + utils::{self}, +}; +use eyre::Result; +use reqwest::StatusCode; + +const JWT_MODULE: &str = "test-module"; +const JWT_SECRET: &str = "test-jwt-secret"; +const ADMIN_SECRET: &str = "test-admin-secret"; + +async fn create_mod_signing_configs() -> HashMap { + let mut cfg = + utils::get_commit_boost_config(utils::get_pbs_static_config(utils::get_pbs_config(0))); + + let module_id = ModuleId(JWT_MODULE.to_string()); + let signing_id = b256!("0101010101010101010101010101010101010101010101010101010101010101"); + + cfg.modules = Some(vec![utils::create_module_config(module_id.clone(), signing_id)]); + + let jwts = HashMap::from([(module_id.clone(), JWT_SECRET.to_string())]); + + load_module_signing_configs(&cfg, &jwts).unwrap() +} + +#[tokio::test] +#[tracing_test::traced_test] +async fn test_signer_jwt_fail_cleanup() -> Result<()> { + // setup_test_env() isn't used because we want to capture logs with tracing_test + let module_id = ModuleId(JWT_MODULE.to_string()); + let mod_cfgs = create_mod_signing_configs().await; + let start_config = start_server(20102, &mod_cfgs, ADMIN_SECRET.to_string(), false).await?; + let mod_cfg = mod_cfgs.get(&module_id).expect("JWT config for test module not found"); + + // Run as many pubkeys requests as the fail limit + let jwt = create_jwt(&module_id, "incorrect secret", GET_PUBKEYS_PATH, None)?; + let client = reqwest::Client::new(); + let url = format!("http://{}{}", start_config.endpoint, GET_PUBKEYS_PATH); + for _ in 0..start_config.jwt_auth_fail_limit { + let response = client.get(&url).bearer_auth(&jwt).send().await?; + assert!(response.status() == StatusCode::UNAUTHORIZED); + } + + // Run another request - this should fail due to rate limiting now + let jwt = create_jwt(&module_id, &mod_cfg.jwt_secret, GET_PUBKEYS_PATH, None)?; + let response = client.get(&url).bearer_auth(&jwt).send().await?; + assert!(response.status() == StatusCode::TOO_MANY_REQUESTS); + + // Wait until the cleanup task should have run properly, takes a while for the + // timing to work out + tokio::time::sleep(Duration::from_secs( + (start_config.jwt_auth_fail_timeout_seconds * 3) as u64, + )) + .await; + + // Make sure the cleanup message was logged - it's all internal state so without + // refactoring or exposing it, this is the easiest way to check if it triggered + assert!(logs_contain("Cleaned up 1 old JWT auth failure entries")); + + Ok(()) +} diff --git a/tests/tests/signer_request_sig.rs b/tests/tests/signer_request_sig.rs new file mode 100644 index 00000000..78efbf9e --- /dev/null +++ b/tests/tests/signer_request_sig.rs @@ -0,0 +1,197 @@ +use std::collections::HashMap; + +use alloy::primitives::{b256, hex}; +use cb_common::{ + commit::{ + constants::REQUEST_SIGNATURE_BLS_PATH, request::SignConsensusRequest, + response::BlsSignResponse, + }, + config::{ModuleSigningConfig, load_module_signing_configs}, + types::{BlsPublicKey, BlsSignature, Chain, ModuleId}, + utils::create_jwt, +}; +use cb_tests::{ + signer_service::start_server, + utils::{self, setup_test_env}, +}; +use eyre::Result; +use reqwest::StatusCode; + +const MODULE_ID_1: &str = "test-module"; +const MODULE_ID_2: &str = "another-module"; +const PUBKEY_1: [u8; 48] = hex!( + "883827193f7627cd04e621e1e8d56498362a52b2a30c9a1c72036eb935c4278dee23d38a24d2f7dda62689886f0c39f4" +); +const ADMIN_SECRET: &str = "test-admin-secret"; + +async fn create_mod_signing_configs() -> HashMap { + let mut cfg = + utils::get_commit_boost_config(utils::get_pbs_static_config(utils::get_pbs_config(0))); + + let module_id_1 = ModuleId(MODULE_ID_1.to_string()); + let signing_id_1 = b256!("0x6a33a23ef26a4836979edff86c493a69b26ccf0b4a16491a815a13787657431b"); + let module_id_2 = ModuleId(MODULE_ID_2.to_string()); + let signing_id_2 = b256!("0x61fe00135d7b4912a8c63ada215ac2e62326e6e7b30f49a29fcf9779d7ad800d"); + + cfg.modules = Some(vec![ + utils::create_module_config(module_id_1.clone(), signing_id_1), + utils::create_module_config(module_id_2.clone(), signing_id_2), + ]); + + let jwts = HashMap::from([ + (module_id_1.clone(), "supersecret".to_string()), + (module_id_2.clone(), "anothersecret".to_string()), + ]); + + load_module_signing_configs(&cfg, &jwts).unwrap() +} + +/// Makes sure the signer service signs requests correctly, using the module's +/// signing ID +#[tokio::test] +async fn test_signer_sign_request_good() -> Result<()> { + setup_test_env(); + let module_id = ModuleId(MODULE_ID_1.to_string()); + let mod_cfgs = create_mod_signing_configs().await; + let start_config = start_server(20200, &mod_cfgs, ADMIN_SECRET.to_string(), false).await?; + let jwt_config = mod_cfgs.get(&module_id).expect("JWT config for test module not found"); + + // Send a signing request + let object_root = b256!("0x0123456789012345678901234567890123456789012345678901234567890123"); + let nonce: u64 = 101; + let pubkey = BlsPublicKey::deserialize(&PUBKEY_1).unwrap(); + let request = SignConsensusRequest { pubkey: pubkey.clone(), object_root, nonce }; + let payload_bytes = serde_json::to_vec(&request)?; + let jwt = create_jwt( + &module_id, + &jwt_config.jwt_secret, + REQUEST_SIGNATURE_BLS_PATH, + Some(&payload_bytes), + )?; + let client = reqwest::Client::new(); + let url = format!("http://{}{}", start_config.endpoint, REQUEST_SIGNATURE_BLS_PATH); + let response = client.post(&url).json(&request).bearer_auth(&jwt).send().await?; + + // Verify the response is successful + assert!(response.status() == StatusCode::OK); + + // Verify the signature is returned + let sig_response = response.json::().await?; + let expected = BlsSignResponse::new( + pubkey, + object_root, + mod_cfgs.get(&module_id).unwrap().signing_id, + nonce, + Chain::Hoodi.id(), + BlsSignature::deserialize(&hex!("0xb653034a6da0e516cb999d6bbcd5ddd8dde9695322a94aefcd3049e6235e0f4f63b13d81ddcd80d4e1e698c3f88c3b440ae696650ccef2f22329afb4ffecec85a34523e25920ceced54c5bc31168174a3b352977750c222c1c25f72672467e5c")).unwrap()); + assert_eq!(sig_response, expected, "Signature response does not match expected value"); + + Ok(()) +} + +/// Makes sure the signer service returns a signature that is different for each +/// module +#[tokio::test] +async fn test_signer_sign_request_different_module() -> Result<()> { + setup_test_env(); + let module_id = ModuleId(MODULE_ID_2.to_string()); + let mod_cfgs = create_mod_signing_configs().await; + let start_config = start_server(20201, &mod_cfgs, ADMIN_SECRET.to_string(), false).await?; + let jwt_config = mod_cfgs.get(&module_id).expect("JWT config for 2nd test module not found"); + + // Send a signing request + let object_root = b256!("0x0123456789012345678901234567890123456789012345678901234567890123"); + let nonce: u64 = 101; + let pubkey = BlsPublicKey::deserialize(&PUBKEY_1).unwrap(); + let request = SignConsensusRequest { pubkey: pubkey.clone(), object_root, nonce }; + let payload_bytes = serde_json::to_vec(&request)?; + let jwt = create_jwt( + &module_id, + &jwt_config.jwt_secret, + REQUEST_SIGNATURE_BLS_PATH, + Some(&payload_bytes), + )?; + let client = reqwest::Client::new(); + let url = format!("http://{}{}", start_config.endpoint, REQUEST_SIGNATURE_BLS_PATH); + let response = client.post(&url).json(&request).bearer_auth(&jwt).send().await?; + + // Verify the response is successful + assert!(response.status() == StatusCode::OK); + + // Verify the signature is returned + let sig_response = response.json::().await?; + assert_eq!(sig_response.pubkey, pubkey, "Public key does not match expected value"); + assert_eq!(sig_response.object_root, object_root, "Object root does not match expected value"); + assert_eq!( + sig_response.module_signing_id, + mod_cfgs.get(&module_id).unwrap().signing_id, + "Module signing ID does not match expected value" + ); + assert_ne!( + sig_response.signature, BlsSignature::deserialize(&hex!("0xb653034a6da0e516cb999d6bbcd5ddd8dde9695322a94aefcd3049e6235e0f4f63b13d81ddcd80d4e1e698c3f88c3b440ae696650ccef2f22329afb4ffecec85a34523e25920ceced54c5bc31168174a3b352977750c222c1c25f72672467e5c")).unwrap(), + "Signature matches the reference signature, which should not happen" + ); + + Ok(()) +} + +/// Makes sure the signer service does not allow requests for JWTs that do +/// not match the JWT hash +#[tokio::test] +async fn test_signer_sign_request_incorrect_hash() -> Result<()> { + setup_test_env(); + let module_id = ModuleId(MODULE_ID_2.to_string()); + let mod_cfgs = create_mod_signing_configs().await; + let start_config = start_server(20202, &mod_cfgs, ADMIN_SECRET.to_string(), false).await?; + let jwt_config = mod_cfgs.get(&module_id).expect("JWT config for 2nd test module not found"); + + // Send a signing request + let fake_object_root = + b256!("0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd"); + let nonce: u64 = 101; + let pubkey = BlsPublicKey::deserialize(&PUBKEY_1).unwrap(); + let fake_request = + SignConsensusRequest { pubkey: pubkey.clone(), object_root: fake_object_root, nonce }; + let fake_payload_bytes = serde_json::to_vec(&fake_request)?; + let true_object_root = + b256!("0x0123456789012345678901234567890123456789012345678901234567890123"); + let true_request = SignConsensusRequest { pubkey, object_root: true_object_root, nonce }; + let jwt = create_jwt( + &module_id, + &jwt_config.jwt_secret, + REQUEST_SIGNATURE_BLS_PATH, + Some(&fake_payload_bytes), + )?; + let client = reqwest::Client::new(); + let url = format!("http://{}{}", start_config.endpoint, REQUEST_SIGNATURE_BLS_PATH); + let response = client.post(&url).json(&true_request).bearer_auth(&jwt).send().await?; + + // Verify that authorization failed + assert!(response.status() == StatusCode::UNAUTHORIZED); + Ok(()) +} + +/// Makes sure the signer service does not allow signer requests for JWTs that +/// do not include a payload hash +#[tokio::test] +async fn test_signer_sign_request_missing_hash() -> Result<()> { + setup_test_env(); + let module_id = ModuleId(MODULE_ID_2.to_string()); + let mod_cfgs = create_mod_signing_configs().await; + let start_config = start_server(20203, &mod_cfgs, ADMIN_SECRET.to_string(), false).await?; + let jwt_config = mod_cfgs.get(&module_id).expect("JWT config for 2nd test module not found"); + + // Send a signing request + let nonce: u64 = 101; + let pubkey = BlsPublicKey::deserialize(&PUBKEY_1).unwrap(); + let object_root = b256!("0x0123456789012345678901234567890123456789012345678901234567890123"); + let request = SignConsensusRequest { pubkey, object_root, nonce }; + let jwt = create_jwt(&module_id, &jwt_config.jwt_secret, REQUEST_SIGNATURE_BLS_PATH, None)?; + let client = reqwest::Client::new(); + let url = format!("http://{}{}", start_config.endpoint, REQUEST_SIGNATURE_BLS_PATH); + let response = client.post(&url).json(&request).bearer_auth(&jwt).send().await?; + + // Verify that authorization failed + assert!(response.status() == StatusCode::UNAUTHORIZED); + Ok(()) +} diff --git a/tests/tests/signer_tls.rs b/tests/tests/signer_tls.rs new file mode 100644 index 00000000..2df98d73 --- /dev/null +++ b/tests/tests/signer_tls.rs @@ -0,0 +1,58 @@ +use std::collections::HashMap; + +use alloy::primitives::b256; +use cb_common::{ + commit::constants::GET_PUBKEYS_PATH, + config::{ModuleSigningConfig, load_module_signing_configs}, + types::ModuleId, + utils::create_jwt, +}; +use cb_tests::{ + signer_service::{start_server, verify_pubkeys}, + utils::{self, setup_test_env}, +}; +use eyre::{Result, bail}; +use reqwest::Certificate; + +const JWT_MODULE: &str = "test-module"; +const JWT_SECRET: &str = "test-jwt-secret"; +const ADMIN_SECRET: &str = "test-admin-secret"; + +async fn create_mod_signing_configs() -> HashMap { + let mut cfg = + utils::get_commit_boost_config(utils::get_pbs_static_config(utils::get_pbs_config(0))); + + let module_id = ModuleId(JWT_MODULE.to_string()); + let signing_id = b256!("0101010101010101010101010101010101010101010101010101010101010101"); + + cfg.modules = Some(vec![utils::create_module_config(module_id.clone(), signing_id)]); + + let jwts = HashMap::from([(module_id.clone(), JWT_SECRET.to_string())]); + + load_module_signing_configs(&cfg, &jwts).unwrap() +} + +#[tokio::test] +async fn test_signer_tls() -> Result<()> { + setup_test_env(); + let module_id = ModuleId(JWT_MODULE.to_string()); + let mod_cfgs = create_mod_signing_configs().await; + let start_config = start_server(20100, &mod_cfgs, ADMIN_SECRET.to_string(), true).await?; + let jwt_config = mod_cfgs.get(&module_id).expect("JWT config for test module not found"); + + // Run a pubkeys request + let jwt = create_jwt(&module_id, &jwt_config.jwt_secret, GET_PUBKEYS_PATH, None)?; + let cert = match start_config.tls_certificates { + Some(ref certificates) => &certificates.0, + None => bail!("TLS certificates not found in start config"), + }; + let client = + reqwest::Client::builder().add_root_certificate(Certificate::from_pem(cert)?).build()?; + let url = format!("https://{}{}", start_config.endpoint, GET_PUBKEYS_PATH); + let response = client.get(&url).bearer_auth(&jwt).send().await?; + + // Verify the expected pubkeys are returned + verify_pubkeys(response).await?; + + Ok(()) +} From ff3692c6de32b984aef515933e9a3946cbd50848 Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Wed, 22 Apr 2026 20:40:17 -0700 Subject: [PATCH 10/11] fix deps from cherrypicking --- Cargo.lock | 547 ++++++++++++++++++++++++++++++++++++++++++----------- Cargo.toml | 4 +- 2 files changed, 438 insertions(+), 113 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b4c8e1d7..fc7c61ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -286,7 +286,7 @@ checksum = "422d110f1c40f1f8d0e5562b0b649c35f345fccb7093d9f02729943dcd1eef71" dependencies = [ "alloy-primitives 1.5.7", "alloy-sol-types", - "http", + "http 1.4.0", "serde", "serde_json", "thiserror 2.0.18", @@ -765,7 +765,7 @@ checksum = "8098f965442a9feb620965ba4b4be5e2b320f4ec5a3fff6bfa9e1ff7ef42bed1" dependencies = [ "alloy-json-rpc", "auto_impl", - "base64", + "base64 0.22.1", "derive_more", "futures", "futures-utils-wasm", @@ -825,7 +825,7 @@ dependencies = [ "alloy-pubsub", "alloy-transport", "futures", - "http", + "http 1.4.0", "rustls", "serde_json", "tokio", @@ -943,6 +943,15 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + [[package]] name = "archery" version = "0.4.0" @@ -1155,9 +1164,9 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "assert_cmd" -version = "2.1.2" +version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514" +checksum = "39bae1d3fa576f7c6519514180a72559268dd7d1fe104070956cb687bc6673bd" dependencies = [ "anstyle", "bstr", @@ -1267,8 +1276,8 @@ dependencies = [ "axum-core 0.4.5", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "itoa", "matchit 0.7.3", @@ -1278,7 +1287,7 @@ dependencies = [ "pin-project-lite", "rustversion", "serde", - "sync_wrapper", + "sync_wrapper 1.0.2", "tower 0.5.3", "tower-layer", "tower-service", @@ -1295,10 +1304,10 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.9.0", "hyper-util", "itoa", "matchit 0.8.4", @@ -1310,7 +1319,7 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower 0.5.3", "tower-layer", @@ -1327,13 +1336,13 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", "rustversion", - "sync_wrapper", + "sync_wrapper 1.0.2", "tower-layer", "tower-service", ] @@ -1346,12 +1355,12 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tower-layer", "tower-service", "tracing", @@ -1368,8 +1377,8 @@ dependencies = [ "bytes", "futures-util", "headers", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", @@ -1391,6 +1400,28 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "axum-server" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ab4a3ec9ea8a657c72d99a03a824af695bd0fb5ec639ccbd9cd3543b41a5f9" +dependencies = [ + "arc-swap", + "bytes", + "fs-err", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.9.0", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-pemfile", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -1412,6 +1443,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -1464,6 +1501,12 @@ dependencies = [ "hex-conservative", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.1" @@ -1668,10 +1711,10 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cb-bench-micro" -version = "0.9.3" +version = "0.9.6" dependencies = [ "alloy", - "axum 0.8.4", + "axum 0.8.9", "cb-common", "cb-pbs", "cb-tests", @@ -1718,11 +1761,12 @@ dependencies = [ "alloy", "async-trait", "axum 0.8.9", - "base64", + "base64 0.22.1", "bimap", "bls", "bytes", "cipher", + "const_format", "ctr", "derive_more", "docker-image", @@ -1735,15 +1779,18 @@ dependencies = [ "futures", "jsonwebtoken", "lazy_static", + "notify", "pbkdf2", "rand 0.9.4", "rayon", "reqwest 0.13.2", + "reqwest-eventsource 0.5.0", "serde", "serde_json", "serde_yaml", "sha2", "ssz_types 0.11.0", + "tempfile", "thiserror 2.0.18", "tokio", "toml", @@ -1805,6 +1852,7 @@ dependencies = [ "alloy", "axum 0.8.9", "axum-extra", + "axum-server", "bimap", "blsful", "cb-common", @@ -1818,6 +1866,7 @@ dependencies = [ "prometheus", "prost", "rand 0.9.4", + "rustls", "thiserror 2.0.18", "tokio", "tonic", @@ -1837,7 +1886,10 @@ dependencies = [ "cb-pbs", "cb-signer", "eyre", + "jsonwebtoken", + "rcgen", "reqwest 0.13.2", + "serde", "serde_json", "tempfile", "tokio", @@ -1900,7 +1952,7 @@ dependencies = [ "iana-time-zone", "num-traits", "serde", - "windows-link 0.2.0", + "windows-link", ] [[package]] @@ -1930,15 +1982,6 @@ dependencies = [ "half", ] -[[package]] -name = "cipher" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7" -dependencies = [ - "generic-array 0.14.7", -] - [[package]] name = "cipher" version = "0.4.4" @@ -2297,7 +2340,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags", + "bitflags 2.11.1", "crossterm_winapi", "document-features", "parking_lot", @@ -2863,7 +2906,7 @@ dependencies = [ "mediatype", "pretty_reqwest_error", "reqwest 0.12.28", - "reqwest-eventsource", + "reqwest-eventsource 0.6.0", "sensitive_url", "serde", "serde_json", @@ -3168,12 +3211,31 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-err" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0" +dependencies = [ + "autocfg", + "tokio", +] + [[package]] name = "fs_extra" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "funty" version = "2.0.0" @@ -3367,6 +3429,25 @@ dependencies = [ "subtle", ] +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.13" @@ -3378,7 +3459,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.4.0", "indexmap 2.14.0", "slab", "tokio", @@ -3444,10 +3525,10 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "headers-core", - "http", + "http 1.4.0", "httpdate", "mime", "sha1", @@ -3459,7 +3540,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" dependencies = [ - "http", + "http 1.4.0", ] [[package]] @@ -3516,6 +3597,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.0" @@ -3526,6 +3618,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -3533,7 +3636,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -3544,8 +3647,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -3561,6 +3664,30 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.9.0" @@ -3571,9 +3698,9 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", - "http", - "http-body", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -3589,8 +3716,8 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http", - "hyper", + "http 1.4.0", + "hyper 1.9.0", "hyper-util", "rustls", "tokio", @@ -3605,7 +3732,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" dependencies = [ - "hyper", + "hyper 1.9.0", "hyper-util", "pin-project-lite", "tokio", @@ -3620,7 +3747,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper", + "hyper 1.9.0", "hyper-util", "native-tls", "tokio", @@ -3634,19 +3761,19 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", - "http", - "http-body", - "hyper", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.9.0", "ipnet", "libc", "percent-encoding", "pin-project-lite", "socket2 0.6.3", - "system-configuration", + "system-configuration 0.7.0", "tokio", "tower-service", "tracing", @@ -3843,11 +3970,11 @@ dependencies = [ [[package]] name = "inotify" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "inotify-sys", "libc", ] @@ -3917,7 +4044,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.0", + "windows-sys 0.61.2", ] [[package]] @@ -4031,7 +4158,7 @@ version = "9.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" dependencies = [ - "base64", + "base64 0.22.1", "js-sys", "ring", "serde", @@ -4086,6 +4213,26 @@ version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "kzg" version = "0.1.0" @@ -4329,6 +4476,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -4378,7 +4526,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "fsevent-sys", "inotify", "kqueue", @@ -4392,9 +4540,12 @@ dependencies = [ [[package]] name = "notify-types" -version = "2.0.0" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.11.1", +] [[package]] name = "nu-ansi-term" @@ -4532,7 +4683,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" name = "once_cell_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "oorandom" @@ -4540,19 +4691,13 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - [[package]] name = "openssl" version = "0.10.78" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cfg-if", "foreign-types", "libc", @@ -4682,6 +4827,16 @@ dependencies = [ "hmac", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -4941,7 +5096,7 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", "bit-vec", - "bitflags", + "bitflags 2.11.1", "num-traits", "rand 0.9.4", "rand_chacha 0.9.0", @@ -5239,6 +5394,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "recvmsg" version = "1.0.0" @@ -5251,7 +5419,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -5303,21 +5471,59 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration 0.5.1", + "tokio", + "tokio-util", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "winreg", +] + [[package]] name = "reqwest" version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.9.0", "hyper-rustls", "hyper-tls", "hyper-util", @@ -5332,7 +5538,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tokio-native-tls", "tokio-rustls", @@ -5354,16 +5560,16 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", "futures-util", - "h2", - "http", - "http-body", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.9.0", "hyper-rustls", "hyper-util", "js-sys", @@ -5377,7 +5583,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tokio-rustls", "tokio-util", @@ -5391,6 +5597,22 @@ dependencies = [ "web-sys", ] +[[package]] +name = "reqwest-eventsource" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f529a5ff327743addc322af460761dff5b50e0c826b9e6ac44c3195c50bb2026" +dependencies = [ + "eventsource-stream", + "futures-core", + "futures-timer", + "mime", + "nom", + "pin-project-lite", + "reqwest 0.11.27", + "thiserror 1.0.69", +] + [[package]] name = "reqwest-eventsource" version = "0.6.0" @@ -5543,7 +5765,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", @@ -5552,9 +5774,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.38" +version = "0.23.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" +checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" dependencies = [ "aws-lc-rs", "log", @@ -5684,15 +5906,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - [[package]] name = "schannel" version = "0.1.29" @@ -5785,7 +5998,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.11.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -5943,7 +6156,7 @@ version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" dependencies = [ - "base64", + "base64 0.22.1", "chrono", "hex", "indexmap 1.9.3", @@ -6280,6 +6493,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -6300,15 +6519,36 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys 0.5.0", +] + [[package]] name = "system-configuration" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags", + "bitflags 2.11.1", "core-foundation 0.9.4", - "system-configuration-sys", + "system-configuration-sys 0.6.0", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", ] [[package]] @@ -6677,13 +6917,13 @@ dependencies = [ "async-stream", "async-trait", "axum 0.7.9", - "base64", + "base64 0.22.1", "bytes", - "h2", - "http", - "http-body", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.9.0", "hyper-timeout", "hyper-util", "percent-encoding", @@ -6743,7 +6983,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower-layer", "tower-service", @@ -6756,11 +6996,11 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.11.1", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "iri-string", "pin-project-lite", "tower 0.5.3", @@ -6974,7 +7214,7 @@ checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.9.4", @@ -7368,7 +7608,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap 2.14.0", "semver 1.0.28", @@ -7551,6 +7791,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -7593,6 +7842,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -7632,6 +7896,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -7650,6 +7920,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -7668,6 +7944,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -7698,6 +7980,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -7716,6 +8004,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -7734,6 +8028,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -7752,6 +8052,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -7782,6 +8088,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -7846,7 +8162,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.1", "indexmap 2.14.0", "log", "serde", @@ -7910,6 +8226,15 @@ dependencies = [ "tap", ] +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index 0d7643ea..0adabb6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,5 @@ [workspace] members = ["benches/*", "bin", "crates/*", "examples/da_commit", "examples/status_api", "tests"] -exclude = ["benches/microbench"] resolver = "2" [workspace.package] @@ -57,6 +56,7 @@ lh_eth2 = { package = "eth2", git = "https://github.com/sigp/lighthouse", tag = lh_eth2_keystore = { package = "eth2_keystore", git = "https://github.com/sigp/lighthouse", tag = "v8.1.3" } lh_bls = { package = "bls", git = "https://github.com/sigp/lighthouse", tag = "v8.1.3" } lh_types = { package = "types", git = "https://github.com/sigp/lighthouse", tag = "v8.1.3" } +notify = "8.2.0" parking_lot = "0.12.3" pbkdf2 = "0.12.2" predicates = "3.0.3" @@ -64,7 +64,7 @@ prometheus = "0.14.0" prost = "0.13.4" rand = { version = "0.9", features = ["os_rng"] } rayon = "1.10.0" -reqwest = { version = "0.13", features = ["json", "stream", "rustls-tls"] } +reqwest = { version = "^0.13.2", features = ["json", "stream", "rustls"] } rcgen = "0.13.2" reqwest-eventsource = "=0.5.0" rustls = "0.23.23" From 396834a7d5ea50c9205b6c41d5ef89b1671e3ecd Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 28 Apr 2026 17:03:27 -0700 Subject: [PATCH 11/11] Update release.yml to work with unified binaries #415 --- .github/workflows/release.yml | 97 ++++------------------------------- 1 file changed, 10 insertions(+), 87 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 90f25598..6bbd33e4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: tag: - description: "Release tag (e.g. v1.2.3)" + description: 'Release tag (e.g. v1.2.3)' required: true type: string @@ -72,7 +72,7 @@ jobs: VALUE=$(python .github/workflows/release/release.py is-latest "${{ inputs.tag }}") echo "value=$VALUE" >> $GITHUB_OUTPUT - # Builds the x64 and arm64 binaries for Linux, for all 3 crates, via the Docker builder + # Builds the x64 and arm64 binaries for Linux via the Docker builder build-binaries-linux: needs: [resolve-tag] timeout-minutes: 60 @@ -250,10 +250,10 @@ jobs: run: | mkdir -p ./artifacts/bin/linux_amd64 mkdir -p ./artifacts/bin/linux_arm64 - tar -xzf ./artifacts/commit-boost-${{ matrix.crate }}-${{ inputs.tag }}-linux_x86-64/commit-boost-${{ matrix.crate }}-${{ inputs.tag }}-linux_x86-64.tar.gz -C ./artifacts/bin - mv ./artifacts/bin/commit-boost-${{ matrix.crate }} ./artifacts/bin/linux_amd64/commit-boost-${{ matrix.crate }} - tar -xzf ./artifacts/commit-boost-${{ matrix.crate }}-${{ inputs.tag }}-linux_arm64/commit-boost-${{ matrix.crate }}-${{ inputs.tag }}-linux_arm64.tar.gz -C ./artifacts/bin - mv ./artifacts/bin/commit-boost-${{ matrix.crate }} ./artifacts/bin/linux_arm64/commit-boost-${{ matrix.crate }} + tar -xzf ./artifacts/commit-boost-${{ inputs.tag }}-linux_x86-64/commit-boost-${{ inputs.tag }}-linux_x86-64.tar.gz -C ./artifacts/bin + mv ./artifacts/bin/commit-boost ./artifacts/bin/linux_amd64/commit-boost + tar -xzf ./artifacts/commit-boost-${{ inputs.tag }}-linux_arm64/commit-boost-${{ inputs.tag }}-linux_arm64.tar.gz -C ./artifacts/bin + mv ./artifacts/bin/commit-boost ./artifacts/bin/linux_arm64/commit-boost - name: Set lowercase owner run: echo "OWNER=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV @@ -297,7 +297,7 @@ jobs: uses: actions/download-artifact@v4 with: path: ./artifacts - pattern: "commit-boost*" + pattern: "commit-boost-*" - name: Sign all binaries with Sigstore uses: sigstore/gh-action-sigstore-python@v3.0.0 @@ -310,7 +310,7 @@ jobs: name: signed-${{ inputs.tag }} path: ./artifacts/**/*.sigstore* - # Creates a release on GitHub with the binaries + # Creates a draft release on GitHub with the binaries finalize-release: needs: - build-binaries-linux @@ -327,13 +327,7 @@ jobs: uses: actions/download-artifact@v4 with: path: ./artifacts - pattern: "commit-boost*" - - - name: Download signatures - uses: actions/download-artifact@v4 - with: - path: ./artifacts - pattern: "signatures-${{ github.ref_name }}*" + pattern: "commit-boost-*" - name: Download signed artifacts uses: actions/download-artifact@v4 @@ -350,75 +344,4 @@ jobs: tag_name: ${{ inputs.tag }} name: ${{ inputs.tag }} env: - GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} - - # Fast-forwards stable (full release) or beta (RC) to the new tag. - # Runs after all artifacts are built and the draft release is created, - # so stable/beta are never touched if any part of the pipeline fails. - fast-forward-branch: - needs: - - finalize-release - runs-on: ubuntu-latest - steps: - - uses: actions/create-github-app-token@v1 - id: app-token - with: - app-id: ${{ secrets.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ steps.app-token.outputs.token }} - - - name: Configure git - run: | - git config user.name "commit-boost-release-bot[bot]" - git config user.email "commit-boost-release-bot[bot]@users.noreply.github.com" - - - name: Fast-forward beta branch (RC releases) - if: contains(github.ref_name, '-rc') - run: | - git checkout beta - git merge --ff-only "${{ github.ref_name }}" - git push origin beta - - - name: Fast-forward stable branch (full releases) - if: "!contains(github.ref_name, '-rc')" - run: | - git checkout stable - git merge --ff-only "${{ github.ref_name }}" - git push origin stable - - # Deletes the tag if any job in the release pipeline fails. - # This keeps the tag and release artifacts in sync — a tag should only - # exist if the full pipeline completed successfully. - # stable/beta are never touched on failure since fast-forward-branch - # only runs after finalize-release succeeds. - # - # Note: if finalize-release specifically fails, a draft release may already - # exist on GitHub pointing at the now-deleted tag and will need manual cleanup. - cleanup-on-failure: - needs: - - build-binaries-linux - - build-binaries-darwin - - sign-binaries - - build-and-push-pbs-docker - - build-and-push-signer-docker - - finalize-release - - fast-forward-branch - runs-on: ubuntu-latest - if: failure() - steps: - - uses: actions/create-github-app-token@v1 - id: app-token - with: - app-id: ${{ secrets.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - - - uses: actions/checkout@v4 - with: - token: ${{ steps.app-token.outputs.token }} - - - name: Delete tag - run: git push origin --delete ${{ github.ref_name }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}