Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 15 additions & 4 deletions book/src/gloas_builder_config.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,17 @@

The validator client reads its external-builder settings from a YAML file named
`builder_definitions.yml` in the validator directory
(`<datadir>/validators/builder_definitions.yml`). The file holds two things:
(`<datadir>/validators/builder_definitions.yml`). The file contains:

- **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.
- **Per-validator configurations** under `validator_configs`, managed through the standard keymanager
API. Each map key is a validator public key.

Use `GET`, `POST`, and `DELETE` at `/eth/v1/validator/{pubkey}/builder_config`. `GET` returns the
configuration in use. `POST` replaces the stored configuration. `DELETE` restores global inheritance.

## Example

Expand All @@ -35,11 +40,16 @@ builders:
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`

# Optional per-validator configuration.
# validator_configs:
# "0x<validator-public-key>":
# min_bid: 500000000
# builders: [] # explicitly disable direct builders for this validator
```

> **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.
> **Comments are not preserved.** The validator client rewrites this file when builder settings
> change through the keymanager API. Keep an annotated copy elsewhere if you rely on inline notes.

## Fields

Expand All @@ -50,6 +60,7 @@ builders:
| `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. |
| `validator_configs` | no | `{}` | Builder settings for individual validators. Omitted fields use global values. An empty `builders` list uses no direct builders. |

### Per builder (each entry under `builders`)

Expand Down
39 changes: 39 additions & 0 deletions common/eth2/src/lighthouse_vc/http_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,18 @@ impl ValidatorClientHttpClient {
Ok(url)
}

fn make_builder_config_url(&self, pubkey: &PublicKeyBytes) -> Result<Url, Error> {
let mut url = self.server.expose_full().clone();
url.path_segments_mut()
.map_err(|()| Error::InvalidUrl(self.server.clone()))?
.push("eth")
.push("v1")
.push("validator")
.push(&pubkey.to_string())
.push("builder_config");
Ok(url)
}

fn make_graffiti_url(&self, pubkey: &PublicKeyBytes) -> Result<Url, Error> {
let mut url = self.server.expose_full().clone();
url.path_segments_mut()
Expand Down Expand Up @@ -603,6 +615,33 @@ impl ValidatorClientHttpClient {
self.delete_with_raw_response(url, &()).await
}

/// `GET /eth/v1/validator/{pubkey}/builder_config`
pub async fn get_builder_config(
&self,
pubkey: &PublicKeyBytes,
) -> Result<BuilderConfig, Error> {
let url = self.make_builder_config_url(pubkey)?;
self.get(url)
.await
.map(|generic: GenericResponse<BuilderConfig>| generic.data)
}

/// `POST /eth/v1/validator/{pubkey}/builder_config`
pub async fn post_builder_config(
&self,
pubkey: &PublicKeyBytes,
request: &BuilderConfig,
) -> Result<Response, Error> {
let url = self.make_builder_config_url(pubkey)?;
self.post_with_raw_response(url, request).await
}

/// `DELETE /eth/v1/validator/{pubkey}/builder_config`
pub async fn delete_builder_config(&self, pubkey: &PublicKeyBytes) -> Result<Response, Error> {
let url = self.make_builder_config_url(pubkey)?;
self.delete_with_raw_response(url, &()).await
}

/// `GET /eth/v1/validator/{pubkey}/gas_limit`
pub async fn get_gas_limit(
&self,
Expand Down
163 changes: 162 additions & 1 deletion common/eth2/src/lighthouse_vc/std_types.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,69 @@
use bls::PublicKeyBytes;
pub use builder_types::{BuilderUrl, RequestAuthData};
use eth2_keystore::Keystore;
use serde::{Deserialize, Serialize};
use serde::{
Deserialize, Deserializer, Serialize, Serializer,
de::{self, value::StringDeserializer},
};
pub use serde_utils::quoted_u64::Quoted;
use types::{Address, Graffiti};
use zeroize::Zeroizing;

fn deserialize_non_null<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de>,
{
T::deserialize(deserializer).map(Some)
}

/// Deserialize the keymanager `Uint64` format: a quoted decimal value without a sign or leading
/// zeros. `Quoted<u64>` accepts strings such as `"+1"` and `"01"`, which the API schema rejects.
/// The `Option` allows omission through `#[serde(default)]`; deserializing a `String` rejects null.
fn deserialize_strict_uint64_string<'de, D>(
deserializer: D,
) -> Result<Option<Quoted<u64>>, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
let is_canonical = value == "0"
|| (value.len() <= 20
&& value.as_bytes().first().is_some_and(|byte| *byte >= b'1')
&& value.as_bytes().iter().all(u8::is_ascii_digit));
if !is_canonical {
return Err(de::Error::custom("invalid quoted uint64"));
}
value
.parse()
.map(|value| Some(Quoted { value }))
.map_err(de::Error::custom)
}

mod serde_option_auth_data {
use super::*;

pub fn serialize<S: Serializer>(
value: &Option<RequestAuthData>,
serializer: S,
) -> Result<S::Ok, S::Error> {
match value {
Some(data) => ssz_types::serde_utils::hex_var_list::serialize(data, serializer),
None => serializer.serialize_none(),
}
}

pub fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Option<RequestAuthData>, D::Error> {
let value = String::deserialize(deserializer)?;
ssz_types::serde_utils::hex_var_list::deserialize(StringDeserializer::<D::Error>::new(
value,
))
.map(Some)
}
}

pub use eip_3076::Interchange;

#[derive(Debug, Deserialize, Serialize, PartialEq)]
Expand All @@ -20,6 +80,107 @@ pub struct GetGasLimitResponse {
pub gas_limit: u64,
}

/// Per-validator external-builder configuration from the standard keymanager API.
///
/// A missing field inherits the validator client's global configuration. The GET endpoint returns
/// all fields resolved, while POST accepts an omitted `builders` field and an explicitly empty
/// list as distinct values.
#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
pub struct BuilderConfig {
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_strict_uint64_string"
)]
pub min_bid: Option<Quoted<u64>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_strict_uint64_string"
)]
pub builder_boost_factor: Option<Quoted<u64>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_non_null"
)]
pub builders: Option<Vec<BuilderEntry>>,
}

/// An external-builder entry from the standard keymanager API.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct BuilderEntry {
pub url: BuilderUrl,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_option_auth_data"
)]
pub auth_data: Option<RequestAuthData>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_non_null"
)]
pub builder_pubkeys: Option<Vec<PublicKeyBytes>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_strict_uint64_string"
)]
pub max_execution_payment: Option<Quoted<u64>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_strict_uint64_string"
)]
pub min_bid: Option<Quoted<u64>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_strict_uint64_string"
)]
pub builder_boost_factor: Option<Quoted<u64>>,
}

#[cfg(test)]
mod builder_config_tests {
use super::*;

#[test]
fn builder_config_uses_keymanager_json_encoding() {
let json = serde_json::json!({
"min_bid": "3",
"builder_boost_factor": "110",
"builders": [{
"url": "https://builder.example",
"auth_data": "0x0102",
"builder_pubkeys": [],
"max_execution_payment": "8"
}]
});
let config = serde_json::from_value::<BuilderConfig>(json.clone()).unwrap();
assert_eq!(serde_json::to_value(config).unwrap(), json);
assert_eq!(
serde_json::to_value(BuilderConfig::default()).unwrap(),
serde_json::json!({})
);
assert!(
serde_json::from_value::<BuilderConfig>(serde_json::json!({"min_bid": 3})).is_err()
);
for invalid in [
serde_json::json!({"min_bid": null}),
serde_json::json!({"min_bid": "01"}),
serde_json::json!({"min_bid": "+1"}),
serde_json::json!({"builders": null}),
serde_json::json!({"builders": [{"url": "https://builder.example", "auth_data": null}]}),
serde_json::json!({"builders": [{"url": "https://builder.example", "builder_pubkeys": null}]}),
] {
assert!(serde_json::from_value::<BuilderConfig>(invalid).is_err());
}
}
}

#[derive(Debug, Deserialize, Serialize, PartialEq)]
pub struct AuthResponse {
pub token_path: String,
Expand Down
12 changes: 12 additions & 0 deletions common/warp_utils/src/reject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ pub fn custom_bad_request(msg: String) -> warp::reject::Rejection {
warp::reject::custom(CustomBadRequest(msg))
}

#[derive(Debug)]
pub struct CustomForbidden(pub String);

impl Reject for CustomForbidden {}

pub fn custom_forbidden(msg: String) -> warp::reject::Rejection {
warp::reject::custom(CustomForbidden(msg))
}

Comment thread
chong-he marked this conversation as resolved.
#[derive(Debug)]
pub struct CustomDeserializeError(pub String);

Expand Down Expand Up @@ -194,6 +203,9 @@ pub async fn handle_rejection(err: warp::Rejection) -> Result<impl warp::Reply,
} else if let Some(e) = err.find::<crate::reject::CustomBadRequest>() {
code = StatusCode::BAD_REQUEST;
message = format!("BAD_REQUEST: {}", e.0);
} else if let Some(e) = err.find::<crate::reject::CustomForbidden>() {
code = StatusCode::FORBIDDEN;
message = format!("FORBIDDEN: {}", e.0);
} else if let Some(e) = err.find::<crate::reject::CustomServerError>() {
code = StatusCode::INTERNAL_SERVER_ERROR;
message = format!("INTERNAL_SERVER_ERROR: {}", e.0);
Expand Down
Loading
Loading