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
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::payload_bid_verification::{
PayloadBidError,
gossip_verified_bid::{is_gas_limit_target_compatible, verify_bid_consistency},
gossip_verified_bid::{is_gas_limit_target_compatible, verify_direct_bid_consistency},
};
use eth2::types::BuilderPubkeys;
use state_processing::signature_sets::{
Expand All @@ -14,7 +14,8 @@ use types::{
/// Fully validate a bid fetched directly from a builder, for inclusion in a block being produced.
///
/// This performs all validation a direct builder bid must pass before it can be selected:
/// - the consensus-consistency checks shared with the gossip verifier via [`verify_bid_consistency`]
/// - the consensus-consistency checks shared with the gossip verifier, bundled for this path in
/// [`verify_direct_bid_consistency`]
/// (fee recipient, blob count, builder eligibility/version, and that the builder's collateral
/// covers the bid value),
/// - that the bid matches the block being produced — the exact `proposal_slot`, the selected
Expand Down Expand Up @@ -81,7 +82,7 @@ pub fn verify_direct_bid<E: EthSpec>(
}

// Consensus-consistency checks shared with the gossip verifier.
verify_bid_consistency(bid, proposal_slot, proposer_preferences, state, spec)?;
verify_direct_bid_consistency(bid, proposal_slot, proposer_preferences, state, spec)?;

// If the requesting `BuilderEntry` named builder pubkeys, the bid must come from one of them:
// the builder at `bid.builder_index` must have one of those pubkeys (the `builder_pubkeys`
Expand Down Expand Up @@ -267,6 +268,38 @@ mod tests {
));
}

#[test]
fn rejects_block_hash_equal_to_parent_block_hash() {
let (state, spec) = state_and_spec();
// Passes every earlier check (slot, ancestor hash, parent root, RANDAO, gas limit), then
// claims a `block_hash` equal to its `parent_block_hash` — the consensus assert from
// `process_execution_payload_bid` that must be front-run before selection.
let executed_ancestor = ExecutionBlockHash::repeat_byte(7);
let mut bid = signed_bid(
Slot::new(1),
executed_ancestor,
Hash256::ZERO,
Hash256::ZERO,
);
bid.message.block_hash = executed_ancestor;
bid.message.gas_limit = EXECUTED_ANCESTOR_GAS_LIMIT;
let result = verify_direct_bid(
&bid,
Slot::new(1),
executed_ancestor,
Hash256::ZERO,
EXECUTED_ANCESTOR_GAS_LIMIT,
&BuilderPubkeys::default(),
&preferences(),
&state,
&spec,
);
assert!(matches!(
result,
Err(PayloadBidError::BlockHashEqualsParentBlockHash { .. })
));
}

#[test]
fn rejects_gas_limit_incompatible_with_parent() {
let (state, spec) = state_and_spec();
Expand Down Expand Up @@ -305,6 +338,9 @@ mod tests {
Hash256::ZERO,
);
bid.message.gas_limit = EXECUTED_ANCESTOR_GAS_LIMIT;
// A default (zero) `block_hash` would equal the zero parent hash and trip the
// block-hash-equals-parent rejection before the checks this test targets.
bid.message.block_hash = ExecutionBlockHash::repeat_byte(1);
let result = verify_direct_bid(
&bid,
Slot::new(1),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,26 @@ fn verify_bid_payment_and_blobs<E: EthSpec>(
});
}

verify_bid_block_hash_not_parent(bid)?;

verify_bid_blobs(bid, spec)
}

/// Reject a bid whose `block_hash` equals its `parent_block_hash`.
///
/// `process_execution_payload_bid` enforces this in `per_block_processing`, so every bid intake —
/// gossip *and* direct (builder-API) — must front-run it: a bid that fails only at block
/// processing has already won selection and costs the proposer the slot.
pub(crate) fn verify_bid_block_hash_not_parent<E: EthSpec>(
bid: &ExecutionPayloadBid<E>,
) -> Result<(), PayloadBidError> {
if bid.block_hash == bid.parent_block_hash {
return Err(PayloadBidError::BlockHashEqualsParentBlockHash {
slot: bid.slot,
block_hash: bid.block_hash,
});
}

verify_bid_blobs(bid, spec)
Ok(())
}

fn verify_bid_blobs<E: EthSpec>(
Expand All @@ -71,12 +83,17 @@ fn verify_bid_blobs<E: EthSpec>(
Ok(())
}

/// Verify that an execution payload bid is consistent with the current chain state
/// and proposer preferences.
/// Verify that a direct (builder-API) bid is consistent with the current chain state
/// and proposer preferences: the direct path's bundle of the shared bid checks.
///
/// These checks are shared by gossip and direct bids. Source-specific checks (e.g. the gossip-only
/// requirement that `execution_payment == 0`) are applied by the caller.
pub(crate) fn verify_bid_consistency<E: EthSpec>(
/// The individual checks are shared with gossip, but this bundle's only caller is
/// [`verify_direct_bid`](crate::payload_bid_verification::direct_verified_bid::verify_direct_bid):
/// the gossip verifier applies the same helpers (`verify_bid_slot`, `verify_bid_blobs`,
/// `verify_bid_block_hash_not_parent`, `verify_bid_state_conditions`) piecewise, in gossip-spec
/// order, interleaved with gossip-only work (cache checks, the preferences lookup, fork-choice
/// rules). A check that must cover both intakes belongs in one of those shared helpers — adding
/// it only here leaves gossip uncovered.
pub(crate) fn verify_direct_bid_consistency<E: EthSpec>(
bid: &ExecutionPayloadBid<E>,
current_slot: Slot,
proposer_preferences: &SignedProposerPreferences,
Expand All @@ -89,6 +106,11 @@ pub(crate) fn verify_bid_consistency<E: EthSpec>(
return Err(PayloadBidError::InvalidFeeRecipient);
}

// Mirrors the consensus assert in `process_execution_payload_bid`. The gossip path applies
// this earlier (via `verify_bid_payment_and_blobs`); repeating it here keeps the direct path
// covered without depending on the gossip caller's composition.
verify_bid_block_hash_not_parent(bid)?;

verify_bid_blobs(bid, spec)?;

verify_bid_state_conditions(bid, head_state, spec)
Expand Down
18 changes: 16 additions & 2 deletions beacon_node/builder_client/src/builder_http_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ const DATE_MILLISECONDS: HeaderName = HeaderName::from_static("date-milliseconds
#[derive(Clone)]
pub struct BuilderHttpClient {
client: reqwest::Client,
/// Client for `submitSignedBeaconBlock` only. The target URL arrives over the wire (the
/// `Eth-Builder-Url` request header echoed by the VC) and is an SSRF risk, so beacon-APIs
/// `publishBlock` requires that the forwarding request "MUST NOT follow redirects" — reqwest's
/// redirect policy is client-wide, hence a dedicated client with redirects disabled.
no_redirect_client: reqwest::Client,
user_agent: String,
/// Only use json for all request/response types.
disable_ssz: bool,
Expand All @@ -50,8 +55,13 @@ impl BuilderHttpClient {
pub fn new(user_agent: Option<String>, disable_ssz: bool) -> Result<Self, Error> {
let user_agent = user_agent.unwrap_or_else(|| DEFAULT_USER_AGENT.to_string());
let client = reqwest::Client::builder().user_agent(&user_agent).build()?;
let no_redirect_client = reqwest::Client::builder()
.user_agent(&user_agent)
.redirect(reqwest::redirect::Policy::none())
.build()?;
Ok(Self {
client,
no_redirect_client,
user_agent,
disable_ssz,
})
Expand Down Expand Up @@ -234,6 +244,10 @@ impl BuilderHttpClient {
///
/// `ssz_request` selects the request-body encoding: SSZ when `true` and the client has SSZ
/// enabled, otherwise JSON.
///
/// Sent via [`Self::no_redirect_client`]: `builder_url` is wire input (`Eth-Builder-Url`), and
/// the spec forbids following redirects on this request. A redirect response surfaces as
/// [`Error::StatusCode`] like any other non-202.
pub async fn submit_signed_beacon_block<E: EthSpec>(
&self,
builder_url: &SensitiveUrl,
Expand Down Expand Up @@ -263,7 +277,7 @@ impl BuilderHttpClient {
HeaderValue::from_str(SSZ_CONTENT_TYPE_HEADER)
.map_err(|e| Error::InvalidHeaders(format!("{}", e)))?,
);
self.client
self.no_redirect_client
.post(path)
.timeout(timeout)
.headers(headers)
Expand All @@ -274,7 +288,7 @@ impl BuilderHttpClient {
HeaderValue::from_str(JSON_CONTENT_TYPE_HEADER)
.map_err(|e| Error::InvalidHeaders(format!("{}", e)))?,
);
self.client
self.no_redirect_client
.post(path)
.timeout(timeout)
.headers(headers)
Expand Down
40 changes: 36 additions & 4 deletions beacon_node/http_api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ use eth2::types::{
self as api_types, BroadcastValidation, EndpointVersion, ForkChoice, ForkChoiceExtraData,
ForkChoiceNode, LightClientUpdatesQuery, PublishBlockRequest, ValidatorId,
};
use eth2::{CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, SSZ_CONTENT_TYPE_HEADER};
use eth2::{
BUILDER_URL_HEADER, CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, SSZ_CONTENT_TYPE_HEADER,
};
use health_metrics::observe::Observe;
use lighthouse_network::Enr;
use lighthouse_network::NetworkGlobals;
Expand Down Expand Up @@ -106,7 +108,7 @@ use types::{
};
use validator::execution_payload_envelopes::get_validator_execution_payload_envelopes;
use version::{
ResponseIncludesVersion, V1, V2, add_consensus_version_header, add_ssz_content_type_header,
ResponseIncludesVersion, V1, V2, V4, add_consensus_version_header, add_ssz_content_type_header,
execution_optimistic_finalized_beacon_response, inconsistent_fork_rejection,
unsupported_version_rejection,
};
Expand Down Expand Up @@ -384,6 +386,7 @@ pub async fn serve<T: BeaconChainTypes>(

let eth_v1 = single_version(any_version.clone(), V1);
let eth_v2 = single_version(any_version.clone(), V2);
let eth_v4 = single_version(any_version.clone(), V4);

// Create a `warp` filter that provides access to the network globals.
let inner_network_globals = ctx.network_globals.clone();
Expand Down Expand Up @@ -819,6 +822,9 @@ pub async fn serve<T: BeaconChainTypes>(
*/
let consensus_version_header_filter =
warp::header::header::<ForkName>(CONSENSUS_VERSION_HEADER).boxed();
// The winning builder's URL echoed by the VC on a Gloas block publish (beacon-APIs #630), so the
// node forwards the block to that builder. Optional: absent for self-build / p2p-won blocks.
let builder_url_header_filter = warp::header::optional::<String>(BUILDER_URL_HEADER).boxed();

let optional_consensus_version_header_filter =
warp::header::optional::<ForkName>(CONSENSUS_VERSION_HEADER).boxed();
Expand Down Expand Up @@ -855,6 +861,8 @@ pub async fn serve<T: BeaconChainTypes>(
&network_tx,
BroadcastValidation::default(),
duplicate_block_status_code,
// Legacy v1 publish: no builder-URL provenance (VC uses v2 for Gloas).
None,
)
.await
})
Expand Down Expand Up @@ -892,6 +900,8 @@ pub async fn serve<T: BeaconChainTypes>(
&network_tx,
BroadcastValidation::default(),
duplicate_block_status_code,
// Legacy v1 publish: no builder-URL provenance (VC uses v2 for Gloas).
None,
)
.await
})
Expand All @@ -909,13 +919,15 @@ pub async fn serve<T: BeaconChainTypes>(
.and(task_spawner_filter.clone())
.and(chain_filter.clone())
.and(network_tx_filter.clone())
.and(builder_url_header_filter.clone())
.then(
move |validation_level: api_types::BroadcastValidationQuery,
value: serde_json::Value,
consensus_version: ForkName,
task_spawner: TaskSpawner<T::EthSpec>,
chain: Arc<BeaconChain<T>>,
network_tx: UnboundedSender<NetworkMessage<T::EthSpec>>| {
network_tx: UnboundedSender<NetworkMessage<T::EthSpec>>,
builder_url: Option<String>| {
task_spawner.spawn_async_with_rejection(Priority::P0, async move {
let request = PublishBlockRequest::<T::EthSpec>::context_deserialize(
&value,
Expand All @@ -932,6 +944,7 @@ pub async fn serve<T: BeaconChainTypes>(
&network_tx,
validation_level.broadcast_validation,
duplicate_block_status_code,
builder_url,
)
.await
})
Expand All @@ -949,13 +962,15 @@ pub async fn serve<T: BeaconChainTypes>(
.and(task_spawner_filter.clone())
.and(chain_filter.clone())
.and(network_tx_filter.clone())
.and(builder_url_header_filter.clone())
.then(
move |validation_level: api_types::BroadcastValidationQuery,
block_bytes: Bytes,
consensus_version: ForkName,
task_spawner: TaskSpawner<T::EthSpec>,
chain: Arc<BeaconChain<T>>,
network_tx: UnboundedSender<NetworkMessage<T::EthSpec>>| {
network_tx: UnboundedSender<NetworkMessage<T::EthSpec>>,
builder_url: Option<String>| {
task_spawner.spawn_async_with_rejection(Priority::P0, async move {
let block_contents = PublishBlockRequest::<T::EthSpec>::from_ssz_bytes(
&block_bytes,
Expand All @@ -971,6 +986,7 @@ pub async fn serve<T: BeaconChainTypes>(
&network_tx,
validation_level.broadcast_validation,
duplicate_block_status_code,
builder_url,
)
.await
})
Expand Down Expand Up @@ -2570,6 +2586,14 @@ pub async fn serve<T: BeaconChainTypes>(
task_spawner_filter.clone(),
);

// POST v4/validator/blocks/{slot}
let post_validator_blocks_v4 = post_validator_blocks_v4(
eth_v4.clone(),
chain_filter.clone(),
not_while_syncing_filter.clone(),
task_spawner_filter.clone(),
);

// GET validator/blinded_blocks/{slot}
let get_validator_blinded_blocks = get_validator_blinded_blocks(
eth_v1.clone(),
Expand Down Expand Up @@ -2683,6 +2707,12 @@ pub async fn serve<T: BeaconChainTypes>(
chain_filter.clone(),
task_spawner_filter.clone(),
);
// POST validator/builder_preferences
let post_validator_builder_preferences = post_validator_builder_preferences(
eth_v1.clone(),
chain_filter.clone(),
task_spawner_filter.clone(),
);
// POST validator/sync_committee_subscriptions
let post_validator_sync_committee_subscriptions = post_validator_sync_committee_subscriptions(
eth_v1.clone(),
Expand Down Expand Up @@ -3496,6 +3526,8 @@ pub async fn serve<T: BeaconChainTypes>(
.uor(post_validator_sync_committee_subscriptions)
.uor(post_validator_prepare_beacon_proposer)
.uor(post_validator_register_validator)
.uor(post_validator_builder_preferences)
.uor(post_validator_blocks_v4)
.uor(post_validator_liveness_epoch)
.uor(post_lighthouse_liveness)
.uor(post_lighthouse_database_reconstruct)
Expand Down
Loading
Loading