diff --git a/crates/net/rpc/src/beacon/validator.rs b/crates/net/rpc/src/beacon/validator.rs index 4b740688..43cbd3bd 100644 --- a/crates/net/rpc/src/beacon/validator.rs +++ b/crates/net/rpc/src/beacon/validator.rs @@ -1,5 +1,5 @@ -//! The validator-facing endpoints under `/eth/v1/validator/`: what a validator -//! client asks a beacon node for in order to do its duties. +//! The validator-facing endpoints under `/eth/v{1,2}/validator/`: what a +//! validator client asks a beacon node for in order to do its duties. //! //! Every answer is computed from the fork-choice head's post-state, read off //! the shared `Store` the chain actor writes: the head row is refreshed on each @@ -46,6 +46,10 @@ pub(crate) fn routes() -> Router { "/eth/v1/validator/duties/proposer/{epoch}", get(get_proposer_duties), ) + .route( + "/eth/v2/validator/duties/proposer/{epoch}", + get(get_proposer_duties_v2), + ) .route( "/eth/v1/validator/duties/attester/{epoch}", post(post_attester_duties), @@ -289,23 +293,57 @@ struct ProposerDuty { slot: Slot, } -/// `GET /eth/v1/validator/duties/proposer/{epoch}`. -/// -/// Read from fulu's `proposer_lookahead`, which the state keeps for its own -/// epoch and the next `MIN_SEED_LOOKAHEAD` epochs, so any epoch in that window -/// is answered without advancing a state. Any other epoch is refused. +/// The last slot before `epoch`: v1 proposer duties' dependent slot. +fn last_slot_before(epoch: Epoch) -> Slot { + compute_start_slot_at_epoch(epoch).saturating_sub(1) +} + +/// The last slot before the epoch preceding `epoch`: the dependent slot of +/// attester duties and of v2 proposer duties. Saturates to genesis, so epochs +/// 0 and 1 both depend on the genesis block. +fn last_slot_before_previous(epoch: Epoch) -> Slot { + last_slot_before(epoch.saturating_sub(1)) +} + +/// `GET /eth/v1/validator/duties/proposer/{epoch}`, deprecated by the Beacon +/// API in favour of v2. /// /// `dependent_root` is v1's definition, the block root at /// `compute_start_slot_at_epoch(epoch) - 1` (the genesis block's at epoch 0). /// It is what `ethlambda validator` compares across fetches to notice a reorg. async fn get_proposer_duties(Path(epoch): Path, State(store): State) -> Response { - match proposer_duties(&store, &epoch) { + match proposer_duties(&store, &epoch, last_slot_before) { Ok(body) => crate::json_response(body), Err(err) => err.into_response(), } } -fn proposer_duties(store: &Store, epoch: &str) -> Result { +/// `GET /eth/v2/validator/duties/proposer/{epoch}`: v1's duties, with +/// `dependent_root` at `compute_start_slot_at_epoch(epoch - 1) - 1` (the +/// genesis block's on underflow). +/// +/// That is the block fulu's lookahead depends on: an epoch's proposers are +/// written into `proposer_lookahead` by the epoch transition into the epoch +/// before it, from the state the blocks before that transition left. v1's +/// later root also changes on reorgs that leave the duties as they were. +async fn get_proposer_duties_v2(Path(epoch): Path, State(store): State) -> Response { + match proposer_duties(&store, &epoch, last_slot_before_previous) { + Ok(body) => crate::json_response(body), + Err(err) => err.into_response(), + } +} + +/// Proposer duties for `epoch`, with `dependent_root` the block at the slot +/// `dependent_slot` names for it, which is all that differs between versions. +/// +/// Read from fulu's `proposer_lookahead`, which the state keeps for its own +/// epoch and the next `MIN_SEED_LOOKAHEAD` epochs, so any epoch in that window +/// is answered without advancing a state. Any other epoch is refused. +fn proposer_duties( + store: &Store, + epoch: &str, + dependent_slot: fn(Epoch) -> Slot, +) -> Result { let epoch = parse_epoch(epoch)?; let (head_root, state) = head(store)?; @@ -340,7 +378,7 @@ fn proposer_duties(store: &Store, epoch: &str) -> Result, ApiError>>()?; - let dependent_root = block_root_at_or_before(&state, head_root, first_slot.saturating_sub(1))?; + let dependent_root = block_root_at_or_before(&state, head_root, dependent_slot(epoch))?; Ok(serde_json::json!({ "dependent_root": dependent_root, "execution_optimistic": store.is_beacon_optimistic(head_root), @@ -442,7 +480,7 @@ fn attester_duties( } } - let dependent_slot = compute_start_slot_at_epoch(epoch.saturating_sub(1)).saturating_sub(1); + let dependent_slot = last_slot_before_previous(epoch); let dependent_root = block_root_at_or_before(&state, head_root, dependent_slot)?; Ok(serde_json::json!({ "dependent_root": dependent_root, @@ -626,6 +664,29 @@ mod tests { assert_eq!(json["dependent_root"], format!("{head_root}")); } + /// v2 serves v1's duties with each epoch's dependent block an epoch + /// earlier. The head's epoch is 1, so its dependent slot underflows to + /// genesis; the next epoch's is the last slot of epoch 0, where v1 names + /// the head. + #[tokio::test] + async fn v2_dependent_root_is_the_block_before_the_previous_epoch() { + let state = fulu_state(); + let state_epoch = compute_epoch_at_slot(state.slot()); + for epoch in [state_epoch, state_epoch + 1] { + let v1_uri = format!("/eth/v1/validator/duties/proposer/{epoch}"); + let (_, v1) = get(state.clone(), &v1_uri).await; + let v2_uri = format!("/eth/v2/validator/duties/proposer/{epoch}"); + let (status, v2) = get(state.clone(), &v2_uri).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(v2["data"], v1["data"]); + + let dependent = compute_start_slot_at_epoch(epoch - 1).saturating_sub(1); + let expected = get_block_root_at_slot(&state, dependent).unwrap(); + assert_eq!(v2["dependent_root"], format!("{expected}")); + assert_ne!(v2["dependent_root"], v1["dependent_root"]); + } + } + async fn post( state: BeaconState, uri: &str, @@ -904,12 +965,11 @@ mod tests { async fn an_epoch_outside_the_lookahead_is_a_400() { let state = fulu_state(); let too_far = compute_epoch_at_slot(state.slot()) + 2; - let (status, _) = get( - state, - &format!("/eth/v1/validator/duties/proposer/{too_far}"), - ) - .await; - assert_eq!(status, StatusCode::BAD_REQUEST); + for version in ["v1", "v2"] { + let uri = format!("/eth/{version}/validator/duties/proposer/{too_far}"); + let (status, _) = get(state.clone(), &uri).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{version}"); + } } // --- duties/sync ----------------------------------------------------- diff --git a/docs/rpc.md b/docs/rpc.md index 6d007e06..12fb6bf3 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -239,7 +239,8 @@ surface rather than sitting beside it; a `/lean/v0` path on a beacon node is a | `GET` | `/eth/v1/node/version` | JSON | Client version string | | `GET` | `/eth/v1/node/identity` | JSON | Peer ID and metadata only (see below) | | `GET`, `POST` | `/eth/v1/beacon/states/{state_id}/validators` | JSON | Registry entries by index or pubkey, with status | -| `GET` | `/eth/v1/validator/duties/proposer/{epoch}` | JSON | Proposers for the head's epoch or the next | +| `GET` | `/eth/v1/validator/duties/proposer/{epoch}` | JSON | Proposers for the head's epoch or the next (deprecated by the spec) | +| `GET` | `/eth/v2/validator/duties/proposer/{epoch}` | JSON | Same duties, with v2's earlier `dependent_root` | | `POST` | `/eth/v1/validator/duties/attester/{epoch}` | JSON | Committee assignments for the given indices | | `POST` | `/eth/v1/validator/duties/sync/{epoch}` | JSON | Sync committee seats for the given indices, in the head's current or next period | | `GET` | `/eth/v1/validator/attestation_data` | JSON | What to attest to at `slot` | @@ -261,8 +262,12 @@ the chain actor writes, so no request waits on the actor. duties read fulu's `proposer_lookahead`, which covers the head's epoch and the next; attester duties cover the head's previous, current and next epoch, which is as far as its shuffling is already fixed. Anything else is a `400`. - `dependent_root` follows each endpoint's v1 definition. Attester duties walk - every committee of the epoch, a full shuffle per request on mainnet. + `dependent_root` follows each endpoint's own definition: the last block + before the epoch for v1 proposer duties, and the last block before the + previous epoch for v2 proposer duties and attester duties. v2's is the block + fulu's lookahead depends on, so it changes only on reorgs that can change the + duties. Attester duties walk every committee of the epoch, a full shuffle per + request on mainnet. - **Sync duties** read the head state's `current_sync_committee` for an epoch in the head's own sync committee period and `next_sync_committee` for the one after; any other period is a `400` (an earlier one would need a historical