Skip to content
Closed
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
94 changes: 77 additions & 17 deletions crates/net/rpc/src/beacon/validator.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -46,6 +46,10 @@ pub(crate) fn routes() -> Router<Store> {
"/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),
Expand Down Expand Up @@ -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<String>, State(store): State<Store>) -> 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<serde_json::Value, ApiError> {
/// `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<String>, State(store): State<Store>) -> 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<serde_json::Value, ApiError> {
let epoch = parse_epoch(epoch)?;
let (head_root, state) = head(store)?;

Expand Down Expand Up @@ -340,7 +378,7 @@ fn proposer_duties(store: &Store, epoch: &str) -> Result<serde_json::Value, ApiE
})
.collect::<Result<Vec<_>, 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),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 -----------------------------------------------------
Expand Down
11 changes: 8 additions & 3 deletions docs/rpc.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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
Expand Down
Loading