From 51c526833535036437d8fb25398083e7666251ed Mon Sep 17 00:00:00 2001 From: Nina Date: Thu, 17 Sep 2026 14:51:06 +0100 Subject: [PATCH 1/2] proposer duties --- crates/beacon_api/src/duties.rs | 246 ++++++++++++++++++++++++++++++++ crates/beacon_api/src/json.rs | 24 +++- crates/beacon_api/src/lib.rs | 1 + crates/beacon_api/src/routes.rs | 14 +- 4 files changed, 276 insertions(+), 9 deletions(-) create mode 100644 crates/beacon_api/src/duties.rs diff --git a/crates/beacon_api/src/duties.rs b/crates/beacon_api/src/duties.rs new file mode 100644 index 00000000..2edb8c1b --- /dev/null +++ b/crates/beacon_api/src/duties.rs @@ -0,0 +1,246 @@ +use silver_beacon_state_data::{ + B256, BLSPubkey, Epoch, MIN_SEED_LOOKAHEAD, SLOTS_PER_EPOCH, Slot, StateReadView, +}; + +use crate::{ids::parse_uint64, response::Response, router::Request, routes::ApiCtx}; + +pub(crate) fn proposer_duties(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + respond(req, ctx, resp, 0); +} + +/// The v2 dependent root is one epoch earlier: with the proposer lookahead, +/// the proposers of an epoch are fixed before the epoch before it begins. +pub(crate) fn proposer_duties_v2(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { + respond(req, ctx, resp, MIN_SEED_LOOKAHEAD); +} + +fn respond(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>, dependent_lookahead: u64) { + let epoch = req.params.get("epoch").expect("{epoch} in the route pattern"); + let Some(epoch) = parse_uint64(epoch) else { + resp.error(400, "invalid epoch"); + return; + }; + + let head_root = ctx.node_status.head_root; + let duties = ctx.read_state(|view| { + ProposerDuties::read(&view, epoch, epoch.saturating_sub(dependent_lookahead), head_root) + }); + match duties { + Some(duties) => resp.json_body(|json| { + json.proposer_duties(&duties, ctx.node_status.execution_optimistic()) + }), + None => resp.error(400, "epoch outside the proposer lookahead of the head state"), + } +} + +pub(crate) struct ProposerDuties { + pub(crate) dependent_root: B256, + pub(crate) duties: [ProposerDuty; SLOTS_PER_EPOCH as usize], +} + +pub(crate) struct ProposerDuty { + pub(crate) pubkey: BLSPubkey, + pub(crate) validator_index: u64, + pub(crate) slot: Slot, +} + +impl ProposerDuties { + fn read( + view: &StateReadView<'_>, + epoch: Epoch, + dependent_epoch: Epoch, + head_root: B256, + ) -> Option { + let slot_state = view.slot.state(); + let state_epoch = slot_state.slot / SLOTS_PER_EPOCH; + if epoch < state_epoch || epoch > state_epoch + MIN_SEED_LOOKAHEAD { + return None; + } + + let start_slot = epoch * SLOTS_PER_EPOCH; + let lookahead_start = (start_slot - state_epoch * SLOTS_PER_EPOCH) as usize; + let mut proposers = [0u64; SLOTS_PER_EPOCH as usize]; + for (offset, proposer) in proposers.iter_mut().enumerate() { + let lookahead_idx = lookahead_start + offset; + let Some(found) = view.epoch.proposer_at(lookahead_idx) else { + tracing::error!( + epoch, + state_epoch, + lookahead_idx, + "proposer lookahead is shorter than the window it must cover" + ); + return None; + }; + *proposer = found; + } + + let duties = std::array::from_fn(|offset| ProposerDuty { + pubkey: *view.validators.pubkey(proposers[offset] as usize), + validator_index: proposers[offset], + slot: start_slot + offset as u64, + }); + + let head_slot = slot_state.latest_block_header.slot; + let decision_slot = (dependent_epoch * SLOTS_PER_EPOCH).saturating_sub(1); + let dependent_root = if decision_slot >= head_slot { + head_root + } else { + view.block_roots.at_slot(decision_slot) + }; + + Some(Self { dependent_root, duties }) + } +} + +#[cfg(test)] +mod tests { + use silver_beacon_state_data::{ + BeaconBlockHeader, BeaconState, BeaconStateOwner, BlockRootsGroup, EpochState, + EpochStateFinalized, PROPOSER_LOOKAHEAD_SIZE, SLOTS_PER_HISTORICAL_ROOT, SlotState, + SlotStateFinalized, SlotStateGroup, SpecConfig, ValSeed, + }; + use silver_httpcore::ParsedRequest; + + use super::*; + use crate::{ + router::{Outcome, Router}, + routes::{ROUTES, test_ctx}, + }; + + const STATE_EPOCH: u64 = 300; + const STATE_SLOT: u64 = STATE_EPOCH * SLOTS_PER_EPOCH + 5; + const HEAD_SLOT: u64 = STATE_SLOT - 2; + const HEAD_ROOT: B256 = [0xdd; 32]; + + /// The block root the ring holds for `slot`, distinct per slot. + fn ring_root(slot: u64) -> B256 { + let mut root = [0u8; 32]; + root[..8].copy_from_slice(&slot.to_le_bytes()); + root + } + + /// Three validators; the lookahead rotates through them so each slot's + /// proposer is `slot % 3`. + fn ctx() -> ApiCtx { + let seeds: Vec<_> = + (0..3u8).map(|i| ValSeed { pubkey: [0xa0 + i; 48], ..ValSeed::default() }).collect(); + let epoch = EpochState { + proposer_lookahead: std::array::from_fn(|i| { + (STATE_EPOCH * SLOTS_PER_EPOCH + i as u64) % 3 + }), + ..Default::default() + }; + let mut state = + BeaconState::for_test(EpochStateFinalized::from_state(epoch), &seeds, STATE_SLOT); + state.slot_states = SlotStateGroup::new(SlotStateFinalized::new(SlotState { + slot: STATE_SLOT, + latest_block_header: BeaconBlockHeader { slot: HEAD_SLOT, ..Default::default() }, + ..Default::default() + })); + let roots: Vec = (0..SLOTS_PER_HISTORICAL_ROOT as u64) + .flat_map(|i| ring_root(STATE_SLOT - STATE_SLOT % SLOTS_PER_HISTORICAL_ROOT as u64 + i)) + .collect(); + state.block_roots = BlockRootsGroup::vector(&roots).unwrap(); + + let mut owner = BeaconStateOwner::new(state); + let anchor = owner.roll_fresh(); + owner.publish_state_id(anchor); + let mut ctx = test_ctx(&SpecConfig::mainnet(), owner.reader()); + ctx.node_status.head_root = HEAD_ROOT; + ctx + } + + fn get(path: &str) -> Vec { + let req = ParsedRequest { + method: "GET", + path, + query: "", + body: b"", + accept: None, + content_type: None, + eth_consensus_version: None, + version: 1, + keep_alive: true, + }; + let mut out = Vec::new(); + assert_eq!(Router::new(ROUTES).dispatch(&req, &ctx(), &mut out), Outcome::Response); + out + } + + fn body(response: &[u8]) -> String { + let text = std::str::from_utf8(response).unwrap(); + assert!(text.starts_with("HTTP/1.1 200 OK\r\n"), "{text}"); + text[text.find("\r\n\r\n").unwrap() + 4..].to_string() + } + + fn status_code(response: &[u8]) -> &str { + std::str::from_utf8(response).unwrap().split(' ').nth(1).unwrap() + } + + fn hex(bytes: &[u8]) -> String { + format!("0x{}", hex::encode(bytes)) + } + + fn expected(dependent_root: B256, epoch: u64) -> String { + let duties: Vec<_> = (epoch * SLOTS_PER_EPOCH..(epoch + 1) * SLOTS_PER_EPOCH) + .map(|slot| { + format!( + "{{\"pubkey\":\"{}\",\"validator_index\":\"{}\",\"slot\":\"{slot}\"}}", + hex(&[0xa0 + (slot % 3) as u8; 48]), + slot % 3 + ) + }) + .collect(); + format!( + "{{\"dependent_root\":\"{}\",\"execution_optimistic\":false,\"data\":[{}]}}", + hex(&dependent_root), + duties.join(",") + ) + } + + /// Body shape: `apis/validator/duties/proposer.yaml`. Both covered epochs + /// come from the lookahead, and the v1 dependent root is the root before + /// the epoch, or the head itself for the epoch still ahead of it. + #[test] + fn v1_serves_the_lookahead_epochs_with_the_root_before_each() { + let current = format!("/eth/v1/validator/duties/proposer/{STATE_EPOCH}"); + let before_current = ring_root(STATE_EPOCH * SLOTS_PER_EPOCH - 1); + assert_eq!(body(&get(¤t)), expected(before_current, STATE_EPOCH)); + + let next = format!("/eth/v1/validator/duties/proposer/{}", STATE_EPOCH + 1); + assert_eq!(body(&get(&next)), expected(HEAD_ROOT, STATE_EPOCH + 1)); + } + + /// `proposer.v2.yaml`: the dependent root is one epoch earlier than v1's. + #[test] + fn v2_dependent_root_is_one_epoch_earlier() { + let current = format!("/eth/v2/validator/duties/proposer/{STATE_EPOCH}"); + let before_previous = ring_root((STATE_EPOCH - 1) * SLOTS_PER_EPOCH - 1); + assert_eq!(body(&get(¤t)), expected(before_previous, STATE_EPOCH)); + + let next = format!("/eth/v2/validator/duties/proposer/{}", STATE_EPOCH + 1); + let before_current = ring_root(STATE_EPOCH * SLOTS_PER_EPOCH - 1); + assert_eq!(body(&get(&next)), expected(before_current, STATE_EPOCH + 1)); + } + + #[test] + fn epochs_outside_the_lookahead_and_malformed_epochs_are_400() { + for epoch in [ + (STATE_EPOCH - 1).to_string(), + (STATE_EPOCH + 2).to_string(), + "0".to_string(), + "-1".to_string(), + "abc".to_string(), + ] { + for version in ["v1", "v2"] { + let path = format!("/eth/{version}/validator/duties/proposer/{epoch}"); + assert_eq!(status_code(&get(&path)), "400", "{path}"); + } + } + } + + #[test] + fn lookahead_size_covers_the_two_epochs_served() { + assert_eq!(PROPOSER_LOOKAHEAD_SIZE as u64, (MIN_SEED_LOOKAHEAD + 1) * SLOTS_PER_EPOCH); + } +} diff --git a/crates/beacon_api/src/json.rs b/crates/beacon_api/src/json.rs index 55f8604e..de6cac3d 100644 --- a/crates/beacon_api/src/json.rs +++ b/crates/beacon_api/src/json.rs @@ -6,7 +6,7 @@ use std::io::Write; use silver_beacon_state_data::{B256, BeaconBlockHeader, Checkpoint, Fork, Version}; -use crate::{events::HeadEvent, peers::Peer, validators::ValidatorRecord}; +use crate::{duties::ProposerDuties, events::HeadEvent, peers::Peer, validators::ValidatorRecord}; const HEX_LOWER: &[u8; 16] = b"0123456789abcdef"; @@ -393,6 +393,28 @@ impl Json<'_> { self.end_object(); } + pub(crate) fn proposer_duties(&mut self, duties: &ProposerDuties, execution_optimistic: bool) { + self.begin_object(); + self.key("dependent_root"); + self.hex(&duties.dependent_root); + self.key("execution_optimistic"); + self.bool(execution_optimistic); + self.key("data"); + self.begin_array(); + for duty in &duties.duties { + self.begin_object(); + self.key("pubkey"); + self.hex(&duty.pubkey); + self.key("validator_index"); + self.quoted_u64(duty.validator_index); + self.key("slot"); + self.quoted_u64(duty.slot); + self.end_object(); + } + self.end_array(); + self.end_object(); + } + /// `ValidatorResponse` (`apis/beacon/states/validator.yaml`). pub(crate) fn validator(&mut self, record: &ValidatorRecord) { self.begin_object(); diff --git a/crates/beacon_api/src/lib.rs b/crates/beacon_api/src/lib.rs index 965a61f9..24690dda 100644 --- a/crates/beacon_api/src/lib.rs +++ b/crates/beacon_api/src/lib.rs @@ -1,5 +1,6 @@ mod blocks; mod config; +mod duties; mod events; mod head_verdict; mod identity; diff --git a/crates/beacon_api/src/routes.rs b/crates/beacon_api/src/routes.rs index 1f0f3bb5..1dc5753f 100644 --- a/crates/beacon_api/src/routes.rs +++ b/crates/beacon_api/src/routes.rs @@ -12,6 +12,7 @@ use silver_httpcore::Query; use crate::{ NodeStatus, blocks::{block, block_header, block_root}, + duties::{proposer_duties, proposer_duties_v2}, events::events, ids::is_recognized_id, json::{FinalityCheckpoints, GenesisData, Json, ReadFlags}, @@ -60,7 +61,7 @@ pub(crate) const ROUTES: &[(Method, &str, Handler)] = &[ "/eth/v1/validator/beacon_committee_subscriptions", post_beacon_committee_subscriptions, ), - (Method::Get, "/eth/v1/validator/duties/proposer/{epoch}", not_implemented), + (Method::Get, "/eth/v1/validator/duties/proposer/{epoch}", proposer_duties), (Method::Post, "/eth/v1/validator/duties/sync/{epoch}", not_implemented), (Method::Post, "/eth/v1/validator/liveness/{epoch}", not_implemented), (Method::Post, "/eth/v1/validator/prepare_beacon_proposer", post_prepare_beacon_proposer), @@ -71,7 +72,7 @@ pub(crate) const ROUTES: &[(Method, &str, Handler)] = &[ post_sync_committee_subscriptions, ), (Method::Get, "/eth/v2/beacon/blocks/{block_id}", block), - (Method::Get, "/eth/v2/validator/duties/proposer/{epoch}", not_implemented), + (Method::Get, "/eth/v2/validator/duties/proposer/{epoch}", proposer_duties_v2), (Method::Get, "/metrics", metrics), ]; @@ -553,12 +554,9 @@ mod tests { fn stubbed_routes_answer_501_not_404() { let router = Router::new(ROUTES); let ctx = anchor_ctx(); - for (method, path) in [ - ("GET", "/eth/v1/validator/duties/proposer/0"), - ("POST", "/eth/v1/validator/duties/sync/0"), - ("POST", "/eth/v1/validator/liveness/0"), - ("GET", "/eth/v2/validator/duties/proposer/0"), - ] { + for (method, path) in + [("POST", "/eth/v1/validator/duties/sync/0"), ("POST", "/eth/v1/validator/liveness/0")] + { let mut out = Vec::new(); let req = ParsedRequest { method, From 9e2af16eaa08541a4644f5786d1c1f3f6b63cbb3 Mon Sep 17 00:00:00 2001 From: Nina Date: Fri, 18 Sep 2026 11:48:32 +0100 Subject: [PATCH 2/2] fixes --- crates/beacon_api/src/duties.rs | 59 ++++++++++++------- crates/beacon_api/src/validators.rs | 62 +++++++++++++++----- crates/beacon_state/data/src/column/roots.rs | 20 ++----- crates/beacon_state/data/src/column/tests.rs | 38 ++++++------ crates/beacon_state/tile/src/tile.rs | 10 +--- crates/beacon_state/tile/src/tile/tests.rs | 8 ++- 6 files changed, 118 insertions(+), 79 deletions(-) diff --git a/crates/beacon_api/src/duties.rs b/crates/beacon_api/src/duties.rs index 2edb8c1b..09e9cfdf 100644 --- a/crates/beacon_api/src/duties.rs +++ b/crates/beacon_api/src/duties.rs @@ -8,22 +8,27 @@ pub(crate) fn proposer_duties(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Respon respond(req, ctx, resp, 0); } -/// The v2 dependent root is one epoch earlier: with the proposer lookahead, -/// the proposers of an epoch are fixed before the epoch before it begins. +/// `proposer.v2.yaml` decides one epoch earlier than `proposer.yaml`: with the +/// proposer lookahead, an epoch's proposers are fixed before the epoch before +/// it begins. pub(crate) fn proposer_duties_v2(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { - respond(req, ctx, resp, MIN_SEED_LOOKAHEAD); + respond(req, ctx, resp, 1); } -fn respond(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>, dependent_lookahead: u64) { +fn respond(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>, epochs_back: u64) { let epoch = req.params.get("epoch").expect("{epoch} in the route pattern"); let Some(epoch) = parse_uint64(epoch) else { resp.error(400, "invalid epoch"); return; }; + if !ctx.node_status.is_following() { + resp.error(503, "api is unavailable while the node is syncing"); + return; + } let head_root = ctx.node_status.head_root; let duties = ctx.read_state(|view| { - ProposerDuties::read(&view, epoch, epoch.saturating_sub(dependent_lookahead), head_root) + ProposerDuties::read(&view, epoch, epoch.saturating_sub(epochs_back), head_root) }); match duties { Some(duties) => resp.json_body(|json| { @@ -80,14 +85,8 @@ impl ProposerDuties { slot: start_slot + offset as u64, }); - let head_slot = slot_state.latest_block_header.slot; - let decision_slot = (dependent_epoch * SLOTS_PER_EPOCH).saturating_sub(1); - let dependent_root = if decision_slot >= head_slot { - head_root - } else { - view.block_roots.at_slot(decision_slot) - }; - + let dependent_root = + view.block_roots.duty_dependent_root(dependent_epoch, head_root, slot_state.slot)?; Some(Self { dependent_root, duties }) } } @@ -99,6 +98,7 @@ mod tests { EpochStateFinalized, PROPOSER_LOOKAHEAD_SIZE, SLOTS_PER_HISTORICAL_ROOT, SlotState, SlotStateFinalized, SlotStateGroup, SpecConfig, ValSeed, }; + use silver_common::SyncUpdate; use silver_httpcore::ParsedRequest; use super::*; @@ -119,9 +119,13 @@ mod tests { root } + fn ctx() -> ApiCtx { + ctx_at(STATE_SLOT, HEAD_SLOT) + } + /// Three validators; the lookahead rotates through them so each slot's /// proposer is `slot % 3`. - fn ctx() -> ApiCtx { + fn ctx_at(state_slot: Slot, head_slot: Slot) -> ApiCtx { let seeds: Vec<_> = (0..3u8).map(|i| ValSeed { pubkey: [0xa0 + i; 48], ..ValSeed::default() }).collect(); let epoch = EpochState { @@ -131,15 +135,15 @@ mod tests { ..Default::default() }; let mut state = - BeaconState::for_test(EpochStateFinalized::from_state(epoch), &seeds, STATE_SLOT); + BeaconState::for_test(EpochStateFinalized::from_state(epoch), &seeds, state_slot); state.slot_states = SlotStateGroup::new(SlotStateFinalized::new(SlotState { - slot: STATE_SLOT, - latest_block_header: BeaconBlockHeader { slot: HEAD_SLOT, ..Default::default() }, + slot: state_slot, + latest_block_header: BeaconBlockHeader { slot: head_slot, ..Default::default() }, ..Default::default() })); - let roots: Vec = (0..SLOTS_PER_HISTORICAL_ROOT as u64) - .flat_map(|i| ring_root(STATE_SLOT - STATE_SLOT % SLOTS_PER_HISTORICAL_ROOT as u64 + i)) - .collect(); + let ring_len = SLOTS_PER_HISTORICAL_ROOT as u64; + let roots: Vec = + (0..ring_len).flat_map(|i| ring_root(state_slot - state_slot % ring_len + i)).collect(); state.block_roots = BlockRootsGroup::vector(&roots).unwrap(); let mut owner = BeaconStateOwner::new(state); @@ -147,10 +151,15 @@ mod tests { owner.publish_state_id(anchor); let mut ctx = test_ctx(&SpecConfig::mainnet(), owner.reader()); ctx.node_status.head_root = HEAD_ROOT; + ctx.node_status.target = Some(SyncUpdate::Following); ctx } fn get(path: &str) -> Vec { + get_from(&ctx(), path) + } + + fn get_from(ctx: &ApiCtx, path: &str) -> Vec { let req = ParsedRequest { method: "GET", path, @@ -163,7 +172,7 @@ mod tests { keep_alive: true, }; let mut out = Vec::new(); - assert_eq!(Router::new(ROUTES).dispatch(&req, &ctx(), &mut out), Outcome::Response); + assert_eq!(Router::new(ROUTES).dispatch(&req, ctx, &mut out), Outcome::Response); out } @@ -239,6 +248,14 @@ mod tests { } } + #[test] + fn decision_slots_below_the_state_read_the_ring_not_the_head() { + let state_slot = STATE_EPOCH * SLOTS_PER_EPOCH; + let ctx = ctx_at(state_slot, state_slot - 6); + let path = format!("/eth/v1/validator/duties/proposer/{STATE_EPOCH}"); + assert_eq!(body(&get_from(&ctx, &path)), expected(ring_root(state_slot - 1), STATE_EPOCH)); + } + #[test] fn lookahead_size_covers_the_two_epochs_served() { assert_eq!(PROPOSER_LOOKAHEAD_SIZE as u64, (MIN_SEED_LOOKAHEAD + 1) * SLOTS_PER_EPOCH); diff --git a/crates/beacon_api/src/validators.rs b/crates/beacon_api/src/validators.rs index 8006dcbc..d925532d 100644 --- a/crates/beacon_api/src/validators.rs +++ b/crates/beacon_api/src/validators.rs @@ -5,24 +5,37 @@ use silver_beacon_state_data::{ use silver_httpcore::Query; use crate::{ - ids::{MAX_BODY_IDS, parse_pubkey, parse_uint64}, + ids::{parse_pubkey, parse_uint64}, json::Json, response::Response, router::Request, routes::ApiCtx, }; +const MAX_VALIDATOR_IDS: usize = 32 * 1024; + pub(crate) fn get_state_validators(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { respond_selection(req, ctx, resp, Selection::from_query(req.query)); } pub(crate) fn post_state_validators(req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>) { let selection = serde_json::from_slice::(req.body) - .map_err(|_| "invalid request body") + .map_err(|_| Rejection::bad_request("invalid request body")) .and_then(|body| Selection::from_body(&body)); respond_selection(req, ctx, resp, selection); } +struct Rejection { + code: u16, + message: &'static str, +} + +impl Rejection { + fn bad_request(message: &'static str) -> Self { + Self { code: 400, message } + } +} + /// The whole registry is a valid selection the schema allows and no validator /// client sends: each one names the validators it runs, and the one library /// that wants every validator downloads the SSZ state instead. @@ -30,7 +43,7 @@ fn respond_selection( req: &Request<'_>, ctx: &ApiCtx, resp: &mut Response<'_>, - selection: Result, + selection: Result, ) { let mut selection = match selection { Ok(selection) if !selection.ids.is_empty() => selection, @@ -39,7 +52,7 @@ fn respond_selection( return; } Err(rejection) => { - resp.error(400, rejection); + resp.error(rejection.code, rejection.message); return; } }; @@ -82,11 +95,12 @@ struct SelectionBody<'a> { struct Selection { ids: Vec, statuses: StatusSet, + over_limit: u16, } impl Selection { - fn from_query(query: &str) -> Result { - let mut selection = Self::default(); + fn from_query(query: &str) -> Result { + let mut selection = Self { over_limit: 414, ..Self::default() }; for (name, value) in Query::new(query) { match &*name { "id" => value.split(',').try_for_each(|id| selection.push_id(id))?, @@ -99,23 +113,25 @@ impl Selection { Ok(selection) } - fn from_body(body: &SelectionBody<'_>) -> Result { - let mut selection = Self::default(); + fn from_body(body: &SelectionBody<'_>) -> Result { + let mut selection = Self { over_limit: 400, ..Self::default() }; body.ids.iter().flatten().try_for_each(|id| selection.push_id(id))?; body.statuses.iter().flatten().try_for_each(|status| selection.push_status(status))?; Ok(selection) } - fn push_id(&mut self, text: &str) -> Result<(), &'static str> { - if self.ids.len() == MAX_BODY_IDS { - return Err("too many ids"); + fn push_id(&mut self, text: &str) -> Result<(), Rejection> { + if self.ids.len() == MAX_VALIDATOR_IDS { + return Err(Rejection { code: self.over_limit, message: "too many ids" }); } - self.ids.push(ValidatorId::parse(text).ok_or("invalid id")?); + let id = ValidatorId::parse(text).ok_or(Rejection::bad_request("invalid id"))?; + self.ids.push(id); Ok(()) } - fn push_status(&mut self, text: &str) -> Result<(), &'static str> { - self.statuses = self.statuses.union(StatusSet::parse(text).ok_or("invalid status")?); + fn push_status(&mut self, text: &str) -> Result<(), Rejection> { + let parsed = StatusSet::parse(text).ok_or(Rejection::bad_request("invalid status"))?; + self.statuses = self.statuses.union(parsed); Ok(()) } @@ -486,6 +502,24 @@ mod tests { } } + #[test] + fn id_lists_past_the_cap_are_414_on_the_query_and_400_in_the_body() { + let path = "/eth/v1/beacon/states/head/validators"; + let ids = |count: usize| (0..count).map(|id| id.to_string()).collect::>(); + let body = |count| format!("{{\"ids\":[\"{}\"]}}", ids(count).join("\",\"")); + + assert_eq!( + status_code(&get(path, &format!("id={}", ids(MAX_VALIDATOR_IDS).join(",")))), + "200" + ); + assert_eq!( + status_code(&get(path, &format!("id={}", ids(MAX_VALIDATOR_IDS + 1).join(",")))), + "414" + ); + assert_eq!(status_code(&post(&body(MAX_VALIDATOR_IDS))), "200"); + assert_eq!(status_code(&post(&body(MAX_VALIDATOR_IDS + 1))), "400"); + } + #[test] fn post_mirrors_get_and_validates_its_body() { let body = format!("{{\"ids\":[\"1\",\"{}\"],\"statuses\":null}}", hex(&pubkey(0xa1))); diff --git a/crates/beacon_state/data/src/column/roots.rs b/crates/beacon_state/data/src/column/roots.rs index fbf694ac..14f2d6f2 100644 --- a/crates/beacon_state/data/src/column/roots.rs +++ b/crates/beacon_state/data/src/column/roots.rs @@ -39,29 +39,19 @@ impl RootsView<'_, BlockRoots> { self.get(slot as usize % SLOTS_PER_HISTORICAL_ROOT) } - /// Root at the slot before `epoch` starts, saturating to slot zero. + /// Root at the slot before `epoch` starts, saturating to slot zero, which + /// is what duties for `epoch` depend on. /// - /// A head at the decision slot supplies its own root without a history - /// read. Otherwise, availability is measured from `state_slot`: a - /// checkpoint state can be ahead of its latest block. Returns `None` - /// for overwritten history. + /// The ring holds a root for every slot below `state_slot` and none at or + /// above it. pub fn duty_dependent_root( &self, epoch: Epoch, - head_slot: Slot, head_root: B256, state_slot: Slot, ) -> Option { let decision_slot = (epoch * SLOTS_PER_EPOCH).saturating_sub(1); - debug_assert!( - decision_slot <= head_slot, - "epoch {epoch} decides at slot {decision_slot}, past the head at {head_slot}" - ); - debug_assert!( - head_slot <= state_slot, - "the state at {state_slot} is behind its head at {head_slot}" - ); - if head_slot == decision_slot { + if decision_slot >= state_slot { return Some(head_root); } (state_slot - decision_slot <= SLOTS_PER_HISTORICAL_ROOT as u64) diff --git a/crates/beacon_state/data/src/column/tests.rs b/crates/beacon_state/data/src/column/tests.rs index 008e0fcc..8f54cd32 100644 --- a/crates/beacon_state/data/src/column/tests.rs +++ b/crates/beacon_state/data/src/column/tests.rs @@ -477,12 +477,12 @@ fn duty_dependent_roots_read_the_slots_below_the_two_epoch_starts() { let reader = g.view(id); assert_eq!( - reader.duty_dependent_root(2, 70, HEAD, 70), + reader.duty_dependent_root(2, HEAD, 71), Some([0x3C; 32]), "epoch 2 decides at slot 63, an empty slot holding slot 60's block" ); assert_eq!( - reader.duty_dependent_root(1, 70, HEAD, 70), + reader.duty_dependent_root(1, HEAD, 71), Some([0x1F; 32]), "epoch 1 decides at slot 31" ); @@ -503,7 +503,7 @@ fn early_epochs_saturate_the_decision_slot_to_genesis() { }; let reader = g.view(id); - let at = |epoch, head_slot| reader.duty_dependent_root(epoch, head_slot, HEAD, head_slot); + let at = |epoch, state_slot| reader.duty_dependent_root(epoch, HEAD, state_slot); assert_eq!(at(0, 5), Some(GENESIS), "epoch 0 decides at slot 0"); assert_eq!(at(1, 40), Some([0x1F; 32]), "epoch 1 decides at slot 31"); assert_eq!(at(0, 40), Some(GENESIS), "epoch 0 stays at slot 0"); @@ -516,26 +516,26 @@ fn a_head_at_slot_zero_decides_its_own_shuffling() { let id = g.roll_fresh().commit(); let reader = g.view(id); - assert_eq!(reader.duty_dependent_root(0, 0, HEAD, 0), Some(HEAD)); + assert_eq!(reader.duty_dependent_root(0, HEAD, 0), Some(HEAD)); assert_ne!(HEAD, reader.at_slot(0), "the ring holds nothing at slot 0 yet"); } -#[cfg(debug_assertions)] +/// The ring is written up to the slot below the state's, so a decision slot +/// at or above it has no entry yet and the head answers for it. #[test] -#[should_panic(expected = "past the head")] -fn a_decision_slot_above_the_head_is_a_bug() { +fn decision_slots_the_state_has_not_reached_answer_with_the_head() { + const HEAD: B256 = [0x46; 32]; let mut g = BlockRootsGroup::zeroed_vector(); - let id = g.roll_fresh().commit(); - g.view(id).duty_dependent_root(3, 70, [0x46; 32], 70); -} + let id = { + let mut wv = g.roll_fresh(); + wv.set(63, [0x3C; 32]); + wv.commit() + }; + let reader = g.view(id); -#[cfg(debug_assertions)] -#[test] -#[should_panic(expected = "behind its head")] -fn a_state_behind_its_head_is_a_bug() { - let mut g = BlockRootsGroup::zeroed_vector(); - let id = g.roll_fresh().commit(); - g.view(id).duty_dependent_root(2, 70, [0x46; 32], 69); + assert_eq!(reader.duty_dependent_root(2, HEAD, 64), Some([0x3C; 32]), "slot 63 is written"); + assert_eq!(reader.duty_dependent_root(2, HEAD, 63), Some(HEAD), "the state is at slot 63"); + assert_eq!(reader.duty_dependent_root(3, HEAD, 64), Some(HEAD), "slot 95 is ahead"); } /// At state slot S, the oldest retained slot is S − 8192, inclusive. @@ -553,8 +553,8 @@ fn a_decision_slot_is_available_until_the_state_moves_a_ring_past_it() { // oldest slot. let edge = SLOTS_PER_HISTORICAL_ROOT as u64 + 31; - assert_eq!(reader.duty_dependent_root(1, 70, HEAD, edge), Some([0x1F; 32])); - assert_eq!(reader.duty_dependent_root(1, 70, HEAD, edge + 1), None, "overwritten a slot later"); + assert_eq!(reader.duty_dependent_root(1, HEAD, edge), Some([0x1F; 32])); + assert_eq!(reader.duty_dependent_root(1, HEAD, edge + 1), None, "overwritten a slot later"); } /// A block's reveal accumulates into the current epoch's bucket; the boundary diff --git a/crates/beacon_state/tile/src/tile.rs b/crates/beacon_state/tile/src/tile.rs index 1243e078..6d3cba4b 100644 --- a/crates/beacon_state/tile/src/tile.rs +++ b/crates/beacon_state/tile/src/tile.rs @@ -484,14 +484,8 @@ impl BeaconStateTile { let epoch = node.slot / SLOTS_PER_EPOCH; let view = self.state.read_view(node.state_id); let state_slot = view.slot.state().slot; - let dependent = |epoch| { - view.block_roots.duty_dependent_root( - epoch, - node.slot, - head.observation.root, - state_slot, - ) - }; + let dependent = + |epoch| view.block_roots.duty_dependent_root(epoch, head.observation.root, state_slot); match (dependent(epoch.saturating_sub(1)), dependent(epoch)) { (Some(previous), Some(current)) => HeadRoots { state_root: node.state_root, diff --git a/crates/beacon_state/tile/src/tile/tests.rs b/crates/beacon_state/tile/src/tile/tests.rs index 687d809a..478dfb21 100644 --- a/crates/beacon_state/tile/src/tile/tests.rs +++ b/crates/beacon_state/tile/src/tile/tests.rs @@ -900,6 +900,10 @@ fn startup_status_uses_the_seeded_anchor_on_both_forks() { tile.loop_body(&mut adapter); + // `for_test` jumps to `state_slot` without the `process_slot` + // calls that fill the ring, so only a state still at slot zero + // has its decision slot answered by the head. + let dependent = if state_slot == 0 { root } else { [0; 32] }; assert_eq!( Published::drain(&mut sink).last_head(), StatusHead { @@ -908,8 +912,8 @@ fn startup_status_uses_the_seeded_anchor_on_both_forks() { optimistic: false, roots: HeadRoots { state_root, - previous_duty_dependent_root: root, - current_duty_dependent_root: root, + previous_duty_dependent_root: dependent, + current_duty_dependent_root: dependent, }, payload: if is_gloas { PayloadResolution::Empty