Implement proposer duties v2 endpoint (#8918)

Fix the issue with the `proposer_duties` endpoint using the wrong dependent root post-Fulu by implementing the new v2 endpoint:

- https://github.com/ethereum/beacon-APIs/pull/563

We need to add this in time for Gloas, and then we can we can deprecate and remove v1.


  - Add a new API handler for the v2 endpoint
- Add client code in the `eth2` crate
- Update existing tests and add some new ones to confirm the different behaviour of v1 and v2

There's a bit of test duplication with v1, but this will be resolved once v1 and its tests are deleted.


Co-Authored-By: Michael Sproul <michael@sigmaprime.io>

Co-Authored-By: Michael Sproul <michaelsproul@users.noreply.github.com>

Co-Authored-By: chonghe <44791194+chong-he@users.noreply.github.com>
This commit is contained in:
Michael Sproul
2026-03-10 18:57:51 +11:00
committed by GitHub
parent 9f3873f2bf
commit 081229b748
6 changed files with 438 additions and 32 deletions

View File

@@ -263,6 +263,7 @@ pub fn prometheus_metrics() -> warp::filters::log::Log<impl Fn(warp::filters::lo
.or_else(|| starts_with("v1/validator/contribution_and_proofs"))
.or_else(|| starts_with("v1/validator/duties/attester"))
.or_else(|| starts_with("v1/validator/duties/proposer"))
.or_else(|| starts_with("v2/validator/duties/proposer"))
.or_else(|| starts_with("v1/validator/duties/sync"))
.or_else(|| starts_with("v1/validator/liveness"))
.or_else(|| starts_with("v1/validator/prepare_beacon_proposer"))
@@ -2464,7 +2465,7 @@ pub fn serve<T: BeaconChainTypes>(
// GET validator/duties/proposer/{epoch}
let get_validator_duties_proposer = get_validator_duties_proposer(
eth_v1.clone(),
any_version.clone(),
chain_filter.clone(),
not_while_syncing_filter.clone(),
task_spawner_filter.clone(),

View File

@@ -13,13 +13,45 @@ use slot_clock::SlotClock;
use tracing::debug;
use types::{Epoch, EthSpec, Hash256, Slot};
/// Selects which dependent root to return in the API response.
///
/// - `Legacy`: the block root at the last slot of epoch N-1 (v1 behaviour, for backwards compat).
/// - `True`: the fork-aware proposer shuffling decision root (v2 behaviour). Pre-Fulu this equals
/// the legacy root; post-Fulu it uses epoch N-2.
#[derive(Clone, Copy, PartialEq, Eq)]
enum DependentRootSelection {
Legacy,
True,
}
/// The struct that is returned to the requesting HTTP client.
type ApiDuties = api_types::DutiesResponse<Vec<api_types::ProposerData>>;
/// Handles a request from the HTTP API for proposer duties.
/// Handles a request from the HTTP API for v1 proposer duties.
///
/// Returns the legacy dependent root (block root at end of epoch N-1) for backwards compatibility.
pub fn proposer_duties<T: BeaconChainTypes>(
request_epoch: Epoch,
chain: &BeaconChain<T>,
) -> Result<ApiDuties, warp::reject::Rejection> {
proposer_duties_internal(request_epoch, chain, DependentRootSelection::Legacy)
}
/// Handles a request from the HTTP API for v2 proposer duties.
///
/// Returns the true fork-aware dependent root. Pre-Fulu this equals the legacy root; post-Fulu it
/// uses epoch N-2 due to deterministic proposer lookahead with `min_seed_lookahead`.
pub fn proposer_duties_v2<T: BeaconChainTypes>(
request_epoch: Epoch,
chain: &BeaconChain<T>,
) -> Result<ApiDuties, warp::reject::Rejection> {
proposer_duties_internal(request_epoch, chain, DependentRootSelection::True)
}
fn proposer_duties_internal<T: BeaconChainTypes>(
request_epoch: Epoch,
chain: &BeaconChain<T>,
root_selection: DependentRootSelection,
) -> Result<ApiDuties, warp::reject::Rejection> {
let current_epoch = chain
.slot_clock
@@ -49,24 +81,29 @@ pub fn proposer_duties<T: BeaconChainTypes>(
if request_epoch == current_epoch || request_epoch == tolerant_current_epoch {
// If we could consider ourselves in the `request_epoch` when allowing for clock disparity
// tolerance then serve this request from the cache.
if let Some(duties) = try_proposer_duties_from_cache(request_epoch, chain)? {
if let Some(duties) = try_proposer_duties_from_cache(request_epoch, chain, root_selection)?
{
Ok(duties)
} else {
debug!(%request_epoch, "Proposer cache miss");
compute_and_cache_proposer_duties(request_epoch, chain)
compute_and_cache_proposer_duties(request_epoch, chain, root_selection)
}
} else if request_epoch
== current_epoch
.safe_add(1)
.map_err(warp_utils::reject::arith_error)?
{
let (proposers, _dependent_root, legacy_dependent_root, execution_status, _fork) =
let (proposers, dependent_root, legacy_dependent_root, execution_status, _fork) =
compute_proposer_duties_from_head(request_epoch, chain)
.map_err(warp_utils::reject::unhandled_error)?;
let selected_root = match root_selection {
DependentRootSelection::Legacy => legacy_dependent_root,
DependentRootSelection::True => dependent_root,
};
convert_to_api_response(
chain,
request_epoch,
legacy_dependent_root,
selected_root,
execution_status.is_optimistic_or_invalid(),
proposers,
)
@@ -84,7 +121,7 @@ pub fn proposer_duties<T: BeaconChainTypes>(
// request_epoch < current_epoch
//
// Queries about the past are handled with a slow path.
compute_historic_proposer_duties(request_epoch, chain)
compute_historic_proposer_duties(request_epoch, chain, root_selection)
}
}
@@ -98,6 +135,7 @@ pub fn proposer_duties<T: BeaconChainTypes>(
fn try_proposer_duties_from_cache<T: BeaconChainTypes>(
request_epoch: Epoch,
chain: &BeaconChain<T>,
root_selection: DependentRootSelection,
) -> Result<Option<ApiDuties>, warp::reject::Rejection> {
let head = chain.canonical_head.cached_head();
let head_block = &head.snapshot.beacon_block;
@@ -116,11 +154,14 @@ fn try_proposer_duties_from_cache<T: BeaconChainTypes>(
.beacon_state
.proposer_shuffling_decision_root_at_epoch(request_epoch, head_block_root, &chain.spec)
.map_err(warp_utils::reject::beacon_state_error)?;
let legacy_dependent_root = head
.snapshot
.beacon_state
.legacy_proposer_shuffling_decision_root_at_epoch(request_epoch, head_block_root)
.map_err(warp_utils::reject::beacon_state_error)?;
let selected_root = match root_selection {
DependentRootSelection::Legacy => head
.snapshot
.beacon_state
.legacy_proposer_shuffling_decision_root_at_epoch(request_epoch, head_block_root)
.map_err(warp_utils::reject::beacon_state_error)?,
DependentRootSelection::True => head_decision_root,
};
let execution_optimistic = chain
.is_optimistic_or_invalid_head_block(head_block)
.map_err(warp_utils::reject::unhandled_error)?;
@@ -134,7 +175,7 @@ fn try_proposer_duties_from_cache<T: BeaconChainTypes>(
convert_to_api_response(
chain,
request_epoch,
legacy_dependent_root,
selected_root,
execution_optimistic,
indices.to_vec(),
)
@@ -155,6 +196,7 @@ fn try_proposer_duties_from_cache<T: BeaconChainTypes>(
fn compute_and_cache_proposer_duties<T: BeaconChainTypes>(
current_epoch: Epoch,
chain: &BeaconChain<T>,
root_selection: DependentRootSelection,
) -> Result<ApiDuties, warp::reject::Rejection> {
let (indices, dependent_root, legacy_dependent_root, execution_status, fork) =
compute_proposer_duties_from_head(current_epoch, chain)
@@ -168,10 +210,14 @@ fn compute_and_cache_proposer_duties<T: BeaconChainTypes>(
.map_err(BeaconChainError::from)
.map_err(warp_utils::reject::unhandled_error)?;
let selected_root = match root_selection {
DependentRootSelection::Legacy => legacy_dependent_root,
DependentRootSelection::True => dependent_root,
};
convert_to_api_response(
chain,
current_epoch,
legacy_dependent_root,
selected_root,
execution_status.is_optimistic_or_invalid(),
indices,
)
@@ -182,6 +228,7 @@ fn compute_and_cache_proposer_duties<T: BeaconChainTypes>(
fn compute_historic_proposer_duties<T: BeaconChainTypes>(
epoch: Epoch,
chain: &BeaconChain<T>,
root_selection: DependentRootSelection,
) -> Result<ApiDuties, warp::reject::Rejection> {
// If the head is quite old then it might still be relevant for a historical request.
//
@@ -219,9 +266,9 @@ fn compute_historic_proposer_duties<T: BeaconChainTypes>(
};
// Ensure the state lookup was correct.
if state.current_epoch() != epoch {
if state.current_epoch() != epoch && state.current_epoch() + 1 != epoch {
return Err(warp_utils::reject::custom_server_error(format!(
"state epoch {} not equal to request epoch {}",
"state from epoch {} cannot serve request epoch {}",
state.current_epoch(),
epoch
)));
@@ -234,18 +281,18 @@ fn compute_historic_proposer_duties<T: BeaconChainTypes>(
// We can supply the genesis block root as the block root since we know that the only block that
// decides its own root is the genesis block.
let legacy_dependent_root = state
.legacy_proposer_shuffling_decision_root_at_epoch(epoch, chain.genesis_block_root)
.map_err(BeaconChainError::from)
.map_err(warp_utils::reject::unhandled_error)?;
let selected_root = match root_selection {
DependentRootSelection::Legacy => state
.legacy_proposer_shuffling_decision_root_at_epoch(epoch, chain.genesis_block_root)
.map_err(BeaconChainError::from)
.map_err(warp_utils::reject::unhandled_error)?,
DependentRootSelection::True => state
.proposer_shuffling_decision_root_at_epoch(epoch, chain.genesis_block_root, &chain.spec)
.map_err(BeaconChainError::from)
.map_err(warp_utils::reject::unhandled_error)?,
};
convert_to_api_response(
chain,
epoch,
legacy_dependent_root,
execution_optimistic,
indices,
)
convert_to_api_response(chain, epoch, selected_root, execution_optimistic, indices)
}
/// Converts the internal representation of proposer duties into one that is compatible with the

View File

@@ -6,7 +6,7 @@ use crate::utils::{
AnyVersionFilter, ChainFilter, EthV1Filter, NetworkTxFilter, NotWhileSyncingFilter,
ResponseFilter, TaskSpawnerFilter, ValidatorSubscriptionTxFilter, publish_network_message,
};
use crate::version::V3;
use crate::version::{V1, V2, V3, unsupported_version_rejection};
use crate::{StateId, attester_duties, proposer_duties, sync_committees};
use beacon_chain::attestation_verification::VerifiedAttestation;
use beacon_chain::validator_monitor::timestamp_now;
@@ -971,12 +971,12 @@ pub fn post_validator_aggregate_and_proofs<T: BeaconChainTypes>(
// GET validator/duties/proposer/{epoch}
pub fn get_validator_duties_proposer<T: BeaconChainTypes>(
eth_v1: EthV1Filter,
any_version: AnyVersionFilter,
chain_filter: ChainFilter<T>,
not_while_syncing_filter: NotWhileSyncingFilter,
task_spawner_filter: TaskSpawnerFilter<T>,
) -> ResponseFilter {
eth_v1
any_version
.and(warp::path("validator"))
.and(warp::path("duties"))
.and(warp::path("proposer"))
@@ -990,13 +990,20 @@ pub fn get_validator_duties_proposer<T: BeaconChainTypes>(
.and(task_spawner_filter)
.and(chain_filter)
.then(
|epoch: Epoch,
|endpoint_version: EndpointVersion,
epoch: Epoch,
not_synced_filter: Result<(), Rejection>,
task_spawner: TaskSpawner<T::EthSpec>,
chain: Arc<BeaconChain<T>>| {
task_spawner.blocking_json_task(Priority::P0, move || {
not_synced_filter?;
proposer_duties::proposer_duties(epoch, &chain)
if endpoint_version == V1 {
proposer_duties::proposer_duties(epoch, &chain)
} else if endpoint_version == V2 {
proposer_duties::proposer_duties_v2(epoch, &chain)
} else {
Err(unsupported_version_rejection(endpoint_version))
}
})
},
)