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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.

36 changes: 28 additions & 8 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 @@ -34,12 +39,17 @@ builders:
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`
# auth_data: "0x6275696c..." # optional — defaults to the hostname 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 All @@ -61,11 +72,20 @@ builders:
| `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`. |
| `auth_data` | no | *(hostname 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 lowercase ASCII hostname 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.

The default `auth_data` excludes the scheme, credentials, port, path, query and fragment of the URL.
For example, both `https://builder.example.com` and `https://builder.example.com/` use
`builder.example.com`. An internationalized hostname must use punycode. IPv6 addresses use compressed,
bracketed hexadecimal form, such as `[::1]` or `[::ffff:c000:201]`.

A builder that uses a different identity must agree explicit `auth_data` with the validator operator.
Explicit values are signed exactly as configured. See the
[default authentication data specification](https://github.com/ethereum/builder-specs/pull/168).

## How bids are selected

At block-production time the validator client requests a bid from each enabled builder with a `url`,
Expand All @@ -74,7 +94,7 @@ 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.
inherited per-validator) value; p2p bids use the validator's `min_bid`.
- **`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;
Expand All @@ -87,4 +107,4 @@ and also considers bids seen over p2p. For each candidate bid:
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.
only to bids requested directly by URL. For p2p bids, per-validator defaults override the global values.
114 changes: 90 additions & 24 deletions common/builder_types/src/builder_url.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,6 @@ pub type MaxBuilderEntries = typenum::U64;
/// [`MaxBuilderEntries`] as a `usize` (derived, so the two cannot drift), for runtime bounds checks.
pub const MAX_BUILDER_ENTRIES: usize = <MaxBuilderEntries as typenum::Unsigned>::USIZE;

// `to_default_auth_data` is infallible only while every possible URL fits within the auth `data`
// bound; enforce that at compile time so growing `MaxBuilderUrlSize` past `MaxDataSize` cannot
// silently turn the default into (wire-invalid) zero-length auth data.
const _: () = assert!(
<MaxBuilderUrlSize as typenum::Unsigned>::USIZE
<= <crate::MaxDataSize as typenum::Unsigned>::USIZE
);

/// A builder URL as it travels on the beacon-API wire.
///
/// Held as the UTF-8 bytes of the URL so it can serialize two ways, matching the `ByteList` /
Expand All @@ -46,7 +38,7 @@ pub struct BuilderUrl {
pub enum BuilderUrlError {
/// The URL exceeds `MaxBuilderUrlSize` bytes.
TooLong,
/// The bytes are not a valid URL (invalid UTF-8 or unparseable).
/// The bytes cannot be parsed as a URL or used to derive an ASCII hostname.
InvalidUrl,
}

Expand All @@ -73,10 +65,32 @@ impl BuilderUrl {

/// The default opaque auth `data` to sign for this builder when no custom auth data is provided.
///
/// Infallible: a `BuilderUrl` is at most `MaxBuilderUrlSize` (2048) bytes, well within
/// `MaxDataSize` (4096), so building the default from the URL cannot overflow.
pub fn to_default_auth_data(&self) -> RequestAuthData {
RequestAuthData::new(self.as_bytes().to_vec()).unwrap_or_default()
/// Uses the lowercase ASCII hostname, with IPv6 compressed inside brackets. Internationalized
/// hostnames must be supplied in punycode form, as required by builder-specs #168.
pub fn to_default_auth_data(&self) -> Result<RequestAuthData, BuilderUrlError> {
let parsed = self.to_sensitive_url()?;
// Match URL parsing without changing the stored routing URL or explicit auth data.
let url = self
.as_str()
.map_err(|_| BuilderUrlError::InvalidUrl)?
.replace(['\t', '\r', '\n'], "");
let (_, authority) = url.split_once("://").ok_or(BuilderUrlError::InvalidUrl)?;
let host_port = authority
.split(['/', '?', '#'])
.next()
.and_then(|authority| authority.rsplit('@').next())
.ok_or(BuilderUrlError::InvalidUrl)?;
let host = if host_port.starts_with('[') {
// URL serialization uses hexadecimal groups even for IPv4-mapped IPv6 addresses.
parsed.expose_full().host_str()
} else {
// Keep the advertised spelling: URL parsing also rewrites IPv4 and percent escapes.
host_port.split(':').next()
}
.filter(|host| !host.is_empty() && host.is_ascii())
.ok_or(BuilderUrlError::InvalidUrl)?;
RequestAuthData::new(host.to_ascii_lowercase().into_bytes())
.map_err(|_| BuilderUrlError::TooLong)
}
}

Expand Down Expand Up @@ -157,22 +171,74 @@ mod tests {
}

#[test]
fn default_auth_data_cannot_fail_even_at_max_url_size() {
fn default_auth_data_ignores_url_spelling() {
for url in [
"https://builder.example.com",
"https://builder.example.com/",
"HTTPS://Builder.Example.com:443/bids?x=1#fragment",
"https://user:pw@builder.example.com:8080/",
"https://Builder.Example.com\r\n",
"https:/\t/Buil\tder.Example.com/",
] {
let url = BuilderUrl::from_str(url).unwrap();
assert_eq!(
&*url.to_default_auth_data().unwrap(),
b"builder.example.com"
);
}
}

#[test]
fn default_auth_data_hostnames() {
for (url, expected) in [
("https://10.0.0.5:18550/eth/v1/builder", "10.0.0.5"),
("https://[0:0:0:0:0:0:0:1]:8443/", "[::1]"),
("https://[::ffff:192.0.2.1]/", "[::ffff:c000:201]"),
("https://[2001:0DB8:0:0:1:0:0:1]/", "[2001:db8::1:0:0:1]"),
("https://XN--BCHER-KVA.example/", "xn--bcher-kva.example"),
("https://builder.example/路徑", "builder.example"),
("http://127.1/", "127.1"),
("http://0177.0.0.1/", "0177.0.0.1"),
("https://%65XAMPLE.com/", "%65xample.com"),
] {
let url = BuilderUrl::from_str(url).unwrap();
assert_eq!(&*url.to_default_auth_data().unwrap(), expected.as_bytes());
}
}

#[test]
fn default_auth_data_requires_ascii_hostname() {
for url in [
"",
"not a URL",
"mailto:builder@example.com",
"https://bücher.example/",
] {
assert!(matches!(
BuilderUrl::from_str(url).unwrap().to_default_auth_data(),
Err(BuilderUrlError::InvalidUrl)
));
}
let url = BuilderUrl {
bytes: VariableList::new(vec![0xff]).unwrap(),
};
assert!(matches!(
url.to_default_auth_data(),
Err(BuilderUrlError::InvalidUrl)
));
}

#[test]
fn default_auth_data_at_max_url_size() {
use ssz_types::typenum::Unsigned;

// The `MaxBuilderUrlSize <= MaxDataSize` invariant is asserted at compile time at module
// level; exercise the largest possible URL to confirm the default is the URL bytes and
// never the empty fallback.
let scheme = "https://";
let prefix = "https://builder.example/";
let url_string = format!(
"{scheme}{}",
"a".repeat(MaxBuilderUrlSize::USIZE - scheme.len())
"{prefix}{}",
"a".repeat(MaxBuilderUrlSize::USIZE - prefix.len())
);
let url = BuilderUrl::from_str(&url_string).unwrap();
assert_eq!(url.as_bytes().len(), MaxBuilderUrlSize::USIZE);

let data = url.to_default_auth_data();
assert!(!data.is_empty(), "default auth data fell back to empty");
assert_eq!(&*data, url.as_bytes());
assert_eq!(&*url.to_default_auth_data().unwrap(), b"builder.example");
}
}
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
Loading
Loading