diff --git a/Cargo.lock b/Cargo.lock index 262a2cf5c32..7e8adb51eb7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1713,6 +1713,25 @@ dependencies = [ "types", ] +[[package]] +name = "builder_store" +version = "0.1.0" +dependencies = [ + "account_utils", + "bls", + "builder_types", + "filesystem", + "futures", + "hex", + "parking_lot", + "serde", + "ssz_types", + "tempfile", + "tracing", + "types", + "yaml_serde", +] + [[package]] name = "builder_types" version = "0.1.0" @@ -5666,6 +5685,7 @@ dependencies = [ "account_utils", "beacon_node_fallback", "bls", + "builder_types", "doppelganger_service", "either", "environment", @@ -8445,6 +8465,7 @@ name = "signing_method" version = "0.1.0" dependencies = [ "bls", + "builder_types", "eth2_keystore", "ethereum_serde_utils", "lockfile", @@ -9809,6 +9830,7 @@ version = "8.2.2" dependencies = [ "account_utils", "beacon_node_fallback", + "builder_store", "clap", "clap_utils", "directory", @@ -9974,6 +9996,8 @@ version = "0.1.0" dependencies = [ "beacon_node_fallback", "bls", + "builder_store", + "builder_types", "either", "eth2", "futures", @@ -9998,6 +10022,7 @@ name = "validator_store" version = "0.1.0" dependencies = [ "bls", + "builder_types", "eth2", "futures", "slashing_protection", diff --git a/Cargo.toml b/Cargo.toml index 48dbefc8b1a..eb0a3c172a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,6 +78,7 @@ members = [ "testing/web3signer_tests", "validator_client", "validator_client/beacon_node_fallback", + "validator_client/builder_store", "validator_client/doppelganger_service", "validator_client/graffiti_file", "validator_client/http_api", @@ -120,6 +121,7 @@ bincode = "1" bitvec = "1" bls = { path = "crypto/bls" } builder_client = { path = "beacon_node/builder_client" } +builder_store = { path = "validator_client/builder_store" } builder_types = { path = "common/builder_types" } byteorder = "1" bytes = "1.11.1" diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index feecd4b6894..15e6be2010a 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -49,6 +49,7 @@ * [Redundancy](./advanced_redundancy.md) * [Release Candidates](./advanced_release_candidates.md) * [MEV](./advanced_builders.md) + * [Gloas Builder Configuration](./gloas_builder_config.md) * [Late Block Re-orgs](./advanced_re-orgs.md) * [Blobs](./advanced_blobs.md) * [Command Line Reference (CLI)](./help_general.md) diff --git a/book/src/gloas_builder_config.md b/book/src/gloas_builder_config.md new file mode 100644 index 00000000000..4709885b7e1 --- /dev/null +++ b/book/src/gloas_builder_config.md @@ -0,0 +1,90 @@ +# Builder Configuration + +> This applies from the **Gloas** fork onwards. It configures how the validator client sources +> execution-payload bids from external builders under ePBS. + +The validator client reads its external-builder settings from a YAML file named +`builder_definitions.yml` in the validator directory +(`/validators/builder_definitions.yml`). The file holds two things: + +- **A global bid policy** — `min_bid` and `builder_boost_factor`, applied to bids received over p2p + (gossip) and used as the default for any builder that does not set its own. +- **A list of builders** to request bids from directly, each with optional per-builder overrides of + the global policy. + +## Example + +```yaml +# Global bid policy: applies to p2p (gossip) bids, and is the default for any +# builder below that omits the corresponding field. +min_bid: 0 # gwei — bids below this rank last; one wins only if nothing else is viable +builder_boost_factor: 100 # percent — 100 = neutral, >100 favors builders, 0 = prefer local + +builders: + # Minimal builder — inherits the global policy. + - enabled: true + url: "https://builder-a.example.com" + max_execution_payment: 1000000000 # gwei — cap on the trusted execution payment + + # Builder overriding the globals and pinning the expected builder key. + - enabled: true + url: "https://builder-b.example.com" + max_execution_payment: 1000000000 + min_bid: 500000000 # override the global for this builder + builder_boost_factor: 120 # override the global for this builder + builder_pubkeys: # optional — reject a bid not signed by one of these keys + - "0xa1b2c3d4..." + # auth_data: "0x68747470..." # optional — defaults to the UTF-8 bytes of `url` +``` + +> **Comments are not preserved.** The validator client rewrites this file when builders are added or +> removed (for example via the keymanager API), which strips YAML comments. Keep an annotated copy +> elsewhere if you rely on inline notes. + +## Fields + +### Top level (global bid policy) + +| Field | Required | Default | Meaning | +| ------- | ---------- | --------- | --------- | +| `min_bid` | no | `0` | Minimum total payment, in gwei, for a p2p bid. A bid below the floor is ranked behind any floor-clearing candidate (including the local block) and only wins when nothing else is viable. Also the default `min_bid` for any builder that omits it. | +| `builder_boost_factor` | no | `100` | Percentage multiplier applied to p2p bids when comparing against the local block. Also the default for any builder that omits it. | +| `builders` | no | `[]` | The list of builders to request bids from directly. | + +### Per builder (each entry under `builders`) + +| Field | Required | Default | Meaning | +| ------- | ---------- | --------- | --------- | +| `enabled` | **yes** | — | Whether this builder is used. Disabled builders are ignored. | +| `url` | **yes** | — | The builder's `http`/`https` URL. Bids are requested from here at block-production time. | +| `max_execution_payment` | **yes** | — | Cap, in gwei, on the *trusted* execution payment accepted from this builder. | +| `min_bid` | no | *(global)* | Override the global minimum bid for this builder. | +| `builder_boost_factor` | no | *(global)* | Override the global boost factor for this builder. | +| `builder_pubkeys` | no | *(empty)* | The builder's BLS public keys, hex-encoded. If non-empty, a returned bid **not** signed by one of them is rejected. | +| `auth_data` | no | *(UTF-8 of `url`)* | Opaque authentication data, hex-encoded, agreed with the builder out of band. Signed into the request. Must be non-empty when set. Defaults to the UTF-8 bytes of `url`. | + +All byte fields (`builder_pubkeys` entries, `auth_data`) are `0x`-prefixed hex strings. All payment values +(`min_bid`, `max_execution_payment`) are in gwei. + +## How bids are selected + +At block-production time the validator client requests a bid from each enabled builder with a `url`, +and also considers bids seen over p2p. For each candidate bid: + +- **`min_bid`** — a bid whose total value is below the applicable `min_bid` is ranked behind any + floor-clearing candidate (including the local block) rather than dropped, so it wins only when + nothing else is viable (e.g. the local build failed). Direct builders use their own (or the + inherited global) value; p2p bids use the global value. +- **`builder_boost_factor`** — the surviving bid's value is scaled by its boost factor + (`boost × value ÷ 100`) before being compared against the locally-built block. A factor below + `100` favors the local block; above `100` favors the builder; `0` always prefers local; + `2^64 − 1` strongly favors the builder. The factor is a multiplier, not an absolute override, so a + zero-value bid still ranks `0` and loses to any non-zero local block. +- **`max_execution_payment`** — bounds how much of a builder's (off-chain) execution payment counts + toward its bid value. This applies only to direct builders; p2p bids carry no trusted execution + payment. +- **`builder_pubkeys`** — for a direct builder, if non-empty, the returned bid must be signed by + one of these keys or it is discarded. + +The highest-value bid after these rules wins. Per-builder `min_bid`/`builder_boost_factor` apply +only to bids requested directly by URL; p2p bids are governed by the global values. diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index a02b948a909..8d94c774eda 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -3048,188 +3048,6 @@ impl BeaconNodeHttpClient { opt_response.ok_or(Error::StatusCode(StatusCode::NOT_FOUND)) } - // The legacy `GET v4/validator/blocks/{slot}` client methods below are kept alongside the new - // POST variants until the validator client migrates to POST (later in this PR stack), at which - // point they are removed. - - /// `GET v4/validator/blocks/{slot}` - pub async fn get_validator_blocks_v4( - &self, - slot: Slot, - randao_reveal: &SignatureBytes, - graffiti: Option<&Graffiti>, - include_payload: bool, - builder_booster_factor: Option, - graffiti_policy: Option, - ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { - self.get_validator_blocks_v4_modular( - slot, - randao_reveal, - graffiti, - SkipRandaoVerification::No, - include_payload, - builder_booster_factor, - graffiti_policy, - ) - .await - } - - /// `GET v4/validator/blocks/{slot}` - /// - /// Returns either a bare block or the full [`BlockAndEnvelope`] (block + execution payload - /// envelope + blobs + KZG proofs) depending on the `Eth-Execution-Payload-Included` response - /// header. Note that a builder bid yields a bare block even when `include_payload=true`. - #[allow(clippy::too_many_arguments)] - pub async fn get_validator_blocks_v4_modular( - &self, - slot: Slot, - randao_reveal: &SignatureBytes, - graffiti: Option<&Graffiti>, - skip_randao_verification: SkipRandaoVerification, - include_payload: bool, - builder_booster_factor: Option, - graffiti_policy: Option, - ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { - let mut path = self - .post_validator_blocks_v4_path( - slot, - randao_reveal, - graffiti, - skip_randao_verification, - include_payload, - graffiti_policy, - ) - .await?; - - if let Some(builder_booster_factor) = builder_booster_factor { - path.query_pairs_mut() - .append_pair("builder_boost_factor", &builder_booster_factor.to_string()); - } - - let opt_result = self - .get_response_with_response_headers( - path, - Accept::Json, - self.timeouts.get_validator_block, - |response, headers| async move { - let metadata = ProduceBlockV4Metadata::try_from(&headers) - .map_err(Error::InvalidHeaders)?; - let block_response = if metadata.execution_payload_included { - ProduceBlockV4Response::BlockAndEnvelope( - response - .json::, - ProduceBlockV4Metadata, - >>() - .await? - .data, - ) - } else { - ProduceBlockV4Response::BlockOnly( - response - .json::, - ProduceBlockV4Metadata, - >>() - .await? - .data, - ) - }; - Ok((block_response, metadata)) - }, - ) - .await?; - - opt_result.ok_or(Error::StatusCode(StatusCode::NOT_FOUND)) - } - - /// `GET v4/validator/blocks/{slot}` in ssz format - pub async fn get_validator_blocks_v4_ssz( - &self, - slot: Slot, - randao_reveal: &SignatureBytes, - graffiti: Option<&Graffiti>, - include_payload: bool, - builder_booster_factor: Option, - graffiti_policy: Option, - ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { - self.get_validator_blocks_v4_modular_ssz::( - slot, - randao_reveal, - graffiti, - SkipRandaoVerification::No, - include_payload, - builder_booster_factor, - graffiti_policy, - ) - .await - } - - /// `GET v4/validator/blocks/{slot}` in ssz format - /// - /// See [`Self::get_validator_blocks_v4_modular`] for the response semantics. - #[allow(clippy::too_many_arguments)] - pub async fn get_validator_blocks_v4_modular_ssz( - &self, - slot: Slot, - randao_reveal: &SignatureBytes, - graffiti: Option<&Graffiti>, - skip_randao_verification: SkipRandaoVerification, - include_payload: bool, - builder_booster_factor: Option, - graffiti_policy: Option, - ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { - let mut path = self - .post_validator_blocks_v4_path( - slot, - randao_reveal, - graffiti, - skip_randao_verification, - include_payload, - graffiti_policy, - ) - .await?; - - if let Some(builder_booster_factor) = builder_booster_factor { - path.query_pairs_mut() - .append_pair("builder_boost_factor", &builder_booster_factor.to_string()); - } - - let opt_response = self - .get_response_with_response_headers( - path, - Accept::Ssz, - self.timeouts.get_validator_block, - |response, headers| async move { - let metadata = ProduceBlockV4Metadata::try_from(&headers) - .map_err(Error::InvalidHeaders)?; - let response_bytes = response.bytes().await?; - let block_response = if metadata.execution_payload_included { - ProduceBlockV4Response::BlockAndEnvelope( - BlockAndEnvelope::from_ssz_bytes_for_fork( - &response_bytes, - metadata.consensus_version, - ) - .map_err(Error::InvalidSsz)?, - ) - } else { - ProduceBlockV4Response::BlockOnly( - BeaconBlock::from_ssz_bytes_for_fork( - &response_bytes, - metadata.consensus_version, - ) - .map_err(Error::InvalidSsz)?, - ) - }; - - Ok((block_response, metadata)) - }, - ) - .await?; - - opt_response.ok_or(Error::StatusCode(StatusCode::NOT_FOUND)) - } - /// `GET v1/validator/execution_payload_envelopes/{slot}/{beacon_block_root}` pub async fn get_validator_execution_payload_envelopes( &self, diff --git a/testing/validator_test_rig/src/mock_beacon_node.rs b/testing/validator_test_rig/src/mock_beacon_node.rs index d01905c0c7e..2cdc87f560a 100644 --- a/testing/validator_test_rig/src/mock_beacon_node.rs +++ b/testing/validator_test_rig/src/mock_beacon_node.rs @@ -102,8 +102,8 @@ impl MockBeaconNode { .create(); } - /// Mocks `GET /eth/v4/validator/blocks/{slot}` - pub fn mock_get_validator_blocks_v4( + /// Mocks `POST /eth/v4/validator/blocks/{slot}` + pub fn mock_post_validator_blocks_v4( &mut self, block: &BeaconBlock, fork_name: ForkName, @@ -121,7 +121,7 @@ impl MockBeaconNode { }); self.server - .mock("GET", Matcher::Regex(path_pattern.to_string())) + .mock("POST", Matcher::Regex(path_pattern.to_string())) .match_query(Matcher::UrlEncoded( "include_payload".into(), "false".into(), @@ -136,8 +136,8 @@ impl MockBeaconNode { .create() } - /// Mocks `GET /eth/v4/validator/blocks/{slot}` (SSZ) - pub fn mock_get_validator_blocks_v4_ssz( + /// Mocks `POST /eth/v4/validator/blocks/{slot}` (SSZ) + pub fn mock_post_validator_blocks_v4_ssz( &mut self, block: &BeaconBlock, fork_name: ForkName, @@ -149,7 +149,7 @@ impl MockBeaconNode { let ssz_bytes = block.as_ssz_bytes(); self.server - .mock("GET", Matcher::Regex(path_pattern.to_string())) + .mock("POST", Matcher::Regex(path_pattern.to_string())) .match_query(Matcher::UrlEncoded( "include_payload".into(), "false".into(), @@ -165,13 +165,13 @@ impl MockBeaconNode { .create() } - /// Mocks `GET /eth/v4/validator/blocks/{slot}` (SSZ) returning error - pub fn mock_get_validator_blocks_v4_ssz_error(&mut self, slot: Slot) -> Mock { + /// Mocks `POST /eth/v4/validator/blocks/{slot}` (SSZ) returning error + pub fn mock_post_validator_blocks_v4_ssz_error(&mut self, slot: Slot) -> Mock { let path_pattern = Regex::new(&format!(r"^/eth/v4/validator/blocks/{}", slot.as_u64())).unwrap(); self.server - .mock("GET", Matcher::Regex(path_pattern.to_string())) + .mock("POST", Matcher::Regex(path_pattern.to_string())) .match_query(Matcher::UrlEncoded( "include_payload".into(), "false".into(), diff --git a/testing/validator_test_rig/src/mock_validator_store.rs b/testing/validator_test_rig/src/mock_validator_store.rs index e4ce9772647..9fb75ae6b8b 100644 --- a/testing/validator_test_rig/src/mock_validator_store.rs +++ b/testing/validator_test_rig/src/mock_validator_store.rs @@ -1,4 +1,5 @@ use bls::{PublicKeyBytes, Signature}; +use eth2::types::{RequestAuth, SignedRequestAuth}; use futures::future::{BoxFuture, FutureExt}; use futures::{Stream, stream}; use std::future::Future; @@ -184,6 +185,14 @@ impl ValidatorStore for MockValidatorStore { panic!("MockValidatorStore::sign_proposer_preferences called without a hook") } + async fn sign_request_auth_v1( + &self, + _validator_pubkey: PublicKeyBytes, + _request_auth_v1: RequestAuth, + ) -> Result> { + panic!("MockValidatorStore::sign_request_auth_v1 called without a hook") + } + fn proposal_data(&self, _pubkey: &PublicKeyBytes) -> Option { panic!("MockValidatorStore::proposal_data called without a hook") } diff --git a/validator_client/Cargo.toml b/validator_client/Cargo.toml index 6990a2f61a7..ab5a85e8b0e 100644 --- a/validator_client/Cargo.toml +++ b/validator_client/Cargo.toml @@ -11,6 +11,7 @@ path = "src/lib.rs" [dependencies] account_utils = { workspace = true } beacon_node_fallback = { workspace = true } +builder_store = { workspace = true } clap = { workspace = true } clap_utils = { workspace = true } directory = { workspace = true } diff --git a/validator_client/builder_store/Cargo.toml b/validator_client/builder_store/Cargo.toml new file mode 100644 index 00000000000..cad40ec39fc --- /dev/null +++ b/validator_client/builder_store/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "builder_store" +version = "0.1.0" +edition = { workspace = true } +authors = ["Sigma Prime "] + +[lib] +name = "builder_store" +path = "src/lib.rs" + +[dependencies] +account_utils = { workspace = true } +bls = { workspace = true } +builder_types = { workspace = true } +filesystem = { workspace = true } +futures = { workspace = true } +hex = { workspace = true } +parking_lot = { workspace = true } +serde = { workspace = true } +ssz_types = { workspace = true } +tracing = { workspace = true } +types = { workspace = true } +yaml_serde = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/validator_client/builder_store/src/builder_definitions.rs b/validator_client/builder_store/src/builder_definitions.rs new file mode 100644 index 00000000000..928f6d3ec14 --- /dev/null +++ b/validator_client/builder_store/src/builder_definitions.rs @@ -0,0 +1,292 @@ +use account_utils::write_file_via_temporary; +use bls::PublicKeyBytes; +use builder_types::{BuilderUrl, MAX_BUILDER_ENTRIES, RequestAuthData}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::fs::{File, create_dir_all}; +use std::io; +use std::path::{Path, PathBuf}; + +/// The file name for the serialized `BuilderConfigFile` struct. +pub const BUILDERS_FILENAME: &str = "builder_definitions.yml"; +/// The temporary file name for the serialized `BuilderConfigFile` struct. +/// +/// This is used to achieve an atomic update of the contents on disk, without truncation. +pub const BUILDERS_TEMP_FILENAME: &str = ".builder_definitions.yml.tmp"; + +#[derive(Debug)] +pub enum Error { + /// The config file could not be opened. + UnableToOpenFile(io::Error), + /// The config file could not be parsed as YAML. + UnableToParseFile(yaml_serde::Error), + /// The builders file could not be serialized as YAML. + UnableToEncodeFile(yaml_serde::Error), + /// The builders file or temp file could not be written to the filesystem. + UnableToWriteFile(filesystem::Error), + /// The validator directory could not be created. + UnableToCreateValidatorDir(PathBuf), + /// A builder with the given URL already exists. + DuplicateBuilderAuth(BuilderUrl), + /// A builder URL could not be parsed as a URL. + InvalidBuilderUrl(BuilderUrl), + /// A builder URL does not use an `http`/`https` scheme. + UnsupportedUrlScheme(BuilderUrl), + /// More than `MAX_BUILDER_ENTRIES` builders are enabled, exceeding what fits in a + /// `BuilderConfig`. + TooManyEnabledBuilders { enabled: usize, max: usize }, +} + +/// A single builder in the config file: a direct bid request, with optional per-builder overrides +/// of the global bid policy. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BuilderDefinition { + /// Indicates whether this definition is enabled or disabled. + pub enabled: bool, + /// The URL the beacon node uses to contact this builder. Routing metadata; never signed. + pub url: BuilderUrl, + /// Opaque authentication data signed into `RequestAuth.data`, agreed with the builder out of + /// band, as a `0x`-prefixed hex string. When unset, it defaults to the UTF-8 bytes of `url` + /// (the builder-specs #165 default). Must be non-empty when set: a zero-length `data` is + /// invalid on the wire. + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "serde_option_auth_data" + )] + pub auth_data: Option, + /// The builder BLS public keys this builder's bids may be signed by, hex-encoded. Empty (or + /// omitted) accepts any builder; otherwise a bid not signed by one of them is rejected. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub builder_pubkeys: Vec, + /// The maximum execution payment, in gwei, that we're willing to accept from this builder. + pub max_execution_payment: u64, + /// Per-builder override of the global minimum total payment (gwei). Inherits the global when + /// unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min_bid: Option, + /// Per-builder override of the global boost factor. Inherits the global when unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub builder_boost_factor: Option, +} + +fn default_builder_boost_factor() -> u64 { + 100 +} + +/// Serde helper: represent `Option` as a `0x`-prefixed hex string in the config +/// file (matching how other byte fields are encoded), omitting it entirely when `None`. +mod serde_option_auth_data { + use super::RequestAuthData; + use serde::{Deserialize, Deserializer, Serializer, de}; + + pub fn serialize( + value: &Option, + serializer: S, + ) -> Result { + match value { + Some(data) => serializer.serialize_some(&format!("0x{}", hex::encode(&data[..]))), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + let Some(s) = Option::::deserialize(deserializer)? else { + return Ok(None); + }; + let stripped = s.strip_prefix("0x").unwrap_or(&s); + let bytes = hex::decode(stripped).map_err(de::Error::custom)?; + let data = RequestAuthData::new(bytes) + .map_err(|_| de::Error::custom("auth_data exceeds the maximum size"))?; + Ok(Some(data)) + } +} + +/// The validator client's builder configuration file. +/// +/// Holds the global bid-policy defaults plus the list of builders to request bids from directly. It +/// resolves into the wire `BuilderConfig` at block-production time: the globals govern p2p bids and +/// fill in any builder that omits `min_bid`/`builder_boost_factor`. +#[derive(Clone, Serialize, Deserialize)] +pub struct BuilderConfigFile { + /// Global minimum total payment (gwei). Applies to p2p bids and is inherited by any builder that + /// omits its own `min_bid`. + #[serde(default)] + pub min_bid: u64, + /// Global boost factor. Applies to p2p bids and is inherited by any builder that omits its own + /// `builder_boost_factor`. + #[serde(default = "default_builder_boost_factor")] + pub builder_boost_factor: u64, + /// The builders to request bids from directly. + #[serde(default)] + pub builders: Vec, +} + +impl Default for BuilderConfigFile { + fn default() -> Self { + Self { + min_bid: 0, + builder_boost_factor: default_builder_boost_factor(), + builders: Vec::new(), + } + } +} + +impl BuilderConfigFile { + /// Open an existing file or create a new, empty one if it does not exist. + pub fn open_or_create>(validators_dir: P) -> Result { + create_dir_all(validators_dir.as_ref()).map_err(|_| { + Error::UnableToCreateValidatorDir(PathBuf::from(validators_dir.as_ref())) + })?; + let builders_file_path = validators_dir.as_ref().join(BUILDERS_FILENAME); + if !builders_file_path.exists() { + let this = Self::default(); + this.save(&validators_dir)?; + } + Self::open(validators_dir) + } + + /// Open an existing file, returning an error if the file does not exist. + pub fn open>(validators_dir: P) -> Result { + let config_path = validators_dir.as_ref().join(BUILDERS_FILENAME); + let file = File::options() + .write(true) + .read(true) + .create_new(false) + .open(config_path) + .map_err(Error::UnableToOpenFile)?; + let config: Self = yaml_serde::from_reader(file).map_err(Error::UnableToParseFile)?; + config.validate()?; + Ok(config) + } + + /// Encodes `self` as a YAML string and atomically writes it to the `CONFIG_FILENAME` file in + /// the `validators_dir` directory. + /// + /// Will create a new file if it does not exist or overwrite any existing file. + pub fn save>(&self, validators_dir: P) -> Result<(), Error> { + let config_path = validators_dir.as_ref().join(BUILDERS_FILENAME); + let temp_path = validators_dir.as_ref().join(BUILDERS_TEMP_FILENAME); + let mut bytes = vec![]; + yaml_serde::to_writer(&mut bytes, self).map_err(Error::UnableToEncodeFile)?; + + write_file_via_temporary(&config_path, &temp_path, &bytes) + .map_err(Error::UnableToWriteFile)?; + + Ok(()) + } + + pub fn as_slice(&self) -> &[BuilderDefinition] { + &self.builders + } + + pub fn push(&mut self, definition: BuilderDefinition) { + self.builders.push(definition); + } + + pub fn validate(&self) -> Result<(), Error> { + // The enabled builders must fit in a `BuilderConfig`'s bounded list, so + // `BuilderStore::builder_config` cannot overflow when constructing it. + let enabled = self.builders.iter().filter(|d| d.enabled).count(); + if enabled > MAX_BUILDER_ENTRIES { + return Err(Error::TooManyEnabledBuilders { + enabled, + max: MAX_BUILDER_ENTRIES, + }); + } + + let mut seen_auth_urls = HashSet::new(); + + for definition in &self.builders { + if !definition.enabled { + // ignore disabled builders + continue; + } + let url = &definition.url; + // Reject malformed or non-http(s) builder URLs here, at config load, rather than + // silently skipping them during block proposal. + let sensitive_url = url + .to_sensitive_url() + .map_err(|_| Error::InvalidBuilderUrl(url.clone()))?; + if !matches!(sensitive_url.expose_full().scheme(), "http" | "https") { + return Err(Error::UnsupportedUrlScheme(url.clone())); + } + + let auth = definition + .auth_data + .clone() + .unwrap_or_else(|| url.to_default_auth_data()); + // two entries cannot contain the same url and auth data + let key = (url.clone(), auth); + if !seen_auth_urls.insert(key) { + return Err(Error::DuplicateBuilderAuth(url.clone())); + } + } + + Ok(()) + } +} + +impl<'a> IntoIterator for &'a BuilderConfigFile { + type Item = &'a BuilderDefinition; + type IntoIter = std::slice::Iter<'a, BuilderDefinition>; + + fn into_iter(self) -> Self::IntoIter { + self.builders.iter() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auth_data_round_trips_as_hex() { + let definition = BuilderDefinition { + enabled: true, + url: "http://builder.example.com".parse().unwrap(), + auth_data: Some(RequestAuthData::new(b"hello".to_vec()).unwrap()), + builder_pubkeys: vec![], + max_execution_payment: 1, + min_bid: None, + builder_boost_factor: None, + }; + + let yaml = yaml_serde::to_string(&definition).unwrap(); + // "hello" is 0x68656c6c6f, a hex string — not a YAML sequence of byte values. + assert!( + yaml.contains("0x68656c6c6f"), + "auth_data not hex-encoded:\n{yaml}" + ); + + let decoded: BuilderDefinition = yaml_serde::from_str(&yaml).unwrap(); + assert_eq!(decoded, definition); + } + + #[test] + fn omits_none_optional_fields() { + let definition = BuilderDefinition { + enabled: true, + url: "http://builder.example.com".parse().unwrap(), + auth_data: None, + builder_pubkeys: vec![], + max_execution_payment: 1, + min_bid: None, + builder_boost_factor: None, + }; + let yaml = yaml_serde::to_string(&definition).unwrap(); + for field in [ + "auth_data", + "builder_pubkeys", + "min_bid", + "builder_boost_factor", + ] { + assert!( + !yaml.contains(field), + "unset `{field}` should be omitted:\n{yaml}" + ); + } + } +} diff --git a/validator_client/builder_store/src/lib.rs b/validator_client/builder_store/src/lib.rs new file mode 100644 index 00000000000..d17c3c73260 --- /dev/null +++ b/validator_client/builder_store/src/lib.rs @@ -0,0 +1,140 @@ +mod builder_definitions; +use builder_definitions::BuilderConfigFile; +pub use builder_definitions::{BuilderDefinition, Error}; +use builder_types::{ + BuilderConfig, BuilderEntry, BuilderPubkeys, RequestAuthData, SignedRequestAuth, +}; +use parking_lot::RwLock; +use ssz_types::VariableList; +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tracing::error; + +#[derive(Clone)] +pub struct BuilderStore { + config: Arc>, + validators_dir: PathBuf, +} + +impl BuilderStore { + pub fn open_or_create>(validators_dir: P) -> Result { + let validators_dir = validators_dir.as_ref().to_path_buf(); + + Ok(Self { + config: Arc::new(RwLock::new(BuilderConfigFile::open_or_create( + &validators_dir, + )?)), + validators_dir, + }) + } + + /// Resolve the enabled builders into a wire [`BuilderConfig`], signing each builder's request + /// auth via `sign`. + /// + /// Per-builder `min_bid`/`builder_boost_factor` inherit the global defaults when unset, and each + /// builder's `auth_data` defaults to the UTF-8 bytes of its URL when unset. `sign` receives a + /// builder's opaque auth `data` and returns the corresponding `SignedRequestAuth` — in + /// practice signed for the current proposer/slot and cached. + /// + /// Signing is per-builder: a builder whose auth `sign` fails to produce is logged (with the + /// returned error) and omitted, so one unsignable builder cannot drop the rest. The returned + /// config always carries the global policy; its `builders` list holds only the successfully + /// signed builders, and is empty when no builders are enabled or every one failed to sign. + pub async fn builder_config(&self, sign: F) -> BuilderConfig + where + F: Fn(RequestAuthData) -> Fut, + Fut: Future>, + E: std::fmt::Debug, + { + // Snapshot the enabled builders and the global policy under the lock, then sign outside it, + // so the lock is never held across an `.await`. + let (definitions, min_bid, builder_boost_factor) = { + let config = self.config.read(); + let definitions: Vec = config + .as_slice() + .iter() + .filter(|d| d.enabled) + .cloned() + .collect(); + (definitions, config.min_bid, config.builder_boost_factor) + }; + + // Sign every builder's request auth concurrently. With a remote signer each `sign` is a + // network round trip, and the signatures are independent, so signing in sequence would put + // up to `MaxBuilderEntries` serial round trips on the block-production critical path. + let signed = futures::future::join_all(definitions.into_iter().filter_map(|definition| { + let auth_data = definition + .auth_data + .clone() + .unwrap_or_else(|| definition.url.to_default_auth_data()); + // A zero-length auth `data` is invalid on the wire (beacon-specs #165 / beacon-APIs + // #630); the beacon node would reject the whole request body, so drop the builder here. + if auth_data.is_empty() { + error!( + builder_url = %definition.url, + "Zero-length auth_data is invalid; omitting builder from config" + ); + return None; + } + let signing = sign(auth_data); + Some(async move { (definition, signing.await) }) + })) + .await; + + // `join_all` preserves input order, so `builders` keeps the configured order. Omit any + // builder we cannot sign for, logging the error, rather than failing the whole config. + let mut builders = Vec::with_capacity(signed.len()); + for (definition, result) in signed { + let auth = match result { + Ok(auth) => auth, + Err(e) => { + error!( + error = ?e, + builder_url = %definition.url, + "Failed to sign builder request auth; omitting builder from config" + ); + continue; + } + }; + let Ok(builder_pubkeys) = BuilderPubkeys::new(definition.builder_pubkeys) else { + error!( + builder_url = %definition.url, + "Too many builder pubkeys; omitting builder from config" + ); + continue; + }; + builders.push(BuilderEntry { + url: definition.url, + auth, + builder_pubkeys, + max_execution_payment: definition.max_execution_payment, + min_bid: definition.min_bid.unwrap_or(min_bid), + builder_boost_factor: definition + .builder_boost_factor + .unwrap_or(builder_boost_factor), + }); + } + + BuilderConfig { + // The number of builders is bounded by `MaxBuilderEntries` at config load, so this + // cannot overflow. + builders: VariableList::new(builders) + .expect("builder count is bounded by MaxBuilderEntries at config load"), + min_bid, + builder_boost_factor, + } + } + + pub fn insert(&self, builder: BuilderDefinition) -> Result<(), Error> { + let mut config = self.config.write(); + // Validate a candidate copy before committing, so a bad insert leaves the config unchanged + // (and the global bid-policy defaults are preserved). + let mut candidate = config.clone(); + candidate.push(builder); + candidate.validate()?; + + *config = candidate; + config.save(&self.validators_dir) + } +} diff --git a/validator_client/lighthouse_validator_store/Cargo.toml b/validator_client/lighthouse_validator_store/Cargo.toml index 55d5f1cf32e..2020280e0bc 100644 --- a/validator_client/lighthouse_validator_store/Cargo.toml +++ b/validator_client/lighthouse_validator_store/Cargo.toml @@ -8,6 +8,7 @@ authors = ["Sigma Prime "] account_utils = { workspace = true } beacon_node_fallback = { workspace = true } bls = { workspace = true } +builder_types = { workspace = true } doppelganger_service = { workspace = true } either = { workspace = true } environment = { workspace = true } diff --git a/validator_client/lighthouse_validator_store/src/lib.rs b/validator_client/lighthouse_validator_store/src/lib.rs index ce2b85f3af5..d98e80cf454 100644 --- a/validator_client/lighthouse_validator_store/src/lib.rs +++ b/validator_client/lighthouse_validator_store/src/lib.rs @@ -1,5 +1,6 @@ use account_utils::validator_definitions::{PasswordStorage, ValidatorDefinition}; use bls::{AggregateSignature, PublicKeyBytes, Signature}; +use builder_types::{RequestAuth, SignedRequestAuth}; use doppelganger_service::DoppelgangerService; use eth2::types::PublishBlockRequest; use futures::{Stream, future::join_all, stream}; @@ -1502,4 +1503,28 @@ impl ValidatorStore for LighthouseValidatorS signature, }) } + + async fn sign_request_auth_v1( + &self, + validator_pubkey: PublicKeyBytes, + request_auth_v1: RequestAuth, + ) -> Result { + let domain_hash = self.spec.get_request_auth_domain(); + let signing_root = request_auth_v1.signing_root(domain_hash); + + let signing_method = self.doppelganger_bypassed_signing_method(validator_pubkey)?; + let signature = signing_method + .get_signature_from_root::>( + SignableMessage::RequestAuth(&request_auth_v1), + signing_root, + &self.task_executor, + None, + ) + .await?; + + Ok(SignedRequestAuth { + message: request_auth_v1, + signature, + }) + } } diff --git a/validator_client/signing_method/Cargo.toml b/validator_client/signing_method/Cargo.toml index cb321c2d498..2a33382d5e8 100644 --- a/validator_client/signing_method/Cargo.toml +++ b/validator_client/signing_method/Cargo.toml @@ -6,6 +6,7 @@ authors = ["Sigma Prime "] [dependencies] bls = { workspace = true } +builder_types = { workspace = true } eth2_keystore = { workspace = true } ethereum_serde_utils = { workspace = true } lockfile = { workspace = true } diff --git a/validator_client/signing_method/src/lib.rs b/validator_client/signing_method/src/lib.rs index 0dfde989464..f877afeaa7e 100644 --- a/validator_client/signing_method/src/lib.rs +++ b/validator_client/signing_method/src/lib.rs @@ -4,6 +4,7 @@ //! - Via a remote signer (Web3Signer) use bls::{Keypair, PublicKey, Signature}; +use builder_types::RequestAuth; use eth2_keystore::Keystore; use lockfile::Lockfile; use parking_lot::Mutex; @@ -52,6 +53,7 @@ pub enum SignableMessage<'a, E: EthSpec, Payload: AbstractExecPayload = FullP ExecutionPayloadEnvelope(&'a ExecutionPayloadEnvelope), PayloadAttestationData(&'a PayloadAttestationData), ProposerPreferences(&'a ProposerPreferences), + RequestAuth(&'a RequestAuth), } impl> SignableMessage<'_, E, Payload> { @@ -76,6 +78,7 @@ impl> SignableMessage<'_, E, Payload SignableMessage::ExecutionPayloadEnvelope(e) => e.signing_root(domain), SignableMessage::PayloadAttestationData(d) => d.signing_root(domain), SignableMessage::ProposerPreferences(p) => p.signing_root(domain), + SignableMessage::RequestAuth(r) => r.signing_root(domain), } } } @@ -248,6 +251,7 @@ impl SigningMethod { SignableMessage::ProposerPreferences(p) => { Web3SignerObject::ProposerPreferences(p) } + SignableMessage::RequestAuth(r) => Web3SignerObject::RequestAuth(r), }; // Determine the Web3Signer message type. diff --git a/validator_client/signing_method/src/web3signer.rs b/validator_client/signing_method/src/web3signer.rs index 8548a933e66..505147a46d4 100644 --- a/validator_client/signing_method/src/web3signer.rs +++ b/validator_client/signing_method/src/web3signer.rs @@ -2,6 +2,7 @@ use super::Error; use bls::{PublicKeyBytes, Signature}; +use builder_types::RequestAuth; use serde::{Deserialize, Serialize}; use types::*; @@ -23,6 +24,7 @@ pub enum MessageType { ExecutionPayloadEnvelope, PayloadAttestation, ProposerPreferences, + RequestAuth, } #[derive(Debug, PartialEq, Copy, Clone, Serialize)] @@ -83,6 +85,7 @@ pub enum Web3SignerObject<'a, E: EthSpec, Payload: AbstractExecPayload> { ExecutionPayloadEnvelope(&'a ExecutionPayloadEnvelope), PayloadAttestationData(&'a PayloadAttestationData), ProposerPreferences(&'a ProposerPreferences), + RequestAuth(&'a RequestAuth), } impl<'a, E: EthSpec, Payload: AbstractExecPayload> Web3SignerObject<'a, E, Payload> { @@ -156,6 +159,7 @@ impl<'a, E: EthSpec, Payload: AbstractExecPayload> Web3SignerObject<'a, E, Pa Web3SignerObject::ExecutionPayloadEnvelope(_) => MessageType::ExecutionPayloadEnvelope, Web3SignerObject::PayloadAttestationData(_) => MessageType::PayloadAttestation, Web3SignerObject::ProposerPreferences(_) => MessageType::ProposerPreferences, + Web3SignerObject::RequestAuth(_) => MessageType::RequestAuth, } } } diff --git a/validator_client/src/lib.rs b/validator_client/src/lib.rs index 88844918431..7697a08c45b 100644 --- a/validator_client/src/lib.rs +++ b/validator_client/src/lib.rs @@ -3,6 +3,7 @@ pub mod config; use crate::cli::ValidatorClient; use crate::duties_service::SelectionProofConfig; +use builder_store::BuilderStore; pub use config::Config; use initialized_validators::InitializedValidators; use metrics::set_gauge; @@ -43,11 +44,13 @@ use validator_services::notifier_service::spawn_notifier; use validator_services::{ attestation_service::{AttestationService, AttestationServiceBuilder}, block_service::{BlockService, BlockServiceBuilder}, + builder_preferences_service::BuilderPreferencesService, duties_service::{self, DutiesService, DutiesServiceBuilder}, latency_service, payload_attestation_service::PayloadAttestationService, preparation_service::{PreparationService, PreparationServiceBuilder}, proposer_preferences_service::ProposerPreferencesService, + request_auth_cache::RequestAuthCache, sync_committee_service::SyncCommitteeService, }; use validator_store::ValidatorStore as ValidatorStoreTrait; @@ -91,6 +94,7 @@ pub struct ProductionValidatorClient { doppelganger_service: Option>, preparation_service: PreparationService, SystemTimeSlotClock>, validator_store: Arc>, + builder_preferences_service: BuilderPreferencesService, SystemTimeSlotClock>, slot_clock: SystemTimeSlotClock, http_api_listen_addr: Option, config: Config, @@ -513,6 +517,10 @@ impl ProductionValidatorClient { ctx.shared.write().duties_service = Some(duties_service.clone()); } + let configured_builders = BuilderStore::open_or_create(&config.validator_dir) + .map_err(|e| format!("Unable to open or create builder definitions: {:?}", e))?; + let request_auth_cache = RequestAuthCache::default(); + let mut block_service_builder = BlockServiceBuilder::new() .slot_clock(slot_clock.clone()) .validator_store(validator_store.clone()) @@ -521,7 +529,9 @@ impl ProductionValidatorClient { .chain_spec(context.eth2_config.spec.clone()) .graffiti(config.graffiti) .graffiti_file(config.graffiti_file.clone()) - .graffiti_policy(config.graffiti_policy); + .graffiti_policy(config.graffiti_policy) + .configured_builders(configured_builders.clone()) + .request_auth_cache(request_auth_cache.clone()); // If we have proposer nodes, add them to the block service builder. if proposer_nodes_num > 0 { @@ -577,6 +587,17 @@ impl ProductionValidatorClient { context.eth2_config.spec.clone(), ); + let builder_preferences_service = BuilderPreferencesService::new( + duties_service.clone(), + validator_store.clone(), + slot_clock.clone(), + beacon_nodes.clone(), + configured_builders.clone(), + request_auth_cache.clone(), + context.executor.clone(), + context.eth2_config.spec.clone(), + ); + Ok(Self { context, duties_service, @@ -588,6 +609,7 @@ impl ProductionValidatorClient { doppelganger_service, preparation_service, validator_store, + builder_preferences_service, config, slot_clock, http_api_listen_addr: None, @@ -667,6 +689,11 @@ impl ProductionValidatorClient { .clone() .start_update_service() .map_err(|e| format!("Unable to start proposer preferences service: {}", e))?; + + self.builder_preferences_service + .clone() + .start_update_service() + .map_err(|e| format!("Unable to start builder preferences service: {}", e))?; } self.preparation_service diff --git a/validator_client/validator_services/Cargo.toml b/validator_client/validator_services/Cargo.toml index 625eee85bdb..5fb3137057e 100644 --- a/validator_client/validator_services/Cargo.toml +++ b/validator_client/validator_services/Cargo.toml @@ -7,6 +7,8 @@ authors = ["Sigma Prime "] [dependencies] beacon_node_fallback = { workspace = true } bls = { workspace = true } +builder_store = { workspace = true } +builder_types = { workspace = true } either = { workspace = true } eth2 = { workspace = true } futures = { workspace = true } diff --git a/validator_client/validator_services/src/block_service.rs b/validator_client/validator_services/src/block_service.rs index 7531543a187..b2cfa138e88 100644 --- a/validator_client/validator_services/src/block_service.rs +++ b/validator_client/validator_services/src/block_service.rs @@ -1,5 +1,7 @@ +use crate::request_auth_cache::RequestAuthCache; use beacon_node_fallback::{ApiTopic, BeaconNodeFallback, Error as FallbackError, Errors}; use bls::PublicKeyBytes; +use builder_store::BuilderStore; use eth2::BeaconNodeHttpClient; use eth2::types::GraffitiPolicy; use graffiti_file::{GraffitiFile, determine_graffiti}; @@ -53,6 +55,8 @@ pub struct BlockServiceBuilder { graffiti: Option, graffiti_file: Option, graffiti_policy: Option, + configured_builders: Option, + request_auth_cache: Option, } impl BlockServiceBuilder { @@ -67,6 +71,8 @@ impl BlockServiceBuilder { graffiti: None, graffiti_file: None, graffiti_policy: None, + configured_builders: None, + request_auth_cache: None, } } @@ -115,6 +121,16 @@ impl BlockServiceBuilder { self } + pub fn configured_builders(mut self, configured_builders: BuilderStore) -> Self { + self.configured_builders = Some(configured_builders); + self + } + + pub fn request_auth_cache(mut self, request_auth_cache: RequestAuthCache) -> Self { + self.request_auth_cache = Some(request_auth_cache); + self + } + pub fn build(self) -> Result, String> { Ok(BlockService { inner: Arc::new(Inner { @@ -137,6 +153,12 @@ impl BlockServiceBuilder { graffiti: self.graffiti, graffiti_file: self.graffiti_file, graffiti_policy: self.graffiti_policy, + configured_builders: self + .configured_builders + .ok_or("Cannot build BlockService without configured_builders")?, + request_auth_cache: self + .request_auth_cache + .ok_or("Cannot build BlockService without request_auth_cache")?, }), }) } @@ -203,6 +225,11 @@ pub struct Inner { graffiti: Option, graffiti_file: Option, graffiti_policy: Option, + /// The configured builders to resolve into a `BuilderConfig` when producing a Gloas block. + configured_builders: BuilderStore, + /// Caches the per-(slot, proposer, auth_data) request-auth signatures reused when resolving the + /// builder config. + request_auth_cache: RequestAuthCache, } /// Attempts to produce attestations for any block producer(s) at the start of the epoch. @@ -339,6 +366,7 @@ impl BlockService { graffiti: Option, validator_pubkey: &PublicKeyBytes, unsigned_block: UnsignedBlock, + builder_url: Option, ) -> Result<(), BlockError> { let signing_timer = validator_metrics::start_timer(&validator_metrics::BLOCK_SIGNING_TIMES); @@ -383,9 +411,10 @@ impl BlockService { // Try the proposer nodes first, since we've likely gone to efforts to // protect them from DoS attacks and they're most likely to successfully // publish a block. + let builder_url_ref = builder_url.as_deref(); proposer_fallback .request_proposers_first(|beacon_node| async { - self.publish_signed_block_contents(&signed_block, beacon_node) + self.publish_signed_block_contents(&signed_block, beacon_node, builder_url_ref) .await }) .await?; @@ -463,7 +492,35 @@ impl BlockService { // Check if Gloas fork is active at this slot let fork_name = self_ref.chain_spec.fork_name_at_slot::(slot); - let (block_proposer, unsigned_block) = if fork_name.gloas_enabled() { + let (block_proposer, unsigned_block, builder_url) = if fork_name.gloas_enabled() { + // Resolve the validator's builder config for this proposal, signing each builder's + // request auth via the cache. Sent in the POST `produceBlockV4` body below (the same + // body is reused on the SSZ-to-JSON fallback and on every proposer-fallback BN). With + // no builders configured this resolves to an empty list, so the proposal still falls + // back to a local or p2p payload. Per-builder sign failures are logged and omitted + // inside `builder_config`, so this never fails the proposal. + let builder_config = self_ref + .configured_builders + .builder_config(|auth_data| { + self_ref.request_auth_cache.get_or_sign( + slot, + validator_pubkey, + auth_data, + |request_auth_v1| { + self_ref + .validator_store + .sign_request_auth_v1(validator_pubkey, request_auth_v1) + }, + ) + }) + .await; + debug!( + slot = slot.as_u64(), + builders = builder_config.builders.len(), + "Resolved builder config for block production" + ); + let builder_config_ref = &builder_config; + // Use V4 block production for Gloas // Request an SSZ block from all beacon nodes in order, returning on the first successful response. // If all nodes fail, run a second pass falling back to JSON. @@ -474,20 +531,25 @@ impl BlockService { &[validator_metrics::BEACON_BLOCK_HTTP_GET], ); beacon_node - .get_validator_blocks_v4_ssz::( + .post_validator_blocks_v4_ssz::( slot, randao_reveal_ref, graffiti.as_ref(), false, - builder_boost_factor, + builder_config_ref, self_ref.graffiti_policy, + fork_name, ) .await }) .await; - let block_response = match ssz_block_response { - Ok((ssz_block_response, _metadata)) => ssz_block_response.into_block(), + // `builder_url` is the `Eth-Builder-Url` from the winning beacon node — echoed on publish + // so it forwards the block to the builder that won selection. + let (block_response, builder_url) = match ssz_block_response { + Ok((ssz_block_response, metadata)) => { + (ssz_block_response.into_block(), metadata.builder_url) + } Err(e) => { warn!( slot = slot.as_u64(), @@ -501,14 +563,15 @@ impl BlockService { &validator_metrics::BLOCK_SERVICE_TIMES, &[validator_metrics::BEACON_BLOCK_HTTP_GET], ); - let (json_block_response, _metadata) = beacon_node - .get_validator_blocks_v4::( + let (json_block_response, metadata) = beacon_node + .post_validator_blocks_v4::( slot, randao_reveal_ref, graffiti.as_ref(), false, - builder_boost_factor, + builder_config_ref, self_ref.graffiti_policy, + fork_name, ) .await .map_err(|e| { @@ -518,7 +581,7 @@ impl BlockService { )) })?; - Ok(json_block_response.into_block()) + Ok((json_block_response.into_block(), metadata.builder_url)) }) .await .map_err(BlockError::from)? @@ -530,6 +593,7 @@ impl BlockService { ( block_contents.block().proposer_index(), UnsignedBlock::Full(block_contents), + builder_url, ) } else { // Use V3 block production for pre-Gloas forks @@ -594,12 +658,16 @@ impl BlockService { } }; + // Pre-Gloas has no builder-URL provenance (the V3 mev-boost path handles builder + // forwarding itself), so there's nothing to echo on publish. match block_response { - eth2::types::ProduceBlockV3Response::Full(block) => { - (block.block().proposer_index(), UnsignedBlock::Full(block)) - } + eth2::types::ProduceBlockV3Response::Full(block) => ( + block.block().proposer_index(), + UnsignedBlock::Full(block), + None, + ), eth2::types::ProduceBlockV3Response::Blinded(block) => { - (block.proposer_index(), UnsignedBlock::Blinded(block)) + (block.proposer_index(), UnsignedBlock::Blinded(block), None) } } }; @@ -623,6 +691,7 @@ impl BlockService { graffiti, &validator_pubkey, unsigned_block, + builder_url, ) .await?; @@ -742,6 +811,7 @@ impl BlockService { &self, signed_block: &SignedBlock, beacon_node: BeaconNodeHttpClient, + builder_url: Option<&str>, ) -> Result<(), BlockError> { match signed_block { SignedBlock::Full(signed_block) => { @@ -750,7 +820,7 @@ impl BlockService { &[validator_metrics::BEACON_BLOCK_HTTP_POST], ); beacon_node - .post_beacon_blocks_v2_ssz(signed_block, None, None) + .post_beacon_blocks_v2_ssz(signed_block, None, builder_url) .await .map(|_| ()) .or_else(|e| { @@ -854,6 +924,10 @@ mod tests { .beacon_nodes(harness.beacon_nodes.clone()) .executor(harness.test_runtime.task_executor.clone()) .chain_spec(harness.spec.clone()) + .request_auth_cache(RequestAuthCache::default()) + .configured_builders( + BuilderStore::open_or_create(harness._validator_dir.path()).unwrap(), + ) .build() .unwrap(); @@ -880,7 +954,11 @@ mod tests { let mock_different_slot = test_harness .harness .mock_beacon_node_1 - .mock_get_validator_blocks_v4_ssz(&block, ForkName::Gloas, different_notification_slot); + .mock_post_validator_blocks_v4_ssz( + &block, + ForkName::Gloas, + different_notification_slot, + ); test_harness .service @@ -902,7 +980,7 @@ mod tests { let mock_same_slot = test_harness .harness .mock_beacon_node_1 - .mock_get_validator_blocks_v4_ssz(&block, ForkName::Gloas, same_notification_slot); + .mock_post_validator_blocks_v4_ssz(&block, ForkName::Gloas, same_notification_slot); test_harness .service @@ -937,7 +1015,7 @@ mod tests { test_harness .harness .mock_beacon_node_1 - .mock_get_validator_blocks_v4_ssz(&block, ForkName::Gloas, slot); + .mock_post_validator_blocks_v4_ssz(&block, ForkName::Gloas, slot); let mock_post_block = test_harness .harness .mock_beacon_node_1 @@ -1000,11 +1078,11 @@ mod tests { let mock_bn_1 = test_harness .harness .mock_beacon_node_1 - .mock_get_validator_blocks_v4_ssz_error(slot); + .mock_post_validator_blocks_v4_ssz_error(slot); let mock_bn_2 = test_harness .harness .mock_beacon_node_2 - .mock_get_validator_blocks_v4_ssz_error(slot); + .mock_post_validator_blocks_v4_ssz_error(slot); let mock_post_block = test_harness .harness @@ -1048,11 +1126,11 @@ mod tests { let mock_ssz = test_harness .harness .mock_beacon_node_1 - .mock_get_validator_blocks_v4_ssz_error(slot); + .mock_post_validator_blocks_v4_ssz_error(slot); let mock_json = test_harness .harness .mock_beacon_node_2 - .mock_get_validator_blocks_v4(&block, ForkName::Gloas, slot); + .mock_post_validator_blocks_v4(&block, ForkName::Gloas, slot); let _result = test_harness .service diff --git a/validator_client/validator_services/src/builder_preferences_service.rs b/validator_client/validator_services/src/builder_preferences_service.rs new file mode 100644 index 00000000000..203c236588b --- /dev/null +++ b/validator_client/validator_services/src/builder_preferences_service.rs @@ -0,0 +1,308 @@ +use crate::duties_service::DutiesService; +use crate::request_auth_cache::RequestAuthCache; +use beacon_node_fallback::BeaconNodeFallback; +use bls::PublicKeyBytes; +use builder_store::BuilderStore; +use builder_types::{BuilderEntry, BuilderUrl, RequestAuthData}; +use eth2::types::{ + BuilderPreferenceEntry, MAX_SUBMITTED_BUILDER_PREFERENCES, SubmittedBuilderPreferences, +}; +use slot_clock::SlotClock; +use std::collections::{BTreeMap, HashSet}; +use std::sync::Arc; +use task_executor::TaskExecutor; +use tokio::time::sleep; +use tracing::{debug, error, info}; +use types::{ChainSpec, EthSpec, Slot}; +use validator_store::ValidatorStore; + +/// The non-slot part of a published entry's identity: the proposer pubkey plus the decomposed +/// `BuilderPreferenceEntry` with its `slot` factored out to the enclosing map's key. +/// - `pubkey`: the proposer the entry was submitted for +/// - `url`: `entry.url` +/// - `auth_data`: `entry.auth.message.data` +/// - `max_execution_payment`: `entry.max_execution_payment` +/// +/// See [`PublishedBuilderPreferencesCache`] for how `entry.auth` decomposes into `auth_data` here +/// and `slot` at the map level, and why the `auth` signature is dropped. +#[derive(PartialEq, Eq, Hash)] +struct InnerPreferencesKey { + pubkey: PublicKeyBytes, + url: BuilderUrl, + auth_data: RequestAuthData, + max_execution_payment: u64, +} + +/// De-duplicates the `BuilderPreferenceEntry`s we've already published, so we don't re-send one. +/// +/// The identity of a published entry is `(proposer_pubkey, decompose(entry))`. That decomposition is +/// split across the two levels of this map: +/// - `entry.auth.message.slot` becomes the outer `BTreeMap` key; +/// - the rest — `proposer_pubkey`, `entry.url`, `entry.auth.message.data`, and +/// `entry.max_execution_payment` — forms the [`InnerPreferencesKey`] held in the per-slot set. +/// +/// So `entry.auth` decomposes into its `slot` (the map key) and its `data`/`auth_data` (in the inner +/// key); the `auth` signature is dropped, as it is a deterministic function of the proposer, the +/// `auth_data`, and the slot and so adds no identity. +/// +/// Operators may change their builder config at any time. Because this identity captures every entry +/// field that reaches a builder, any edit yields a new key that won't match a previously-sent entry, +/// so the updated preference is published again. +#[derive(Default)] +struct PublishedBuilderPreferencesCache { + cache: BTreeMap>, +} + +impl PublishedBuilderPreferencesCache { + pub fn new() -> Self { + Self::default() + } + + pub fn contains( + &self, + slot: Slot, + pubkey: PublicKeyBytes, + builder_entry: &BuilderEntry, + ) -> bool { + self.cache.get(&slot).is_some_and(|set| { + set.contains(&InnerPreferencesKey { + pubkey, + url: builder_entry.url.clone(), + auth_data: builder_entry.auth.message.data.clone(), + max_execution_payment: builder_entry.max_execution_payment, + }) + }) + } + + pub fn mark_sent( + &mut self, + pubkey: PublicKeyBytes, + builder_preferences_entry: BuilderPreferenceEntry, + ) { + let slot = builder_preferences_entry.auth.message.slot; + let inner_key = InnerPreferencesKey { + pubkey, + url: builder_preferences_entry.url, + auth_data: builder_preferences_entry.auth.message.data, + max_execution_payment: builder_preferences_entry.max_execution_payment, + }; + self.cache.entry(slot).or_default().insert(inner_key); + } + + pub fn prune(&mut self, current_slot: Slot) { + self.cache = self.cache.split_off(¤t_slot); + } +} + +// Minimizes `Arc` usage +struct Inner { + duties_service: Arc>, + validator_store: Arc, + slot_clock: T, + beacon_nodes: Arc>, + configured_builders: BuilderStore, + request_auth_cache: RequestAuthCache, + executor: TaskExecutor, + chain_spec: Arc, +} + +pub struct BuilderPreferencesService { + inner: Arc>, +} + +// Generic clone implementation is too dumb to do this +impl Clone for BuilderPreferencesService { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } +} + +impl BuilderPreferencesService { + #[allow(clippy::too_many_arguments)] + pub fn new( + duties_service: Arc>, + validator_store: Arc, + slot_clock: T, + beacon_nodes: Arc>, + configured_builders: BuilderStore, + request_auth_cache: RequestAuthCache, + executor: TaskExecutor, + chain_spec: Arc, + ) -> Self { + Self { + inner: Arc::new(Inner { + duties_service, + validator_store, + slot_clock, + beacon_nodes, + configured_builders, + request_auth_cache, + executor, + chain_spec, + }), + } + } + + pub fn start_update_service(self) -> Result<(), String> { + let slot_duration = self.inner.chain_spec.get_slot_duration(); + info!("Builder preferences service started"); + + let executor = self.inner.executor.clone(); + + let interval_fut = async move { + let mut published_preferences = PublishedBuilderPreferencesCache::new(); + + loop { + let Some(current_slot) = self.inner.slot_clock.now() else { + error!("Failed to read slot clock"); + sleep(slot_duration).await; + continue; + }; + + self.poll_and_publish_preferences(current_slot, &mut published_preferences) + .await; + + published_preferences.prune(current_slot); + self.inner.request_auth_cache.prune(current_slot); + + let duration_to_next_slot = self + .inner + .slot_clock + .duration_to_next_slot() + .unwrap_or(slot_duration); + sleep(duration_to_next_slot).await; + } + }; + + executor.spawn(interval_fut, "builder_preferences_service"); + Ok(()) + } + + /// Publish builder preferences for `current_epoch` and `current_epoch + 1`. + /// Will only publish a given `(proposer, builder, max_execution_payment)` preference once. + async fn poll_and_publish_preferences( + &self, + current_slot: Slot, + published_preferences: &mut PublishedBuilderPreferencesCache, + ) { + let current_epoch = current_slot.epoch(S::E::slots_per_epoch()); + // One flat request whose body spans both epochs, each entry naming its own proposer + // (beacon-APIs #630, whose body is sized for several epochs of entries). The single + // `Eth-Consensus-Version` is the version active now, at submission time. + let current_fork = self.inner.chain_spec.fork_name_at_epoch(current_epoch); + let mut pending_entries: Vec = Vec::new(); + + for (epoch, fork_name) in [ + ( + current_epoch, + self.inner.chain_spec.fork_name_at_epoch(current_epoch), + ), + ( + current_epoch + 1, + self.inner.chain_spec.fork_name_at_epoch(current_epoch + 1), + ), + ] { + if !fork_name.gloas_enabled() { + continue; + } + + let proposers = match self.inner.duties_service.proposers.read().get(&epoch) { + Some((_, proposers)) => proposers.clone(), + None => continue, + }; + + for proposer_data in &proposers { + let slot = proposer_data.slot; + let pubkey = proposer_data.pubkey; + + // Resolve and sign the whole builder config for this proposer/slot. Auths are + // cached, so builders already published for this slot cost only a cache hit. + // Per-builder sign failures are logged and omitted inside `builder_config`, so a + // fully-failed set just yields an empty `builders` list (nothing to publish). + let config = self + .inner + .configured_builders + .builder_config(|auth_data| { + self.inner.request_auth_cache.get_or_sign( + slot, + pubkey, + auth_data, + |request_auth_v1| { + self.inner + .validator_store + .sign_request_auth_v1(pubkey, request_auth_v1) + }, + ) + }) + .await; + + // A `BuilderPreferenceEntry` is a `BuilderEntry` narrowed to what a builder may see: + // its private `min_bid`/`builder_boost_factor`/`builder_pubkeys` are dropped. + for entry in config.builders.iter() { + if published_preferences.contains(slot, pubkey, entry) { + // already published, skip + continue; + } + pending_entries.push(BuilderPreferenceEntry::from_builder_entry( + pubkey, + entry.clone(), + )); + } + } + } + + if pending_entries.is_empty() { + return; + } + + // One submission carries at most `MAX_SUBMITTED_BUILDER_PREFERENCES` entries (beacon-APIs + // #630), so submit in bounded chunks. Each chunk is best-effort: a failed chunk is logged + // and does not stop the rest. + for chunk in pending_entries.chunks(MAX_SUBMITTED_BUILDER_PREFERENCES) { + let Ok(entries) = SubmittedBuilderPreferences::new(chunk.to_vec()) else { + // Unreachable: `chunks()` bounds each chunk by the list limit. + continue; + }; + let entries_ref = &entries; + + // Try SSZ first, falling back to JSON. `first_success` is okay here because later + // we'll be resending the auths when we publish the beacon block. + let ssz_result = self + .inner + .beacon_nodes + .first_success(|beacon_node| async move { + beacon_node + .post_validator_builder_preferences_ssz(entries_ref, current_fork) + .await + }) + .await; + + let result = match ssz_result { + Ok(()) => Ok(()), + Err(ssz_err) => { + debug!(error = %ssz_err, "SSZ builder preferences publish failed, falling back to JSON"); + self.inner + .beacon_nodes + .first_success(|beacon_node| async move { + beacon_node + .post_validator_builder_preferences(entries_ref, current_fork) + .await + }) + .await + } + }; + + match result { + Ok(()) => { + for entry in entries.iter().cloned() { + let pubkey = entry.proposer_pubkey; + published_preferences.mark_sent(pubkey, entry); + } + } + Err(e) => error!(error = %e, "Failed to publish builder preferences"), + } + } + } +} diff --git a/validator_client/validator_services/src/lib.rs b/validator_client/validator_services/src/lib.rs index c39ef4499b7..3db106ac692 100644 --- a/validator_client/validator_services/src/lib.rs +++ b/validator_client/validator_services/src/lib.rs @@ -1,10 +1,12 @@ pub mod attestation_service; pub mod block_service; +pub mod builder_preferences_service; pub mod duties_service; pub mod latency_service; pub mod notifier_service; pub mod payload_attestation_service; pub mod preparation_service; pub mod proposer_preferences_service; +pub mod request_auth_cache; pub mod sync; pub mod sync_committee_service; diff --git a/validator_client/validator_services/src/request_auth_cache.rs b/validator_client/validator_services/src/request_auth_cache.rs new file mode 100644 index 00000000000..4d9e553d8be --- /dev/null +++ b/validator_client/validator_services/src/request_auth_cache.rs @@ -0,0 +1,106 @@ +use bls::PublicKeyBytes; +use builder_types::{RequestAuth, RequestAuthData, SignedRequestAuth}; +use parking_lot::RwLock; +use std::collections::{BTreeMap, HashMap}; +use std::future::Future; +use std::sync::Arc; +use types::Slot; + +/// Caches signed `RequestAuth` objects so a given proposer/auth-data/slot combination is only +/// signed once. +/// +/// The signed authorization is a pure function of the proposer pubkey, the opaque `auth_data`, and +/// the proposal `slot`, so those form the cache key. The builder URL is deliberately *not* part of +/// the key: two builders configured with the same `auth_data` share one signature. +#[derive(Hash, PartialEq, Eq)] +struct RequestAuthInnerKey { + pubkey: PublicKeyBytes, + auth_data: RequestAuthData, +} + +#[derive(Default)] +struct Inner { + entries: BTreeMap>, +} + +#[derive(Clone)] +pub struct RequestAuthCache { + inner: Arc>, +} + +impl Default for RequestAuthCache { + fn default() -> Self { + Self { + inner: Arc::new(RwLock::new(Inner::default())), + } + } +} + +impl RequestAuthCache { + pub fn get( + &self, + slot: Slot, + pubkey: PublicKeyBytes, + auth_data: &RequestAuthData, + ) -> Option { + self.inner.read().entries.get(&slot).and_then(|entries| { + let key = RequestAuthInnerKey { + pubkey, + auth_data: auth_data.clone(), + }; + entries.get(&key).cloned() + }) + } + + pub fn insert( + &self, + slot: Slot, + pubkey: PublicKeyBytes, + auth_data: RequestAuthData, + signed_request_auth: SignedRequestAuth, + ) { + let key = RequestAuthInnerKey { pubkey, auth_data }; + + self.inner + .write() + .entries + .entry(slot) + .or_default() + .insert(key, signed_request_auth); + } + + /// Return the cached signature for `(slot, pubkey, auth_data)`, or produce it via `sign` (and + /// cache the result) on a miss. + /// + /// The signature is a pure function of the proposer, `auth_data`, and slot, so a hit returns + /// immediately without invoking `sign`. `sign` receives the fully-formed `RequestAuth` to + /// sign — in practice `ValidatorStore::sign_request_auth_v1`. + pub async fn get_or_sign( + &self, + slot: Slot, + pubkey: PublicKeyBytes, + auth_data: RequestAuthData, + sign: F, + ) -> Result + where + F: FnOnce(RequestAuth) -> Fut, + Fut: Future>, + { + if let Some(signed) = self.get(slot, pubkey, &auth_data) { + return Ok(signed); + } + + let signed = sign(RequestAuth { + data: auth_data.clone(), + slot, + }) + .await?; + self.insert(slot, pubkey, auth_data, signed.clone()); + Ok(signed) + } + + pub fn prune(&self, current_slot: Slot) { + let mut guard = self.inner.write(); + guard.entries = guard.entries.split_off(¤t_slot); + } +} diff --git a/validator_client/validator_store/Cargo.toml b/validator_client/validator_store/Cargo.toml index 2c6a68d4949..092f927589f 100644 --- a/validator_client/validator_store/Cargo.toml +++ b/validator_client/validator_store/Cargo.toml @@ -6,6 +6,7 @@ authors = ["Sigma Prime "] [dependencies] bls = { workspace = true } +builder_types = { workspace = true } eth2 = { workspace = true } futures = { workspace = true } slashing_protection = { workspace = true } diff --git a/validator_client/validator_store/src/lib.rs b/validator_client/validator_store/src/lib.rs index dde82a2a5bb..6b2257af198 100644 --- a/validator_client/validator_store/src/lib.rs +++ b/validator_client/validator_store/src/lib.rs @@ -1,4 +1,5 @@ use bls::{PublicKeyBytes, Signature}; +use builder_types::{RequestAuth, SignedRequestAuth}; use eth2::types::{FullBlockContents, PublishBlockRequest}; use futures::Stream; use slashing_protection::NotSafe; @@ -213,6 +214,12 @@ pub trait ValidatorStore: Send + Sync { preferences: ProposerPreferences, ) -> impl Future>> + Send; + fn sign_request_auth_v1( + &self, + validator_pubkey: PublicKeyBytes, + request_auth_v1: RequestAuth, + ) -> impl Future>> + Send; + /// Returns `ProposalData` for the provided `pubkey` if it exists in `InitializedValidators`. /// `ProposalData` fields include defaulting logic described in `get_fee_recipient_defaulting`, /// `get_gas_limit_defaulting`, and `get_builder_proposals_defaulting`. diff --git a/wordlist.txt b/wordlist.txt index f0076e63322..1fd0e7603d9 100644 --- a/wordlist.txt +++ b/wordlist.txt @@ -108,6 +108,7 @@ UI Uncached UPnP USD +UTF UX Validator VC @@ -150,6 +151,7 @@ doppelgänger dropdown else's env +ePBS eth ethdo ethereum