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
2 changes: 2 additions & 0 deletions Cargo.lock

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

25 changes: 24 additions & 1 deletion crates/common/types/src/beacon/signing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
//! a message, such as a validator client, can reach them without taking on that
//! crate's `blst`, `c-kzg` and RocksDB dependencies.

use crate::beacon::constants::DOMAIN_DEPOSIT;
use crate::beacon::constants::{DOMAIN_APPLICATION_BUILDER, DOMAIN_DEPOSIT};
use crate::beacon::containers::shared::{Fork, SigningData};
use crate::beacon::fork_digest::compute_fork_data_root;
use crate::beacon::preset;
Expand Down Expand Up @@ -72,10 +72,33 @@ pub fn compute_deposit_domain(genesis_fork_version: Version) -> Domain {
compute_domain(DOMAIN_DEPOSIT, genesis_fork_version, Root::ZERO)
}

/// The domain for the builder API's own messages: validator registrations
/// and builder bids.
///
/// Like a deposit's, it is computed against the genesis fork version and a
/// zero validators root, so one signature stays valid across every fork of
/// its chain. This is builder-specs' `compute_domain(DOMAIN_APPLICATION_BUILDER)`,
/// whose defaults are exactly those. It is not gloas's in-protocol builder
/// domain; see [`DOMAIN_APPLICATION_BUILDER`].
pub fn compute_builder_domain(genesis_fork_version: Version) -> Domain {
compute_domain(DOMAIN_APPLICATION_BUILDER, genesis_fork_version, Root::ZERO)
}

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

#[test]
fn the_mainnet_builder_domain_is_the_published_one() {
// The domain type, then the first 28 bytes of the fork data root of
// mainnet's genesis fork version (all zero) and a zero validators
// root, which is sha256 of 64 zero bytes.
let expected =
hex::decode("00000001f5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a9")
.unwrap();
assert_eq!(compute_builder_domain([0, 0, 0, 0]).to_vec(), expected);
}

#[test]
fn fork_version_switches_at_the_boundary() {
let fork = Fork {
Expand Down
2 changes: 2 additions & 0 deletions crates/net/rpc/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ serde_json.workspace = true
hex.workspace = true
tracing.workspace = true
jemalloc_pprof.workspace = true
reqwest.workspace = true
thiserror.workspace = true
futures-util = "0.3"

[dev-dependencies]
Expand Down
98 changes: 98 additions & 0 deletions crates/net/rpc/src/beacon/builder_client/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
//! What can go wrong talking to a builder.

use std::time::Duration;

use reqwest::StatusCode;

use super::{read_body, truncate_error_text};

/// A failed builder API call.
///
/// A Beacon API handler for registrations or blinded blocks answers
/// [`BadRequest`](Self::BadRequest) with the builder's own 400 and anything
/// else with a 500. On `produceBlockV3` any error just means no bid, and the
/// node builds locally.
#[derive(Debug, thiserror::Error)]
pub enum BuilderError {
/// No complete answer within the call's deadline.
#[error("builder did not answer within {0:?}")]
Timeout(Duration),
/// The request did not complete: refused, reset, DNS, TLS.
#[error("builder unreachable: {0}")]
Transport(String),
/// The builder refused the request as invalid, with its reason.
#[error("builder refused the request: {0}")]
BadRequest(String),
/// A status the call does not expect.
#[error("builder answered {status}: {body}")]
Status { status: u16, body: String },
/// A body that does not decode as what the call returns.
#[error("builder response did not decode: {0}")]
Decode(String),
/// A response missing something it needs to be read.
#[error("builder response is incomplete: {0}")]
Incomplete(String),
/// A bid for a fork whose containers this node does not decode.
#[error("builder offered a bid for {0}")]
UnsupportedFork(String),
/// An answer longer than the client reads.
#[error("builder answer larger than {limit} bytes")]
TooLarge { limit: usize },
/// A builder URL that does not parse.
#[error("invalid builder url: {0}")]
InvalidUrl(String),
/// A request body this node could not encode.
#[error("request did not encode: {0}")]
Encode(String),
}

impl BuilderError {
/// The URL is dropped from the message: it may carry credentials, and
/// the error ends up in logs and response bodies.
pub(super) fn from_reqwest(err: reqwest::Error, deadline: Duration) -> Self {
let err = err.without_url();
if err.is_timeout() {
Self::Timeout(deadline)
} else {
Self::Transport(err.to_string())
}
}

/// The error a status other than the call's success stands for. A 400
/// carries the builder's `message`, or its raw body if it sent no JSON.
pub(super) async fn from_status(response: reqwest::Response, deadline: Duration) -> Self {
let status = response.status();
let body = read_body(response, deadline).await.unwrap_or_default();
let body = String::from_utf8_lossy(&body).into_owned();
if status != StatusCode::BAD_REQUEST {
return Self::Status {
status: status.as_u16(),
body: truncate_error_text(body),
};
}
let message = serde_json::from_str::<ErrorMessage>(&body)
.map(|error| error.message)
.unwrap_or(body);
Self::BadRequest(truncate_error_text(message))
}

/// How the call ended, as a metric label.
pub(crate) fn label(&self) -> &'static str {
match self {
Self::Timeout(_) => "timeout",
Self::Transport(_) => "unreachable",
Self::BadRequest(_) => "bad_request",
Self::Status { .. } => "unexpected_status",
Self::Decode(_) | Self::Incomplete(_) | Self::TooLarge { .. } => "invalid_response",
Self::InvalidUrl(_) => "invalid_url",
Self::UnsupportedFork(_) => "unsupported_fork",
Self::Encode(_) => "encode",
}
}
}

/// builder-specs' `ErrorMessage`, of which only the message is kept.
#[derive(serde::Deserialize)]
struct ErrorMessage {
message: String,
}
Loading