From b252fbca519ab3ba83536c59cd3cb4e38459935c Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Tue, 29 Sep 2026 15:37:16 -0300 Subject: [PATCH 01/11] Serve GET /eth/v1/beacon/states/{state_id}/fork, GET /eth/v1/config/deposit_contract and POST /eth/v1/validator/duties/sync/{epoch} from ethlambda beacon, three of the four Beacon API endpoints validator clients call that were missing. fork returns the state's own Fork through the same state_id resolution as the other state endpoints, so a state root is the same 404. deposit_contract returns the Config's deposit chain id and contract address. duties/sync reads the head state's current_sync_committee for an epoch in the head's own sync committee period and next_sync_committee for the next one, matches each requested validator by pubkey and returns every seat it holds (the committee is drawn with replacement), leaves out validators with no seat, and answers 400 for an unknown index or any other period and 503 while syncing. An earlier period is refused rather than answered from a historical state, recorded in docs/spec_deviations.md. compute_sync_committee_period is added to the altair helpers as validator.md defines it. --- .../src/beacon/helpers/altair.rs | 19 ++ crates/net/rpc/src/beacon/config.rs | 42 ++- crates/net/rpc/src/beacon/states.rs | 53 ++++ crates/net/rpc/src/beacon/validator.rs | 242 ++++++++++++++++++ docs/rpc.md | 12 + docs/spec_deviations.md | 15 ++ 6 files changed, 375 insertions(+), 8 deletions(-) diff --git a/crates/blockchain/state_transition/src/beacon/helpers/altair.rs b/crates/blockchain/state_transition/src/beacon/helpers/altair.rs index eaa008c2..bb0ab6eb 100644 --- a/crates/blockchain/state_transition/src/beacon/helpers/altair.rs +++ b/crates/blockchain/state_transition/src/beacon/helpers/altair.rs @@ -170,6 +170,16 @@ pub fn get_next_sync_committee(state: &BeaconState) -> Result u64 { + epoch / preset::EPOCHS_PER_SYNC_COMMITTEE_PERIOD +} + /// The reward every one-increment slice of a validator's effective balance /// earns for a single timely, correct component of its attestation. /// @@ -592,4 +602,13 @@ mod tests { let elapsed = start.elapsed() / ITERATIONS; println!("get_flag_index_deltas, {VALIDATOR_COUNT} validators -> {elapsed:?}/call"); } + + #[test] + fn a_sync_committee_period_spans_its_epochs_and_no_more() { + let period = preset::EPOCHS_PER_SYNC_COMMITTEE_PERIOD; + assert_eq!(compute_sync_committee_period(0), 0); + assert_eq!(compute_sync_committee_period(period - 1), 0); + assert_eq!(compute_sync_committee_period(period), 1); + assert_eq!(compute_sync_committee_period(3 * period + 1), 3); + } } diff --git a/crates/net/rpc/src/beacon/config.rs b/crates/net/rpc/src/beacon/config.rs index 3ddb354b..11babc7e 100644 --- a/crates/net/rpc/src/beacon/config.rs +++ b/crates/net/rpc/src/beacon/config.rs @@ -1,4 +1,4 @@ -//! `/eth/v1/config/spec`. +//! `/eth/v1/config/spec` and `/eth/v1/config/deposit_contract`. //! //! The Beacon API asks for three things in one flat object: the network's //! configuration, the preset the node was built against, and the @@ -29,7 +29,22 @@ use ethlambda_types::beacon::{config::Config, constants, preset, serde_helpers:: use serde_json::{Map, Value}; pub(crate) fn routes() -> Router { - Router::new().route("/eth/v1/config/spec", get(get_spec)) + Router::new() + .route("/eth/v1/config/spec", get(get_spec)) + .route("/eth/v1/config/deposit_contract", get(get_deposit_contract)) +} + +/// `GET /eth/v1/config/deposit_contract`: the network's deposit contract, off +/// the same `Config` `/eth/v1/config/spec` reports it from (as +/// `DEPOSIT_CHAIN_ID` and `DEPOSIT_CONTRACT_ADDRESS`). +async fn get_deposit_contract(State(store): State) -> Response { + let config = store.config(); + crate::json_response(serde_json::json!({ + "data": { + "chain_id": config.deposit_chain_id.to_string(), + "address": HexPrefixed(&config.deposit_contract_address).to_string(), + } + })) } async fn get_spec(State(store): State) -> Response { @@ -202,15 +217,14 @@ mod tests { use tower::ServiceExt as _; async fn get_spec_json() -> serde_json::Value { + get_json("/eth/v1/config/spec").await + } + + async fn get_json(uri: &str) -> serde_json::Value { let fixture = beacon_fixture(64); let app = routes().with_state(fixture.store); let response = app - .oneshot( - Request::builder() - .uri("/eth/v1/config/spec") - .body(Body::empty()) - .unwrap(), - ) + .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) .await .unwrap(); @@ -219,6 +233,18 @@ mod tests { serde_json::from_slice(&body).unwrap() } + /// The fixture's store is bootstrapped with `Config::mainnet()`, whose + /// deposit contract is the one on Ethereum mainnet. + #[tokio::test] + async fn the_deposit_contract_is_mainnets() { + let json = get_json("/eth/v1/config/deposit_contract").await; + assert_eq!(json["data"]["chain_id"], "1"); + assert_eq!( + json["data"]["address"], + "0x00000000219ab540356cbb839cbe05303d7705fa" + ); + } + #[tokio::test] async fn the_spec_is_screaming_snake_case_with_quoted_values() { let json = get_spec_json().await; diff --git a/crates/net/rpc/src/beacon/states.rs b/crates/net/rpc/src/beacon/states.rs index 47b0e449..c16bd85b 100644 --- a/crates/net/rpc/src/beacon/states.rs +++ b/crates/net/rpc/src/beacon/states.rs @@ -37,6 +37,7 @@ use crate::{ pub(crate) fn routes() -> Router { Router::new() .route("/eth/v2/debug/beacon/states/{state_id}", get(get_state)) + .route("/eth/v1/beacon/states/{state_id}/fork", get(get_fork)) .route( "/eth/v1/beacon/states/{state_id}/finality_checkpoints", get(get_finality_checkpoints), @@ -100,6 +101,20 @@ async fn get_state( with_consensus_version(response, fork) } +/// `GET /eth/v1/beacon/states/{state_id}/fork`: the `Fork` the state carries, +/// which is what a validator client builds its signing domains from. +async fn get_fork(Path(state_id): Path, State(store): State) -> Response { + let (root, state) = match load(&store, &state_id) { + Ok(found) => found, + Err(err) => return err.into_response(), + }; + crate::json_response(serde_json::json!({ + "execution_optimistic": store.is_beacon_optimistic(root), + "finalized": is_finalized(&store, state.slot()), + "data": state.fork(), + })) +} + async fn get_finality_checkpoints( Path(state_id): Path, State(store): State, @@ -439,6 +454,44 @@ mod tests { } } + #[tokio::test] + async fn the_fork_is_the_one_the_state_carries() { + let fixture = beacon_fixture(ANCHOR_SLOT); + let head_state = fixture + .store + .get_state(&fixture.head_root) + .unwrap() + .unwrap(); + let expected = serde_json::to_value(head_state.fork()).unwrap(); + + let response = get("/eth/v1/beacon/states/head/fork", None).await; + assert_eq!(response.status(), StatusCode::OK); + let json = body_json(response).await; + assert_eq!(json["data"], expected); + assert!( + json["data"]["epoch"].is_string(), + "the epoch must be quoted" + ); + assert!(json["execution_optimistic"].is_boolean()); + assert!(json["finalized"].is_boolean()); + } + + #[tokio::test] + async fn the_finalized_states_fork_is_marked_finalized() { + let response = get("/eth/v1/beacon/states/finalized/fork", None).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(body_json(response).await["finalized"], true); + } + + /// The same refusal every other state endpoint gives: state roots are not + /// indexed, so a `0x` id is a 404 rather than a guess. + #[tokio::test] + async fn a_fork_by_state_root_is_a_404() { + let root = format!("0x{}", "ab".repeat(32)); + let response = get(&format!("/eth/v1/beacon/states/{root}/fork"), None).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + mod validators { use super::*; use crate::test_utils::beacon_store_at; diff --git a/crates/net/rpc/src/beacon/validator.rs b/crates/net/rpc/src/beacon/validator.rs index abbbd95e..4b740688 100644 --- a/crates/net/rpc/src/beacon/validator.rs +++ b/crates/net/rpc/src/beacon/validator.rs @@ -14,6 +14,7 @@ use axum::{ response::{IntoResponse, Response}, routing::{get, post}, }; +use ethlambda_blockchain::{SyncStatusController, metrics::SyncStatus}; use ethlambda_storage::Store; use ethlambda_types::{ beacon::{ @@ -34,6 +35,7 @@ use ethlambda_state_transition::beacon::{ fork_choice::checkpoint_state, gossip::attestation::compute_subnet_for_attestation, helpers::accessors::{CommitteeCacheExt as _, get_block_root_at_slot}, + helpers::altair::compute_sync_committee_period, }; use crate::beacon::ApiError; @@ -48,6 +50,10 @@ pub(crate) fn routes() -> Router { "/eth/v1/validator/duties/attester/{epoch}", post(post_attester_duties), ) + .route( + "/eth/v1/validator/duties/sync/{epoch}", + post(post_sync_duties), + ) .route( "/eth/v1/validator/attestation_data", get(get_attestation_data), @@ -62,6 +68,101 @@ pub(crate) fn routes() -> Router { ) } +#[derive(Debug, Serialize)] +struct SyncDuty { + pubkey: BlsPubkey, + #[serde(with = "ethlambda_types::beacon::serde_helpers::quoted_or_bare")] + validator_index: ValidatorIndex, + /// Every position the validator holds in the committee, quoted. A + /// validator can hold more than one, since the committee is drawn with + /// replacement. + validator_sync_committee_indices: Vec, +} + +/// `POST /eth/v1/validator/duties/sync/{epoch}`. +/// +/// Answered from the head state: its `current_sync_committee` for an epoch in +/// the head's own sync committee period, its `next_sync_committee` for the +/// period after, which is as far ahead as the Beacon API allows. An earlier +/// period is refused rather than answered from a historical state, since a +/// validator client only ever asks about the current and next period. +/// +/// A requested validator that holds no seat is left out of `data`. The +/// answer is `503` while the node is syncing: the head state's committees are +/// not yet the chain's. +async fn post_sync_duties( + Path(epoch): Path, + State(store): State, + Extension(sync_status): Extension, + Json(indices): Json>, +) -> Response { + if sync_status.get() == SyncStatus::Syncing { + return ApiError::ServiceUnavailable("the node is syncing").into_response(); + } + match sync_duties(&store, &epoch, &indices) { + Ok(body) => crate::json_response(body), + Err(err) => err.into_response(), + } +} + +fn sync_duties( + store: &Store, + epoch: &str, + indices: &[String], +) -> Result { + let epoch = parse_epoch(epoch)?; + let indices = indices + .iter() + .map(|index| index.parse::()) + .collect::, _>>() + .map_err(|_| ApiError::BadRequest("invalid validator index"))?; + let (head_root, state) = head(store)?; + + let (current, next) = state + .sync_committees() + .map_err(|_| ApiError::BadRequest("sync committees start at altair"))?; + let head_period = compute_sync_committee_period(compute_epoch_at_slot(state.slot())); + let requested_period = compute_sync_committee_period(epoch); + let committee = if requested_period == head_period { + current + } else if requested_period == head_period + 1 { + next + } else { + return Err(ApiError::BadRequest( + "epoch is not in the head state's current or next sync committee period", + )); + }; + + // One pass over the committee rather than one per requested validator: + // the committee stores pubkeys, so that is what a validator is matched by. + let mut positions: HashMap> = HashMap::new(); + for (position, pubkey) in committee.pubkeys.iter().enumerate() { + positions + .entry(*pubkey) + .or_default() + .push(position.to_string()); + } + + let mut duties = Vec::new(); + for validator_index in indices { + let validator = state + .validator(validator_index) + .map_err(|_| ApiError::BadRequest("unknown validator index"))?; + if let Some(held) = positions.get(&validator.pubkey) { + duties.push(SyncDuty { + pubkey: validator.pubkey, + validator_index, + validator_sync_committee_indices: held.clone(), + }); + } + } + + Ok(serde_json::json!({ + "execution_optimistic": store.is_beacon_optimistic(head_root), + "data": duties, + })) +} + /// One entry of `beacon_committee_subscriptions`. Parsed so a malformed body /// is refused, though `validator_index` is never read. #[derive(Debug, Deserialize)] @@ -810,4 +911,145 @@ mod tests { .await; assert_eq!(status, StatusCode::BAD_REQUEST); } + + // --- duties/sync ----------------------------------------------------- + + mod sync_duties { + use super::*; + use ethlambda_types::beacon::containers::altair::SyncCommittee; + + /// A committee whose seat `i` belongs to validator `first + i % 8`, so + /// each of those eight holds `SYNC_COMMITTEE_SIZE / 8` seats and every + /// other validator holds none. + fn committee_of(state: &BeaconState, first: u64) -> SyncCommittee { + let pubkeys: Vec = (0..preset::SYNC_COMMITTEE_SIZE as u64) + .map(|seat| state.validator(first + seat % 8).unwrap().pubkey) + .collect(); + SyncCommittee { + aggregate_pubkey: pubkeys[0], + pubkeys: pubkeys.try_into().unwrap(), + } + } + + /// A fulu state whose current committee is validators 0-7 and whose + /// next committee is validators 8-15, so an answer drawn from the + /// wrong one shows up. + fn state_with_committees() -> BeaconState { + let mut state = fulu_state(); + let current = committee_of(&state, 0); + let next = committee_of(&state, 8); + let BeaconState::Fulu(fulu) = &mut state else { + unreachable!("built as fulu") + }; + fulu.current_sync_committee = current; + fulu.next_sync_committee = next; + state + } + + async fn post_sync( + state: BeaconState, + epoch: u64, + indices: &[&str], + sync_status: SyncStatusController, + ) -> (StatusCode, serde_json::Value) { + let (store, _root) = beacon_store_at(state); + let request = Request::post(format!("/eth/v1/validator/duties/sync/{epoch}")) + .header("content-type", "application/json") + .body(Body::from(serde_json::json!(indices).to_string())) + .unwrap(); + let app = routes().with_state(store).layer(Extension(sync_status)); + let response = app.oneshot(request).await.unwrap(); + let status = response.status(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + (status, serde_json::from_slice(&body).unwrap_or_default()) + } + + /// The seats `validator` holds in [`committee_of`]`(_, first)`. + fn seats(validator: u64, first: u64) -> Vec { + (0..preset::SYNC_COMMITTEE_SIZE as u64) + .filter(|seat| first + seat % 8 == validator) + .map(|seat| seat.to_string()) + .collect() + } + + #[tokio::test] + async fn the_current_period_reads_the_current_committee() { + let state = state_with_committees(); + let epoch = compute_epoch_at_slot(state.slot()); + let (status, json) = + post_sync(state.clone(), epoch, &["3", "9", "20"], Default::default()).await; + assert_eq!(status, StatusCode::OK); + + // Validator 3 sits in the current committee; 9 only in the next; + // 20 in neither, so only 3 is listed. + let duties = json["data"].as_array().unwrap(); + assert_eq!(duties.len(), 1); + assert_eq!(duties[0]["validator_index"], "3"); + let pubkey = state.validator(3).unwrap().pubkey; + assert_eq!(duties[0]["pubkey"], format!("0x{}", hex::encode(pubkey.0))); + assert_eq!( + duties[0]["validator_sync_committee_indices"], + serde_json::json!(seats(3, 0)) + ); + assert!(json["execution_optimistic"].is_boolean()); + } + + #[tokio::test] + async fn the_next_period_reads_the_next_committee() { + let state = state_with_committees(); + let next_period_epoch = preset::EPOCHS_PER_SYNC_COMMITTEE_PERIOD + * (compute_sync_committee_period(compute_epoch_at_slot(state.slot())) + 1); + let (status, json) = + post_sync(state, next_period_epoch, &["3", "9"], Default::default()).await; + assert_eq!(status, StatusCode::OK); + + let duties = json["data"].as_array().unwrap(); + assert_eq!(duties.len(), 1); + assert_eq!(duties[0]["validator_index"], "9"); + assert_eq!( + duties[0]["validator_sync_committee_indices"], + serde_json::json!(seats(9, 8)) + ); + } + + #[tokio::test] + async fn the_period_after_next_is_a_400() { + let state = state_with_committees(); + let period = compute_sync_committee_period(compute_epoch_at_slot(state.slot())); + let epoch = preset::EPOCHS_PER_SYNC_COMMITTEE_PERIOD * (period + 2); + let (status, _) = post_sync(state, epoch, &["3"], Default::default()).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + } + + /// An earlier period would need a historical state, which a validator + /// client never asks for; refused rather than answered wrongly. + #[tokio::test] + async fn an_earlier_period_is_a_400() { + let mut state = state_with_committees(); + let BeaconState::Fulu(fulu) = &mut state else { + unreachable!("built as fulu") + }; + fulu.slot = preset::EPOCHS_PER_SYNC_COMMITTEE_PERIOD * preset::SLOTS_PER_EPOCH; + let (status, _) = post_sync(state, 0, &["3"], Default::default()).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn an_unknown_validator_is_a_400() { + let state = state_with_committees(); + let epoch = compute_epoch_at_slot(state.slot()); + let unknown = (COUNT as u64).to_string(); + let (status, _) = post_sync(state, epoch, &[&unknown], Default::default()).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn a_syncing_node_answers_503() { + let state = state_with_committees(); + let epoch = compute_epoch_at_slot(state.slot()); + let syncing = SyncStatusController::new(SyncStatus::Syncing); + let (status, _) = post_sync(state, epoch, &["3"], syncing).await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + } + } } diff --git a/docs/rpc.md b/docs/rpc.md index cd0f2259..6d007e06 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -230,8 +230,10 @@ surface rather than sitting beside it; a `/lean/v0` path on a beacon node is a | `GET` | `/eth/v1/beacon/headers/{block_id}` | JSON | `SignedBeaconBlockHeader`, plus `canonical` | | `GET` | `/eth/v2/debug/beacon/states/{state_id}` | JSON or SSZ | `BeaconState` at `state_id` | | `GET` | `/eth/v1/beacon/states/{state_id}/finality_checkpoints` | JSON | That state's three checkpoints | +| `GET` | `/eth/v1/beacon/states/{state_id}/fork` | JSON | That state's `Fork`: previous and current version, and the epoch it changed | | `GET` | `/eth/v1/beacon/genesis` | JSON | Genesis time, validators root, fork version | | `GET` | `/eth/v1/config/spec` | JSON | The store's `Config`, plus `PRESET_BASE`, `CONFIG_NAME`, the preset and the constants (see below) | +| `GET` | `/eth/v1/config/deposit_contract` | JSON | The `Config`'s deposit chain id and contract address | | `GET` | `/eth/v1/node/syncing` | JSON | Head slot, sync distance, optimistic flag | | `GET` | `/eth/v1/node/health` | *(status only)* | `200` caught up, `206` syncing | | `GET` | `/eth/v1/node/version` | JSON | Client version string | @@ -239,6 +241,7 @@ surface rather than sitting beside it; a `/lean/v0` path on a beacon node is a | `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 | | `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` | | `POST` | `/eth/v2/beacon/pool/attestations` | *(status only)* | Validate and gossip `SingleAttestation`s | | `POST` | `/eth/v1/validator/beacon_committee_subscriptions` | *(status only)* | Aggregators' entries join their committee's subnet | @@ -260,6 +263,15 @@ the chain actor writes, so no request waits on the actor. 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. +- **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 + state; see `docs/spec_deviations.md`). A validator is matched by pubkey and + gets every seat it holds, since the committee is drawn with replacement; one + with no seat is left out. An unknown index is a `400`, and the endpoint is a + `503` while the node is syncing. This node serves no sync committee message + or contribution endpoint yet, so a validator client that gets duties here + cannot publish what they ask for. - **`attestation_data`** follows phase0's `validator.md`: the head block, the epoch's boundary block as target, and as source the current justified checkpoint of the head state advanced to the slot's epoch (through fork diff --git a/docs/spec_deviations.md b/docs/spec_deviations.md index 7ff2d24e..a6cf1ec2 100644 --- a/docs/spec_deviations.md +++ b/docs/spec_deviations.md @@ -99,6 +99,21 @@ rather than populated, which is not spec-valid. work. Everything that reads `peer_id` is unaffected. Out of scope for the change that added the Beacon API surface; a follow-up exposes the record. +## Sync duties are served for the current and next period only + +`POST /eth/v1/validator/duties/sync/{epoch}` answers an `epoch` in the head +state's own sync committee period or the next one, and refuses an earlier +period with a `400`. + +- **Beacon API:** allows any period up to the current one plus one, so an + earlier period is valid to ask about. +- **ethlambda:** the head state carries only `current_sync_committee` and + `next_sync_committee`. Answering an earlier period means loading the state + at the start of that period, which is what Lighthouse does; Prysm also + serves it. A validator client only asks about the current and next period, + so this refuses rather than add a historical state lookup to a duty + endpoint (`sync_duties`, `crates/net/rpc/src/beacon/validator.rs`). + ## `block_id` cannot name `genesis`, and `state_id` cannot be a state root Two id forms the Beacon API defines return `404` here. From d11635b31464fd422229e943972547128c9ff617 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:22:07 -0300 Subject: [PATCH 02/11] feat(types): read the sync committee containers from JSON The Beacon API's sync committee endpoints take SyncCommitteeMessage and SignedContributionAndProof bodies and answer with contributions, so the node and the validator client both need these containers in both directions. SYNC_SUBCOMMITTEE_SIZE names the subnet width the gossip seen caches, the pool and the validator client all index by. --- crates/common/types/src/beacon/constants.rs | 5 +- .../types/src/beacon/containers/altair.rs | 150 ++++++++++++++++-- 2 files changed, 142 insertions(+), 13 deletions(-) diff --git a/crates/common/types/src/beacon/constants.rs b/crates/common/types/src/beacon/constants.rs index 4bcd0d1f..171ddece 100644 --- a/crates/common/types/src/beacon/constants.rs +++ b/crates/common/types/src/beacon/constants.rs @@ -245,9 +245,8 @@ pub const SAFE_SLOTS_TO_IMPORT_OPTIMISTICALLY: u64 = 128; pub const TARGET_AGGREGATORS_PER_COMMITTEE: u64 = 16; /// The sync committee counterpart of [`TARGET_AGGREGATORS_PER_COMMITTEE`]: -/// how many aggregators the protocol aims for per sync subcommittee. Nothing -/// in this build aggregates sync committee messages, so the spec endpoint is -/// its only reader. +/// how many aggregators the protocol aims for per sync subcommittee, which +/// sets `is_sync_committee_aggregator`'s modulo. pub const TARGET_AGGREGATORS_PER_SYNC_SUBCOMMITTEE: u64 = 16; /// How many gossip subnets sync committee messages are split across, one diff --git a/crates/common/types/src/beacon/containers/altair.rs b/crates/common/types/src/beacon/containers/altair.rs index d968c113..c001d6c8 100644 --- a/crates/common/types/src/beacon/containers/altair.rs +++ b/crates/common/types/src/beacon/containers/altair.rs @@ -56,8 +56,15 @@ pub type SyncCommitteePubkeys = SszVector; +pub type SyncSubcommitteeBits = SszBitvector; + +/// Members per sync subcommittee: `SYNC_COMMITTEE_SIZE // SYNC_COMMITTEE_SUBNET_COUNT`, +/// the width of one [`SyncCommitteeContribution`]'s `aggregation_bits` and of +/// one `sync_committee_{subnet_id}` subnet's share of the committee. +pub const SYNC_SUBCOMMITTEE_SIZE: usize = + preset::SYNC_COMMITTEE_SIZE / constants::SYNC_COMMITTEE_SUBNET_COUNT; +// Seen caches pack a subcommittee's bits into a `u128`. +const _: () = assert!(SYNC_SUBCOMMITTEE_SIZE <= 128); // --------------------------------------------------------------------------- // Sync committees @@ -70,10 +77,19 @@ pub type SyncSubcommitteeBits = /// needs one regardless of how many attestations or other operations it /// includes. #[derive( - Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, + Debug, + Clone, + Default, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, )] pub struct SyncAggregate { - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex")] pub sync_committee_bits: SyncCommitteeBits, /// The aggregate of every signature from a member set in /// `sync_committee_bits`, over the previous slot's block root. @@ -264,7 +280,16 @@ pub struct BeaconState { /// committee member gossips one of these every slot, and an aggregator /// combines a subcommittee's worth into a [`SyncCommitteeContribution`]. #[derive( - Debug, Clone, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, + Debug, + Clone, + Default, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, )] pub struct SyncCommitteeMessage { #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] @@ -282,14 +307,24 @@ pub struct SyncCommitteeMessage { /// `SYNC_COMMITTEE_SUBNET_COUNT` aggregators work in parallel on disjoint /// slices of the committee, the same way phase0 attestation aggregation is /// scoped to one committee rather than the whole active set. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] pub struct SyncCommitteeContribution { #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] pub slot: Slot, pub beacon_block_root: Root, #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] pub subcommittee_index: u64, - #[serde(serialize_with = "crate::beacon::serde_helpers::ssz_hex::serialize")] + #[serde(with = "crate::beacon::serde_helpers::ssz_hex")] pub aggregation_bits: SyncSubcommitteeBits, /// The aggregate signature of every member set in `aggregation_bits`, over /// `beacon_block_root`. @@ -300,7 +335,17 @@ pub struct SyncCommitteeContribution { /// selected to produce it. /// /// The sync committee analogue of phase0's `AggregateAndProof`. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] pub struct ContributionAndProof { #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] pub aggregator_index: ValidatorIndex, @@ -310,7 +355,17 @@ pub struct ContributionAndProof { pub selection_proof: BlsSignature, } -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot)] +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, +)] pub struct SignedContributionAndProof { pub message: ContributionAndProof, pub signature: BlsSignature, @@ -322,7 +377,17 @@ pub struct SignedContributionAndProof { /// Separate from [`ContributionAndProof::selection_proof`]'s signature target /// only in name: this is the unsigned message that signature covers. #[derive( - Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, SszEncode, SszDecode, HashTreeRoot, + Debug, + Clone, + Copy, + Default, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + SszEncode, + SszDecode, + HashTreeRoot, )] pub struct SyncAggregatorSelectionData { #[serde(with = "crate::beacon::serde_helpers::quoted_or_bare")] @@ -377,4 +442,69 @@ mod tests { let bytes = body.to_ssz(); assert_eq!(BeaconBlockBody::from_ssz_bytes(&bytes).unwrap(), body); } + + /// Serializes `value` and reads it back, returning the JSON for shape checks. + fn json_round_trip(value: &T) -> serde_json::Value + where + T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug, + { + let json = serde_json::to_value(value).unwrap(); + let back: T = serde_json::from_value(json.clone()).unwrap(); + assert_eq!(&back, value); + json + } + + #[test] + fn sync_committee_containers_round_trip_through_json() { + // The Beacon API carries these as JSON in both directions: quoted + // integers, and bitvectors as `0x` hex of their SSZ bytes. + let mut aggregation_bits = SyncSubcommitteeBits::default(); + aggregation_bits.set(0, true).unwrap(); + aggregation_bits + .set(SYNC_SUBCOMMITTEE_SIZE - 1, true) + .unwrap(); + let contribution = SyncCommitteeContribution { + slot: 7, + beacon_block_root: Root::repeat_byte(0x11), + subcommittee_index: 3, + aggregation_bits, + signature: BlsSignature([0x22; 96]), + }; + let json = json_round_trip(&contribution); + assert_eq!(json["slot"], "7"); + assert_eq!(json["subcommittee_index"], "3"); + let bits = json["aggregation_bits"].as_str().unwrap(); + assert_eq!(bits.len(), 2 + 2 * SYNC_SUBCOMMITTEE_SIZE / 8); + + let message = SyncCommitteeMessage { + slot: 9, + beacon_block_root: Root::repeat_byte(0x33), + validator_index: 42, + signature: BlsSignature([0x44; 96]), + }; + assert_eq!(json_round_trip(&message)["validator_index"], "42"); + + let signed = SignedContributionAndProof { + message: ContributionAndProof { + aggregator_index: 5, + contribution, + selection_proof: BlsSignature([0x55; 96]), + }, + signature: BlsSignature([0x66; 96]), + }; + json_round_trip(&signed); + json_round_trip(&SyncAggregatorSelectionData { + slot: 1, + subcommittee_index: 2, + }); + + let mut sync_committee_bits = SyncCommitteeBits::default(); + sync_committee_bits + .set(preset::SYNC_COMMITTEE_SIZE - 1, true) + .unwrap(); + json_round_trip(&SyncAggregate { + sync_committee_bits, + sync_committee_signature: BlsSignature([0x77; 96]), + }); + } } From 1aec351d5457d34f2df404c8c4a270cb2083820f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:40:32 -0300 Subject: [PATCH 03/11] feat(beacon): sync committee helpers and a pool for block production Sync committee gossip and block packing both need the committee a message belongs to, its signing roots and somewhere to hold validated messages. The helpers key the committee by the message's slot and the domain by the fork schedule, so a head that lags across a period or fork boundary does not reject honest messages. The pool keeps messages per position and the best contribution per subcommittee, and builds the block's aggregate. --- .../src/beacon/helpers/mod.rs | 1 + .../src/beacon/helpers/sync_committee.rs | 343 +++++++++++++ .../state_transition/src/beacon/mod.rs | 1 + .../src/beacon/sync_committee_pool.rs | 452 ++++++++++++++++++ 4 files changed, 797 insertions(+) create mode 100644 crates/blockchain/state_transition/src/beacon/helpers/sync_committee.rs create mode 100644 crates/blockchain/state_transition/src/beacon/sync_committee_pool.rs diff --git a/crates/blockchain/state_transition/src/beacon/helpers/mod.rs b/crates/blockchain/state_transition/src/beacon/helpers/mod.rs index fe79b80b..60955289 100644 --- a/crates/blockchain/state_transition/src/beacon/helpers/mod.rs +++ b/crates/blockchain/state_transition/src/beacon/helpers/mod.rs @@ -24,5 +24,6 @@ pub mod misc; pub mod mutators; pub mod predicates; pub mod shuffling; +pub mod sync_committee; #[cfg(any(test, feature = "test-utils"))] pub mod test_state; diff --git a/crates/blockchain/state_transition/src/beacon/helpers/sync_committee.rs b/crates/blockchain/state_transition/src/beacon/helpers/sync_committee.rs new file mode 100644 index 00000000..7f098ff4 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/helpers/sync_committee.rs @@ -0,0 +1,343 @@ +//! Sync committee lookups and signing roots for the gossip rules, the pool and +//! block production. +//! +//! The specification's own helpers (`p2p-interface.md` +//! `get_sync_subcommittee_pubkeys`, `validator.md` +//! `compute_subnets_for_sync_committee`) pick the committee by +//! `state.slot + 1`. These pick it by the *message's* slot instead, and take +//! signing domains from [`Config`]'s fork schedule instead of `state.fork`. +//! When the head state is in the message's period and fork the answers are the +//! specification's; when the head lags across a period or fork boundary the +//! specification would reject honest messages and these do not. See +//! `docs/spec_deviations.md`. + +use std::collections::BTreeSet; + +use ethlambda_types::beacon::containers::altair::{ + ContributionAndProof, SyncAggregatorSelectionData, +}; + +pub use super::altair::compute_sync_committee_period; +pub use ethlambda_types::beacon::containers::altair::SYNC_SUBCOMMITTEE_SIZE; + +use super::accessors::get_current_epoch; +use super::math::bytes_to_uint64; +use super::misc::{compute_domain, compute_epoch_at_slot, compute_signing_root}; +use crate::beacon::config::Config; +use crate::beacon::constants::{ + DOMAIN_CONTRIBUTION_AND_PROOF, DOMAIN_SYNC_COMMITTEE, DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF, + SYNC_COMMITTEE_SUBNET_COUNT, TARGET_AGGREGATORS_PER_SYNC_SUBCOMMITTEE, +}; +use crate::beacon::containers::{BeaconState, altair::SyncCommittee}; +use crate::beacon::error::{Error, Result, verify}; +use crate::beacon::hash::hash; +use crate::beacon::primitives::{ + BlsPubkey, BlsSignature, Domain, DomainType, HashTreeRoot as _, Root, Slot, ValidatorIndex, +}; + +/// The committee whose members sign at `slot` (for inclusion at `slot + 1`): +/// `state.current_sync_committee` when the period of `slot + 1`'s epoch is the +/// head's own, `next_sync_committee` when it is the one after, else an error. +/// A pre-altair state has no committee and also errors. +pub fn sync_committee_for_slot(state: &BeaconState, slot: Slot) -> Result<&SyncCommittee> { + let (current, next) = state.sync_committees()?; + let signing_period = compute_sync_committee_period(compute_epoch_at_slot(slot + 1)); + let state_period = compute_sync_committee_period(get_current_epoch(state)); + if signing_period == state_period { + Ok(current) + } else if signing_period == state_period + 1 { + Ok(next) + } else { + Err(Error::SpecAssert( + "the state's sync committees cover the slot's period", + )) + } +} + +/// Every `(subcommittee_index, index_within_subcommittee)` seat `pubkey` +/// holds, ascending. One pass over the committee; repeats are kept, since the +/// committee is drawn with replacement. +pub fn sync_committee_seats(committee: &SyncCommittee, pubkey: &BlsPubkey) -> Vec<(u64, usize)> { + committee + .pubkeys + .iter() + .enumerate() + .filter(|(_, candidate)| *candidate == pubkey) + .map(|(position, _)| { + ( + (position / SYNC_SUBCOMMITTEE_SIZE) as u64, + position % SYNC_SUBCOMMITTEE_SIZE, + ) + }) + .collect() +} + +/// `validator.md`'s `compute_subnets_for_sync_committee`, by message slot. +pub fn compute_subnets_for_sync_committee( + state: &BeaconState, + slot: Slot, + validator_index: ValidatorIndex, +) -> Result> { + let committee = sync_committee_for_slot(state, slot)?; + let pubkey = state.validator(validator_index)?.pubkey; + Ok(sync_committee_seats(committee, &pubkey) + .into_iter() + .map(|(subnet, _)| subnet) + .collect()) +} + +/// `p2p-interface.md`'s `get_sync_subcommittee_pubkeys`, by message slot. +pub fn get_sync_subcommittee_pubkeys( + state: &BeaconState, + slot: Slot, + subcommittee_index: u64, +) -> Result<&[BlsPubkey]> { + verify( + subcommittee_index < SYNC_COMMITTEE_SUBNET_COUNT as u64, + "subcommittee_index < SYNC_COMMITTEE_SUBNET_COUNT", + )?; + let committee = sync_committee_for_slot(state, slot)?; + let start = subcommittee_index as usize * SYNC_SUBCOMMITTEE_SIZE; + Ok(&committee.pubkeys[start..start + SYNC_SUBCOMMITTEE_SIZE]) +} + +/// `validator.md`'s `is_sync_committee_aggregator`. +pub fn is_sync_committee_aggregator(selection_proof: &BlsSignature) -> bool { + let modulo = (SYNC_SUBCOMMITTEE_SIZE as u64 / TARGET_AGGREGATORS_PER_SYNC_SUBCOMMITTEE).max(1); + let digest = hash(selection_proof.as_ref()); + bytes_to_uint64(&digest.0[0..8]).is_multiple_of(modulo) +} + +/// `get_domain` for a sync committee signature at `slot`, from `config`'s +/// schedule rather than `state.fork`. +pub fn sync_committee_domain( + config: &Config, + genesis_validators_root: Root, + domain_type: DomainType, + slot: Slot, +) -> Domain { + let fork = config.fork_at_epoch(compute_epoch_at_slot(slot)); + compute_domain( + domain_type, + config.fork_version(fork), + genesis_validators_root, + ) +} + +/// What a sync committee member signs: `beacon_block_root` under +/// `DOMAIN_SYNC_COMMITTEE` at `slot`. +pub fn sync_committee_message_signing_root( + config: &Config, + genesis_validators_root: Root, + slot: Slot, + beacon_block_root: Root, +) -> Root { + let domain = + sync_committee_domain(config, genesis_validators_root, DOMAIN_SYNC_COMMITTEE, slot); + compute_signing_root(beacon_block_root, domain) +} + +/// What an aggregator signs to prove it was selected: a +/// [`SyncAggregatorSelectionData`] under `DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF`. +pub fn sync_selection_proof_signing_root( + config: &Config, + genesis_validators_root: Root, + slot: Slot, + subcommittee_index: u64, +) -> Root { + let domain = sync_committee_domain( + config, + genesis_validators_root, + DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF, + slot, + ); + let data = SyncAggregatorSelectionData { + slot, + subcommittee_index, + }; + compute_signing_root(data.hash_tree_root(), domain) +} + +/// What an aggregator signs over its [`ContributionAndProof`], under +/// `DOMAIN_CONTRIBUTION_AND_PROOF` at the contribution's slot. +pub fn contribution_and_proof_signing_root( + config: &Config, + genesis_validators_root: Root, + message: &ContributionAndProof, +) -> Root { + let domain = sync_committee_domain( + config, + genesis_validators_root, + DOMAIN_CONTRIBUTION_AND_PROOF, + message.contribution.slot, + ); + compute_signing_root(message.hash_tree_root(), domain) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::beacon::fork::ForkName; + use crate::beacon::helpers::test_state::{secret_key_for, with_signing_validators_at}; + use crate::beacon::preset; + + /// A fulu state with `validators` signing validators, one epoch in. The + /// current committee seats validator `position % validators`, the next one + /// `(position + 1) % validators`, so the two differ at every position. + pub(crate) fn state_with_committees(validators: usize) -> BeaconState { + let mut state = with_signing_validators_at(ForkName::Fulu, validators); + let pubkey = |index: usize| BlsPubkey(secret_key_for(index).sk_to_pk().to_bytes()); + let committee = |shift: usize| SyncCommittee { + pubkeys: (0..preset::SYNC_COMMITTEE_SIZE) + .map(|position| pubkey((position + shift) % validators)) + .collect::>() + .try_into() + .expect("built at the committee's exact length"), + aggregate_pubkey: Default::default(), + }; + let (current, next) = state.sync_committees_mut().expect("fulu has committees"); + *current = committee(0); + *next = committee(1); + state.apply_pending_mutations(); + state + } + + fn last_slot_of_period_zero() -> Slot { + preset::SLOTS_PER_EPOCH * preset::EPOCHS_PER_SYNC_COMMITTEE_PERIOD - 1 + } + + #[test] + fn the_committee_is_current_within_the_period_and_next_at_its_last_slot() { + let state = state_with_committees(8); + let (current, next) = state.sync_committees().unwrap(); + assert_eq!(sync_committee_for_slot(&state, 5).unwrap(), current); + assert_eq!( + sync_committee_for_slot(&state, last_slot_of_period_zero() - 1).unwrap(), + current + ); + assert_eq!( + sync_committee_for_slot(&state, last_slot_of_period_zero()).unwrap(), + next + ); + } + + #[test] + fn a_slot_two_periods_ahead_or_a_pre_altair_state_has_no_committee() { + let state = state_with_committees(8); + let far = 2 * preset::SLOTS_PER_EPOCH * preset::EPOCHS_PER_SYNC_COMMITTEE_PERIOD; + assert!(sync_committee_for_slot(&state, far).is_err()); + let phase0 = crate::beacon::helpers::test_state::with_validators(4); + assert!(sync_committee_for_slot(&phase0, 5).is_err()); + } + + #[test] + fn seats_keep_repeats_and_subnets_deduplicate() { + let state = state_with_committees(2); + let committee = sync_committee_for_slot(&state, 5).unwrap(); + let pubkey = state.validator(0).unwrap().pubkey; + let seats = sync_committee_seats(committee, &pubkey); + // Validator 0 holds every even position of a committee drawn from two. + assert_eq!(seats.len(), preset::SYNC_COMMITTEE_SIZE / 2); + assert_eq!(seats[0], (0, 0)); + assert_eq!(seats[1], (0, 2)); + assert!(seats.windows(2).all(|pair| pair[0] < pair[1])); + let subnets = compute_subnets_for_sync_committee(&state, 5, 0).unwrap(); + assert_eq!( + subnets, + (0..SYNC_COMMITTEE_SUBNET_COUNT as u64).collect::>() + ); + assert!(compute_subnets_for_sync_committee(&state, 5, 99).is_err()); + } + + #[test] + fn a_subcommittee_is_its_slice_of_the_committee() { + let state = state_with_committees(8); + let committee = sync_committee_for_slot(&state, 5).unwrap(); + for subcommittee in 0..SYNC_COMMITTEE_SUBNET_COUNT as u64 { + let start = subcommittee as usize * SYNC_SUBCOMMITTEE_SIZE; + assert_eq!( + get_sync_subcommittee_pubkeys(&state, 5, subcommittee).unwrap(), + &committee.pubkeys[start..start + SYNC_SUBCOMMITTEE_SIZE] + ); + } + assert!(get_sync_subcommittee_pubkeys(&state, 5, 4).is_err()); + } + + /// A signature-shaped value whose hash is picked by `seed`. + fn signature_with(seed: u64) -> BlsSignature { + let mut bytes = [0u8; 96]; + bytes[..8].copy_from_slice(&seed.to_le_bytes()); + BlsSignature(bytes) + } + + #[test] + fn the_aggregator_modulo_is_sized_from_the_subcommittee_and_read_little_endian() { + let modulo = + (SYNC_SUBCOMMITTEE_SIZE as u64 / TARGET_AGGREGATORS_PER_SYNC_SUBCOMMITTEE).max(1); + let mut selected = 0; + let total = 4000u64; + for seed in 0..total { + let signature = signature_with(seed); + let digest = hash(signature.as_ref()); + let expected = u64::from_le_bytes(digest.0[0..8].try_into().unwrap()) % modulo == 0; + assert_eq!(is_sync_committee_aggregator(&signature), expected); + selected += u64::from(expected); + } + // About one in `modulo` is selected. + let expected = total / modulo; + assert!( + selected > expected / 2 && selected < expected * 2, + "{selected}" + ); + } + + #[test] + fn the_domain_follows_the_schedule_while_the_state_fork_lags() { + // Fulu from epoch 10, whatever a state's own `fork` still says. + let config = Config::mainnet().with_fork_epoch(ForkName::Fulu, 10); + let gvr = Root::repeat_byte(3); + let before = sync_committee_domain( + &config, + gvr, + DOMAIN_SYNC_COMMITTEE, + 9 * preset::SLOTS_PER_EPOCH, + ); + let after = sync_committee_domain( + &config, + gvr, + DOMAIN_SYNC_COMMITTEE, + 10 * preset::SLOTS_PER_EPOCH, + ); + assert_ne!(before, after); + assert_eq!( + after, + compute_domain( + DOMAIN_SYNC_COMMITTEE, + config.fork_version(ForkName::Fulu), + gvr + ) + ); + assert_eq!( + before, + compute_domain( + DOMAIN_SYNC_COMMITTEE, + config.fork_version(config.fork_at_epoch(9)), + gvr + ) + ); + } + + #[test] + fn the_signing_roots_differ_by_object() { + let config = Config::mainnet(); + let gvr = Root::ZERO; + let message_root = + sync_committee_message_signing_root(&config, gvr, 5, Root::repeat_byte(1)); + let selection = sync_selection_proof_signing_root(&config, gvr, 5, 1); + assert_ne!(message_root, selection); + assert_ne!( + selection, + sync_selection_proof_signing_root(&config, gvr, 5, 2) + ); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/mod.rs b/crates/blockchain/state_transition/src/beacon/mod.rs index 1a4471fa..9953cee5 100644 --- a/crates/blockchain/state_transition/src/beacon/mod.rs +++ b/crates/blockchain/state_transition/src/beacon/mod.rs @@ -83,6 +83,7 @@ pub mod kzg; pub mod payload_attestation_pool; pub mod precheck; pub mod stf; +pub mod sync_committee_pool; pub mod upgrade; mod lean_boundary; diff --git a/crates/blockchain/state_transition/src/beacon/sync_committee_pool.rs b/crates/blockchain/state_transition/src/beacon/sync_committee_pool.rs new file mode 100644 index 00000000..93c81062 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/sync_committee_pool.rs @@ -0,0 +1,452 @@ +//! Sync committee messages and contributions, held until a proposer packs them. +//! +//! Altair `validator.md` ("Sync committee"): the proposer of slot `N` builds +//! its `SyncAggregate` from what the committee signed at slot `N - 1` over its +//! parent block's root. Members gossip a `SyncCommitteeMessage` per subnet; +//! aggregators combine a subcommittee's worth into a +//! `SyncCommitteeContribution`. This pool keeps both, keyed by +//! `(slot, beacon_block_root, subcommittee_index)`. +//! +//! Only objects that already passed validation go in (gossip's verdicts, or the +//! Beacon API's endpoints after the same checks). That is what makes combining +//! their signatures safe: one bad share fails the whole aggregate, and with it +//! the block that carries it. +//! +//! Block production reads [`SyncCommitteePool::sync_aggregate`] and verifies +//! its answer again (`block_production::verified_sync_aggregate`) before use. + +use std::{ + collections::{BTreeMap, HashMap}, + sync::{Arc, Mutex}, +}; + +use ethlambda_types::beacon::{ + constants::SYNC_COMMITTEE_SUBNET_COUNT, + containers::altair::{ + SYNC_SUBCOMMITTEE_SIZE, SyncAggregate, SyncCommitteeContribution, SyncCommitteeMessage, + }, + primitives::{BlsSignature, Root, Slot}, +}; + +use super::bls; + +/// The pool, shared between whatever fills it (the sync committee gossip +/// verdicts, the Beacon API's endpoints) and what reads it (block production, +/// `GET /eth/v1/validator/sync_committee_contribution`). There must be exactly +/// one per node. +pub type SharedSyncCommitteePool = Arc>; + +/// How many slots before the newest one the pool still holds. A block at slot +/// `N` reads slot `N - 1`, so only a little slack for clock skew is needed. +pub const RETAINED_SLOTS: u64 = 4; + +/// `(beacon_block_root, subcommittee_index)`: what one contribution covers +/// within a slot. +type Key = (Root, u64); + +#[derive(Debug, Default)] +pub struct SyncCommitteePool { + /// slot -> key -> a signature slot per position of the subcommittee. + messages: BTreeMap>>>, + /// slot -> key -> the held contribution with the most participants. + contributions: BTreeMap>, +} + +impl SyncCommitteePool { + /// Record a validated message at each of its `seats` (`(subcommittee, + /// position)` pairs). A validator with several seats fills each one, so + /// its signature is aggregated once per bit, as `validator.md` ("Signature") + /// requires. Keeps the first signature per position, and drops every slot + /// more than [`RETAINED_SLOTS`] before the message's own. Returns `true` + /// when any position was new. + pub fn insert_message( + &mut self, + message: &SyncCommitteeMessage, + seats: &[(u64, usize)], + ) -> bool { + self.prune_before(message.slot.saturating_sub(RETAINED_SLOTS)); + let by_key = self.messages.entry(message.slot).or_default(); + let mut inserted = false; + for &(subcommittee, position) in seats { + if subcommittee >= SYNC_COMMITTEE_SUBNET_COUNT as u64 + || position >= SYNC_SUBCOMMITTEE_SIZE + { + continue; + } + let signatures = by_key + .entry((message.beacon_block_root, subcommittee)) + .or_insert_with(|| vec![None; SYNC_SUBCOMMITTEE_SIZE]); + if signatures[position].is_none() { + signatures[position] = Some(message.signature); + inserted = true; + } + } + inserted + } + + /// Record a validated contribution. Replaces the held one for its key only + /// when it has strictly more participants. Returns whether it was kept. + pub fn insert_contribution(&mut self, contribution: SyncCommitteeContribution) -> bool { + self.prune_before(contribution.slot.saturating_sub(RETAINED_SLOTS)); + if contribution.subcommittee_index >= SYNC_COMMITTEE_SUBNET_COUNT as u64 { + return false; + } + let by_key = self.contributions.entry(contribution.slot).or_default(); + let key = ( + contribution.beacon_block_root, + contribution.subcommittee_index, + ); + match by_key.get(&key) { + Some(held) + if held.aggregation_bits.count_ones() + >= contribution.aggregation_bits.count_ones() => + { + false + } + _ => { + by_key.insert(key, contribution); + true + } + } + } + + /// The best contribution for `(slot, beacon_block_root, subcommittee_index)`: + /// the held one extended by every pooled message at a position it does not + /// set, or the messages alone when no contribution is held. `None` when + /// neither exists. When the signatures cannot be combined, the held + /// contribution alone. + pub fn contribution( + &self, + slot: Slot, + beacon_block_root: Root, + subcommittee_index: u64, + ) -> Option { + let key = (beacon_block_root, subcommittee_index); + let held = self + .contributions + .get(&slot) + .and_then(|by_key| by_key.get(&key)); + let messages = self.messages.get(&slot).and_then(|by_key| by_key.get(&key)); + + let mut contribution = held.cloned().unwrap_or_else(|| SyncCommitteeContribution { + slot, + beacon_block_root, + subcommittee_index, + aggregation_bits: Default::default(), + signature: BlsSignature::default(), + }); + let mut signatures: Vec = + held.map(|held| held.signature).into_iter().collect(); + let held_signatures = signatures.len(); + for (position, signature) in messages.into_iter().flatten().enumerate() { + let Some(signature) = signature else { continue }; + if contribution.aggregation_bits.get(position).unwrap_or(true) { + continue; + } + contribution + .aggregation_bits + .set(position, true) + .expect("the position is below the subcommittee size"); + signatures.push(*signature); + } + if signatures.is_empty() { + return None; + } + if signatures.len() > held_signatures { + match bls::aggregate(&signatures) { + Ok(signature) => contribution.signature = signature, + // A pooled signature that fails to combine is not expected, since + // everything pooled was verified. Fall back to what was held. + Err(_) => return held.cloned(), + } + } + Some(contribution) + } + + /// The aggregate for a block at `slot + 1` whose parent is + /// `beacon_block_root`: per subcommittee, [`Self::contribution`]'s bits at + /// `subcommittee * SYNC_SUBCOMMITTEE_SIZE + i`, the signatures combined. + /// `None` when no bit is set, or when the signatures cannot be combined. + pub fn sync_aggregate(&self, slot: Slot, beacon_block_root: Root) -> Option { + let mut bits = ::default().sync_committee_bits; + let mut signatures = Vec::new(); + for subcommittee in 0..SYNC_COMMITTEE_SUBNET_COUNT as u64 { + let Some(contribution) = self.contribution(slot, beacon_block_root, subcommittee) + else { + continue; + }; + let mut any = false; + for position in 0..SYNC_SUBCOMMITTEE_SIZE { + if contribution.aggregation_bits.get(position).unwrap_or(false) { + bits.set( + subcommittee as usize * SYNC_SUBCOMMITTEE_SIZE + position, + true, + ) + .expect("the position is below the committee size"); + any = true; + } + } + if any { + signatures.push(contribution.signature); + } + } + if signatures.is_empty() { + return None; + } + let sync_committee_signature = bls::aggregate(&signatures).ok()?; + Some(SyncAggregate { + sync_committee_bits: bits, + sync_committee_signature, + }) + } + + /// Drop every slot before `slot`. + pub fn prune_before(&mut self, slot: Slot) { + self.messages = self.messages.split_off(&slot); + self.contributions = self.contributions.split_off(&slot); + } +} + +#[cfg(test)] +mod tests { + use ethlambda_types::beacon::primitives::{BlsPubkey, ValidatorIndex}; + + use super::*; + use crate::beacon::bls::eth_fast_aggregate_verify; + use crate::beacon::config::Config; + use crate::beacon::helpers::sync_committee::tests::state_with_committees; + use crate::beacon::helpers::sync_committee::{ + sync_committee_for_slot, sync_committee_message_signing_root, sync_committee_seats, + }; + use crate::beacon::helpers::test_state::sign_for; + + const SLOT: Slot = 5; + + fn root() -> Root { + Root::repeat_byte(9) + } + + /// Validator `index`'s message for `SLOT` over `beacon_block_root`, with the + /// seats it holds in the state's committee. + fn message_for( + state: ðlambda_types::beacon::containers::BeaconState, + index: ValidatorIndex, + beacon_block_root: Root, + ) -> (SyncCommitteeMessage, Vec<(u64, usize)>) { + let signing_root = sync_committee_message_signing_root( + &Config::mainnet(), + state.genesis_validators_root(), + SLOT, + beacon_block_root, + ); + let message = SyncCommitteeMessage { + slot: SLOT, + beacon_block_root, + validator_index: index, + signature: sign_for(index as usize, signing_root), + }; + let pubkey = state.validator(index).unwrap().pubkey; + let seats = sync_committee_seats(sync_committee_for_slot(state, SLOT).unwrap(), &pubkey); + (message, seats) + } + + fn pubkeys_of( + state: ðlambda_types::beacon::containers::BeaconState, + subcommittee: u64, + bits: ðlambda_types::beacon::containers::altair::SyncSubcommitteeBits, + ) -> Vec { + let committee = sync_committee_for_slot(state, SLOT).unwrap(); + (0..SYNC_SUBCOMMITTEE_SIZE) + .filter(|&i| bits.get(i).unwrap()) + .map(|i| committee.pubkeys[subcommittee as usize * SYNC_SUBCOMMITTEE_SIZE + i]) + .collect() + } + + #[test] + fn a_position_keeps_its_first_signature() { + let state = state_with_committees(SYNC_COMMITTEE_SIZE_FOR_TESTS); + let mut pool = SyncCommitteePool::default(); + let (message, seats) = message_for(&state, 3, root()); + assert!(pool.insert_message(&message, &seats)); + assert!(!pool.insert_message(&message, &seats)); + let mut later = message.clone(); + later.signature = sign_for(4, Root::ZERO); + assert!(!pool.insert_message(&later, &seats)); + let contribution = pool.contribution(SLOT, root(), seats[0].0).unwrap(); + assert_eq!(contribution.signature, message.signature); + } + + #[test] + fn a_validator_with_several_seats_fills_each_and_signs_once_per_bit() { + let state = state_with_committees(2); + let mut pool = SyncCommitteePool::default(); + let (message, seats) = message_for(&state, 0, root()); + assert!(seats.len() > 1); + assert!(pool.insert_message(&message, &seats)); + let mut total_bits = 0; + for subcommittee in 0..SYNC_COMMITTEE_SUBNET_COUNT as u64 { + let contribution = pool.contribution(SLOT, root(), subcommittee).unwrap(); + let pubkeys = pubkeys_of(&state, subcommittee, &contribution.aggregation_bits); + total_bits += pubkeys.len(); + let signing_root = sync_committee_message_signing_root( + &Config::mainnet(), + state.genesis_validators_root(), + SLOT, + root(), + ); + assert!(eth_fast_aggregate_verify( + &pubkeys, + signing_root, + &contribution.signature + )); + } + assert_eq!(total_bits, seats.len()); + } + + #[test] + fn the_contribution_with_more_participants_is_kept() { + let state = state_with_committees(SYNC_COMMITTEE_SIZE_FOR_TESTS); + let (first, seats_first) = message_for(&state, 0, root()); + let (second, seats_second) = message_for(&state, 1, root()); + let subcommittee = seats_first[0].0; + let one = pool_contribution(&[(&first, &seats_first)], subcommittee); + let two = pool_contribution( + &[(&first, &seats_first), (&second, &seats_second)], + subcommittee, + ); + let mut pool = SyncCommitteePool::default(); + assert!(pool.insert_contribution(one.clone())); + assert!(!pool.insert_contribution(one.clone())); + assert!(pool.insert_contribution(two.clone())); + assert!(!pool.insert_contribution(one)); + assert_eq!(pool.contribution(SLOT, root(), subcommittee), Some(two)); + } + + /// The contribution a pool holding only `messages` would offer. + fn pool_contribution( + messages: &[(&SyncCommitteeMessage, &Vec<(u64, usize)>)], + subcommittee: u64, + ) -> SyncCommitteeContribution { + let mut pool = SyncCommitteePool::default(); + for (message, seats) in messages { + pool.insert_message(message, seats); + } + pool.contribution(SLOT, messages[0].0.beacon_block_root, subcommittee) + .unwrap() + } + + #[test] + fn a_contribution_is_extended_by_messages_at_uncovered_positions() { + // Committee of 512 seats drawn from 512 validators: validator v holds + // position v, so subcommittee 0 holds validators 0..128. + let state = state_with_committees(SYNC_COMMITTEE_SIZE_FOR_TESTS); + let (a, seats_a) = message_for(&state, 0, root()); + let (b, seats_b) = message_for(&state, 1, root()); + let (c, seats_c) = message_for(&state, 2, root()); + assert_eq!(seats_a[0].0, seats_b[0].0); + let subcommittee = seats_a[0].0; + + // The held contribution covers `a` and `b`; `c` arrives as a message. + let held = { + let mut helper = SyncCommitteePool::default(); + helper.insert_message(&a, &seats_a); + helper.insert_message(&b, &seats_b); + helper.contribution(SLOT, root(), subcommittee).unwrap() + }; + let mut pool = SyncCommitteePool::default(); + assert!(pool.insert_contribution(held)); + // `a` is already covered by the contribution: a duplicate position. + pool.insert_message(&a, &seats_a); + pool.insert_message(&c, &seats_c); + + let best = pool.contribution(SLOT, root(), subcommittee).unwrap(); + assert_eq!(best.aggregation_bits.count_ones(), 3); + let pubkeys = pubkeys_of(&state, subcommittee, &best.aggregation_bits); + let signing_root = sync_committee_message_signing_root( + &Config::mainnet(), + state.genesis_validators_root(), + SLOT, + root(), + ); + assert!(eth_fast_aggregate_verify( + &pubkeys, + signing_root, + &best.signature + )); + } + + /// The committee size of the preset the tests build, as a validator count. + const SYNC_COMMITTEE_SIZE_FOR_TESTS: usize = + ethlambda_types::beacon::preset::SYNC_COMMITTEE_SIZE; + + #[test] + fn the_aggregate_offsets_bits_per_subcommittee_and_verifies() { + let state = state_with_committees(SYNC_COMMITTEE_SIZE_FOR_TESTS); + let mut pool = SyncCommitteePool::default(); + // One member of each subcommittee: validator `s * SIZE` sits at the + // subcommittee's first position. + for subcommittee in 0..SYNC_COMMITTEE_SUBNET_COUNT { + let (message, seats) = message_for( + &state, + (subcommittee * SYNC_SUBCOMMITTEE_SIZE) as u64, + root(), + ); + assert!(pool.insert_message(&message, &seats)); + } + let aggregate = pool.sync_aggregate(SLOT, root()).unwrap(); + assert_eq!( + aggregate.sync_committee_bits.count_ones(), + SYNC_COMMITTEE_SUBNET_COUNT + ); + let committee = sync_committee_for_slot(&state, SLOT).unwrap(); + let mut pubkeys = Vec::new(); + for subcommittee in 0..SYNC_COMMITTEE_SUBNET_COUNT { + let position = subcommittee * SYNC_SUBCOMMITTEE_SIZE; + assert!(aggregate.sync_committee_bits.get(position).unwrap()); + pubkeys.push(committee.pubkeys[position]); + } + let signing_root = sync_committee_message_signing_root( + &Config::mainnet(), + state.genesis_validators_root(), + SLOT, + root(), + ); + assert!(eth_fast_aggregate_verify( + &pubkeys, + signing_root, + &aggregate.sync_committee_signature + )); + } + + #[test] + fn different_roots_stay_separate_and_an_empty_pool_has_no_aggregate() { + let state = state_with_committees(SYNC_COMMITTEE_SIZE_FOR_TESTS); + let mut pool = SyncCommitteePool::default(); + assert!(pool.sync_aggregate(SLOT, root()).is_none()); + let (message, seats) = message_for(&state, 0, root()); + pool.insert_message(&message, &seats); + assert!(pool.sync_aggregate(SLOT, Root::repeat_byte(1)).is_none()); + assert!(pool.sync_aggregate(SLOT + 1, root()).is_none()); + assert!(pool.contribution(SLOT, root(), 1).is_none()); + assert!(pool.sync_aggregate(SLOT, root()).is_some()); + } + + #[test] + fn pruning_drops_older_slots_and_inserts_prune_themselves() { + let state = state_with_committees(SYNC_COMMITTEE_SIZE_FOR_TESTS); + let mut pool = SyncCommitteePool::default(); + let (message, seats) = message_for(&state, 0, root()); + pool.insert_message(&message, &seats); + pool.prune_before(SLOT); + assert!(pool.sync_aggregate(SLOT, root()).is_some()); + pool.prune_before(SLOT + 1); + assert!(pool.sync_aggregate(SLOT, root()).is_none()); + + pool.insert_message(&message, &seats); + let mut newer = message.clone(); + newer.slot = SLOT + RETAINED_SLOTS + 1; + pool.insert_message(&newer, &seats); + assert!(pool.sync_aggregate(SLOT, root()).is_none()); + assert!(pool.sync_aggregate(newer.slot, root()).is_some()); + } +} From 85dc1d09245fbea5d5e1871602050b5a7a777a9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:40:38 -0300 Subject: [PATCH 04/11] feat(validator): beacon node API, signing, clock and duties for sync committees The validator client had no way to ask for, sign or publish sync committee work. This adds the pieces the duty service builds on: the six BeaconNodeApi calls (HTTP, failover and mock), the three sync signing roots, the sync message and contribution deadlines (read from the node's spec, with gloas variants), and period-keyed sync duties. An optimistic head is reported as BeaconNodeSyncing so failover tries the next node instead of signing over an unvalidated head, and subscriptions go to every node because a node that never joined a subnet cannot pool the messages a contribution is folded from. --- crates/validator/src/beacon_node/dto.rs | 132 ++++++++++++++ crates/validator/src/beacon_node/fallback.rs | 99 +++++++++++ crates/validator/src/beacon_node/http.rs | 127 ++++++++++++- crates/validator/src/beacon_node/mock.rs | 152 +++++++++++++++- crates/validator/src/beacon_node/mod.rs | 83 ++++++++- crates/validator/src/duties.rs | 137 +++++++++++++- crates/validator/src/metrics.rs | 65 +++++++ crates/validator/src/signing.rs | 178 ++++++++++++++++++- crates/validator/src/slot_clock.rs | 112 ++++++++++++ 9 files changed, 1077 insertions(+), 8 deletions(-) diff --git a/crates/validator/src/beacon_node/dto.rs b/crates/validator/src/beacon_node/dto.rs index e62c24e3..d072748f 100644 --- a/crates/validator/src/beacon_node/dto.rs +++ b/crates/validator/src/beacon_node/dto.rs @@ -58,6 +58,29 @@ pub mod quoted_u64 { } } +/// A list of 64-bit integers, each quoted, as `validator_sync_committee_indices` +/// and `sync_committee_indices` carry them. +pub mod quoted_u64_vec { + use serde::ser::SerializeSeq as _; + use serde::{Deserialize as _, Deserializer, Serializer}; + + pub fn serialize(values: &[u64], serializer: S) -> Result { + let mut seq = serializer.serialize_seq(Some(values.len()))?; + for value in values { + seq.serialize_element(&value.to_string())?; + } + seq.end() + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + let texts = Vec::::deserialize(deserializer)?; + texts + .iter() + .map(|text| text.parse().map_err(serde::de::Error::custom)) + .collect() + } +} + /// The envelope almost every Beacon API response uses. #[derive(Debug, Deserialize)] pub struct DataResponse { @@ -217,6 +240,45 @@ pub struct PtcDutyDto { pub slot: Slot, } +/// `GET /eth/v1/beacon/blocks/head/root`: the root, and whether the head is +/// one the execution client has not validated. +#[derive(Debug, Clone, Deserialize)] +pub struct BlockRootResponse { + /// `Option` and read as `false` when absent, for the reason + /// [`SyncingDto::is_optimistic`] is. + pub execution_optimistic: Option, + pub data: BlockRootDto, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct BlockRootDto { + pub root: String, +} + +/// One entry of `POST /eth/v1/validator/duties/sync/{epoch}`: the validator and +/// every seat it holds in the period's sync committee. Seats are positions in +/// the whole committee, and a validator can hold several. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct SyncDutyDto { + pub pubkey: String, + #[serde(with = "quoted_u64")] + pub validator_index: ValidatorIndex, + #[serde(with = "quoted_u64_vec")] + pub validator_sync_committee_indices: Vec, +} + +/// One entry of `POST /eth/v1/validator/sync_committee_subscriptions`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SyncCommitteeSubscriptionDto { + #[serde(with = "quoted_u64")] + pub validator_index: ValidatorIndex, + #[serde(with = "quoted_u64_vec")] + pub sync_committee_indices: Vec, + /// Exclusive. + #[serde(with = "quoted_u64")] + pub until_epoch: Epoch, +} + #[derive(Debug, Clone, Deserialize)] pub struct ValidatorEntryDto { #[serde(with = "quoted_u64")] @@ -503,6 +565,16 @@ pub fn config_from_spec_response(value: &serde_json::Value) -> Result { "AGGREGATE_DUE_BPS_GLOAS", &mut config.aggregate_due_bps_gloas, ), + ("SYNC_MESSAGE_DUE_BPS", &mut config.sync_message_due_bps), + ("CONTRIBUTION_DUE_BPS", &mut config.contribution_due_bps), + ( + "SYNC_MESSAGE_DUE_BPS_GLOAS", + &mut config.sync_message_due_bps_gloas, + ), + ( + "CONTRIBUTION_DUE_BPS_GLOAS", + &mut config.contribution_due_bps_gloas, + ), ("PAYLOAD_DUE_BPS", &mut config.payload_due_bps), ( "PAYLOAD_ATTESTATION_DUE_BPS", @@ -1024,6 +1096,66 @@ mod tests { assert!(matches!(err, Error::Decode(_)), "got {err:?}"); } + #[test] + fn the_sync_offsets_are_read_and_default_to_the_specification() { + let response = serde_json::json!({ + "SYNC_MESSAGE_DUE_BPS": "3000", + "CONTRIBUTION_DUE_BPS": "6000", + "SYNC_MESSAGE_DUE_BPS_GLOAS": "2000", + "CONTRIBUTION_DUE_BPS_GLOAS": "4000", + }); + let config = config_from_spec_response(&response).expect("builds"); + assert_eq!(config.sync_message_due_bps, 3_000); + assert_eq!(config.contribution_due_bps, 6_000); + assert_eq!(config.sync_message_due_bps_gloas, 2_000); + assert_eq!(config.contribution_due_bps_gloas, 4_000); + + let config = config_from_spec_response(&serde_json::json!({})).expect("builds"); + assert_eq!(config.sync_message_due_bps, 3_333); + assert_eq!(config.contribution_due_bps, 6_667); + assert_eq!(config.sync_message_due_bps_gloas, 2_500); + assert_eq!(config.contribution_due_bps_gloas, 5_000); + } + + #[test] + fn a_sync_duty_parses_its_quoted_indices() { + let duty: SyncDutyDto = serde_json::from_value(serde_json::json!({ + "pubkey": "0xaa", + "validator_index": "7", + "validator_sync_committee_indices": ["3", "130"], + })) + .expect("parses"); + assert_eq!(duty.validator_index, 7); + assert_eq!(duty.validator_sync_committee_indices, vec![3, 130]); + } + + #[test] + fn an_unquoted_sync_index_is_rejected() { + let result = serde_json::from_value::(serde_json::json!({ + "pubkey": "0xaa", + "validator_index": "7", + "validator_sync_committee_indices": [3], + })); + assert!(result.is_err()); + } + + #[test] + fn a_sync_subscription_quotes_every_integer() { + let dto = SyncCommitteeSubscriptionDto { + validator_index: 7, + sync_committee_indices: vec![3, 130], + until_epoch: 256, + }; + assert_eq!( + serde_json::to_value(&dto).expect("serialises"), + serde_json::json!({ + "validator_index": "7", + "sync_committee_indices": ["3", "130"], + "until_epoch": "256", + }) + ); + } + /// A gloas aggregate parses straight into the gloas container, bitfields /// included, and serialises back to the JSON the electra DTO produces for /// the same vote, which is what the endpoint takes. diff --git a/crates/validator/src/beacon_node/fallback.rs b/crates/validator/src/beacon_node/fallback.rs index c5eb5c8d..8251f955 100644 --- a/crates/validator/src/beacon_node/fallback.rs +++ b/crates/validator/src/beacon_node/fallback.rs @@ -9,6 +9,7 @@ use std::pin::Pin; use async_trait::async_trait; use ethlambda_types::beacon::config::Config; +use ethlambda_types::beacon::containers::altair; use ethlambda_types::beacon::containers::gloas; use ethlambda_types::beacon::containers::shared::AttestationData; use ethlambda_types::beacon::fork::ForkName; @@ -18,6 +19,7 @@ use tracing::{debug, warn}; use crate::beacon_node::block_contents::ProducedBlock; use crate::beacon_node::dto::{ CommitteeSubscriptionDto, ProposerPreparationDto, SingleAttestationDto, + SyncCommitteeSubscriptionDto, SyncDutyDto, }; use crate::beacon_node::{ AggregateAttestation, AttesterDuties, BeaconNodeApi, BlockRequest, Genesis, ProposerDuties, @@ -363,6 +365,69 @@ impl BeaconNodeApi for FallbackBeaconNode { }) .await } + + async fn sync_duties( + &self, + epoch: Epoch, + indices: &[ValidatorIndex], + ) -> Result> { + self.try_each("sync_duties", |node| node.sync_duties(epoch, indices)) + .await + } + + /// An optimistic head is an error from each implementation, so a node + /// tracking an unvalidated head is walked past rather than signed over. + async fn head_block_root(&self) -> Result { + self.try_each("head_block_root", |node| node.head_block_root()) + .await + } + + async fn submit_sync_committee_messages( + &self, + messages: &[altair::SyncCommitteeMessage], + ) -> Result { + self.try_each("submit_sync_committee_messages", |node| { + node.submit_sync_committee_messages(messages) + }) + .await + } + + /// A 404 is a node that holds no messages for this subcommittee; another + /// node may have been subscribed when they arrived. + async fn sync_committee_contribution( + &self, + slot: Slot, + subcommittee_index: u64, + beacon_block_root: Root, + ) -> Result { + self.try_each("sync_committee_contribution", |node| { + node.sync_committee_contribution(slot, subcommittee_index, beacon_block_root) + }) + .await + } + + async fn publish_contribution_and_proofs( + &self, + contributions: &[altair::SignedContributionAndProof], + ) -> Result<()> { + self.try_each("publish_contribution_and_proofs", |node| { + node.publish_contribution_and_proofs(contributions) + }) + .await + } + + /// Every node, for the reason [`Self::subscribe_committees`] is: a node + /// that never joined a sync subnet cannot pool the messages a contribution + /// is folded from. + async fn subscribe_sync_committees( + &self, + subscriptions: &[SyncCommitteeSubscriptionDto], + ) -> Result<()> { + self.try_all("subscribe_sync_committees", |node| { + node.subscribe_sync_committees(subscriptions) + }) + .await + } } #[cfg(test)] @@ -721,6 +786,40 @@ mod tests { ); } + #[tokio::test] + async fn an_optimistic_head_fails_over_to_the_next_node() { + let mut optimistic = MockBeaconNode::new().with_head_root(Root::repeat_byte(1)); + optimistic.head_optimistic = true; + let healthy = MockBeaconNode::new().with_head_root(Root::repeat_byte(2)); + let fallback = FallbackBeaconNode::new(vec![optimistic, healthy]); + + assert_eq!( + fallback.head_block_root().await.expect("second answers"), + Root::repeat_byte(2) + ); + } + + #[tokio::test] + async fn a_sync_subscription_reaches_every_node() { + let fallback = FallbackBeaconNode::new(vec![MockBeaconNode::new(), MockBeaconNode::new()]); + let subscription = SyncCommitteeSubscriptionDto { + validator_index: 1, + sync_committee_indices: vec![3], + until_epoch: 256, + }; + fallback + .subscribe_sync_committees(&[subscription]) + .await + .expect("subscribes"); + + assert_eq!(fallback.nodes[0].sync_subscriptions().len(), 1); + assert_eq!( + fallback.nodes[1].sync_subscriptions().len(), + 1, + "try_all, not try_each" + ); + } + /// The pair a beacon node can genuinely report and this client must still /// refuse: caught up, but tracking a head its execution client has not /// validated. The specification makes not signing in that position a MUST, diff --git a/crates/validator/src/beacon_node/http.rs b/crates/validator/src/beacon_node/http.rs index 71a845fb..013964c7 100644 --- a/crates/validator/src/beacon_node/http.rs +++ b/crates/validator/src/beacon_node/http.rs @@ -24,6 +24,7 @@ use std::time::Duration; use async_trait::async_trait; use ethlambda_types::beacon::config::Config; +use ethlambda_types::beacon::containers::altair; use ethlambda_types::beacon::containers::electra::Attestation; use ethlambda_types::beacon::containers::gloas; use ethlambda_types::beacon::containers::shared::AttestationData; @@ -37,15 +38,16 @@ use libssz::SszDecode as _; use crate::beacon_node::block_contents::ProducedBlock; use crate::beacon_node::dto::{ - AttestationDataDto, AttestationDto, AttesterDutyDto, CommitteeSubscriptionDto, DataResponse, - DutiesResponse, GenesisDto, IndexedErrorResponse, ProposerDutyDto, ProposerPreparationDto, - PtcDutyDto, SignedAggregateAndProofOutDto, SingleAttestationDto, SyncingDto, ValidatorEntryDto, + AttestationDataDto, AttestationDto, AttesterDutyDto, BlockRootResponse, + CommitteeSubscriptionDto, DataResponse, DutiesResponse, GenesisDto, IndexedErrorResponse, + ProposerDutyDto, ProposerPreparationDto, PtcDutyDto, SignedAggregateAndProofOutDto, + SingleAttestationDto, SyncCommitteeSubscriptionDto, SyncDutyDto, SyncingDto, ValidatorEntryDto, VersionedResponse, config_from_spec_response, encode_hex, parse_pubkey, parse_root, }; use crate::beacon_node::{ AggregateAttestation, AggregateKind, AttesterDuties, BeaconNodeApi, BlockRequest, Genesis, ProposerDuties, PtcDuties, Published, SignedAggregates, ValidatorEntry, - validate_attestation_data, validate_produced_block, + validate_attestation_data, validate_produced_block, validate_sync_contribution, }; use crate::error::{BeaconNodeFailure, Error, Result}; @@ -395,6 +397,21 @@ impl HttpBeaconNode { ) } + /// The query string for one sync committee contribution. + /// + /// A pure function for the reason [`Self::aggregate_path`] is. + fn sync_contribution_path( + slot: Slot, + subcommittee_index: u64, + beacon_block_root: Root, + ) -> String { + format!( + "/eth/v1/validator/sync_committee_contribution?slot={slot}\ + &subcommittee_index={subcommittee_index}&beacon_block_root={}", + encode_hex(&beacon_block_root.0), + ) + } + /// Turn a non-2xx response from the pool-attestations endpoint into /// either a partial success or an error. /// @@ -942,6 +959,94 @@ impl BeaconNodeApi for HttpBeaconNode { ) .await } + + async fn sync_duties( + &self, + epoch: Epoch, + indices: &[ValidatorIndex], + ) -> Result> { + let body: Vec = indices.iter().map(|index| index.to_string()).collect(); + let response: DataResponse> = self + .post( + &format!("/eth/v1/validator/duties/sync/{epoch}"), + &body, + None, + ) + .await?; + Ok(response.data) + } + + async fn head_block_root(&self) -> Result { + let response: BlockRootResponse = self.get("/eth/v1/beacon/blocks/head/root").await?; + // The same error a 503 gets, so failover moves to the next node. See + // the contract on `BeaconNodeApi::head_block_root`. + if response.execution_optimistic.unwrap_or(false) { + return Err(Error::BeaconNodeSyncing); + } + parse_root(&response.data.root) + } + + /// The same partial-success reading as attestations: see + /// [`Self::handle_pool_submission`]. + async fn submit_sync_committee_messages( + &self, + messages: &[altair::SyncCommitteeMessage], + ) -> Result { + let url = format!("{}/eth/v1/beacon/pool/sync_committees", self.base_url); + let response = self + .client + .post(&url) + .json(messages) + .send() + .await + .map_err(|err| Error::BeaconNode { + url: url.clone(), + failure: BeaconNodeFailure::classify(&err), + detail: err.to_string(), + })?; + let status = response.status(); + if status.is_success() { + return Ok(messages.len()); + } + let body = response.text().await.unwrap_or_default(); + Self::handle_pool_submission(status, body, messages.len()) + } + + async fn sync_committee_contribution( + &self, + slot: Slot, + subcommittee_index: u64, + beacon_block_root: Root, + ) -> Result { + let path = Self::sync_contribution_path(slot, subcommittee_index, beacon_block_root); + let response: DataResponse = self.get(&path).await?; + validate_sync_contribution(slot, subcommittee_index, beacon_block_root, &response.data)?; + Ok(response.data) + } + + async fn publish_contribution_and_proofs( + &self, + contributions: &[altair::SignedContributionAndProof], + ) -> Result<()> { + self.post_no_content( + "/eth/v1/validator/contribution_and_proofs", + &contributions, + None, + ) + .await + } + + async fn subscribe_sync_committees( + &self, + subscriptions: &[SyncCommitteeSubscriptionDto], + ) -> Result<()> { + self.post_no_content( + "/eth/v1/validator/sync_committee_subscriptions", + &subscriptions, + None, + ) + .await + } } #[cfg(test)] @@ -1049,6 +1154,20 @@ mod tests { /// Before gloas the query still names committee 0, exactly as it always /// has; from gloas it is omitted. + #[test] + fn the_sync_contribution_query_carries_all_three_parameters_without_whitespace() { + let path = HttpBeaconNode::sync_contribution_path(7, 2, Root::repeat_byte(0xab)); + assert_eq!( + path, + format!( + "/eth/v1/validator/sync_committee_contribution?slot=7&subcommittee_index=2\ + &beacon_block_root=0x{}", + "ab".repeat(32) + ) + ); + assert!(!path.contains(' ')); + } + #[test] fn the_committee_index_is_omitted_from_gloas_on() { assert_eq!( diff --git a/crates/validator/src/beacon_node/mock.rs b/crates/validator/src/beacon_node/mock.rs index 34d01f57..0f4ab658 100644 --- a/crates/validator/src/beacon_node/mock.rs +++ b/crates/validator/src/beacon_node/mock.rs @@ -6,6 +6,7 @@ use std::sync::Mutex; use async_trait::async_trait; use ethlambda_types::beacon::config::Config; +use ethlambda_types::beacon::containers::altair; use ethlambda_types::beacon::containers::electra::{Attestation, SignedAggregateAndProof}; use ethlambda_types::beacon::containers::gloas; use ethlambda_types::beacon::containers::shared::AttestationData; @@ -19,11 +20,12 @@ use crate::beacon_node::block_contents::{ }; use crate::beacon_node::dto::{ AttesterDutyDto, CommitteeSubscriptionDto, ProposerDutyDto, ProposerPreparationDto, PtcDutyDto, - SingleAttestationDto, + SingleAttestationDto, SyncCommitteeSubscriptionDto, SyncDutyDto, }; use crate::beacon_node::{ AggregateAttestation, AggregateKind, AttesterDuties, BeaconNodeApi, BlockRequest, Genesis, ProposerDuties, PtcDuties, Published, SignedAggregates, ValidatorEntry, + validate_sync_contribution, }; use crate::error::{BeaconNodeFailure, Error, Result}; @@ -112,6 +114,24 @@ pub struct MockBeaconNode { pub validator_indices_calls: Mutex, pub proposer_duties_calls: Mutex, pub attestation_data_calls: Mutex, + /// Sync duties by the epoch they are asked at. An epoch never set answers + /// no duties, so tests that do not care about sync committees are + /// unaffected. + pub sync_duties: Mutex)>>, + /// Every `sync_duties` request, as (epoch, indices). + pub sync_duty_requests: Mutex)>>, + /// What `head_block_root` answers with. `None` fails the call. + pub head_root: Option, + /// The head is one the execution client has not validated. + pub head_optimistic: bool, + pub submitted_sync_messages: Mutex>, + /// Contributions the mock holds; one is served by its `subcommittee_index`. + /// A subcommittee with none answers like a node's 404. + pub contributions: Vec, + /// Every `sync_committee_contribution` request, as (slot, subcommittee, root). + pub contribution_requests: Mutex>, + pub published_contributions: Mutex>, + pub sync_subscriptions: Mutex>, } impl MockBeaconNode { @@ -349,6 +369,45 @@ impl MockBeaconNode { .clone() } + /// Answer `sync_duties` for the period containing `epoch` with `duties`. + pub fn with_sync_duties(self, epoch: Epoch, duties: Vec) -> Self { + self.sync_duties.lock().expect("lock").push((epoch, duties)); + self + } + + /// Answer `head_block_root` with `root`. + pub fn with_head_root(mut self, root: Root) -> Self { + self.head_root = Some(root); + self + } + + /// Hold `contribution`, to be served for its subcommittee. + pub fn with_contribution(mut self, contribution: altair::SyncCommitteeContribution) -> Self { + self.contributions.push(contribution); + self + } + + /// Every `sync_duties` request, as (epoch, indices). + pub fn sync_duty_requests(&self) -> Vec<(Epoch, Vec)> { + self.sync_duty_requests.lock().expect("lock").clone() + } + + pub fn submitted_sync_messages(&self) -> Vec { + self.submitted_sync_messages.lock().expect("lock").clone() + } + + pub fn contribution_requests(&self) -> Vec<(Slot, u64, Root)> { + self.contribution_requests.lock().expect("lock").clone() + } + + pub fn published_contributions(&self) -> Vec { + self.published_contributions.lock().expect("lock").clone() + } + + pub fn sync_subscriptions(&self) -> Vec { + self.sync_subscriptions.lock().expect("lock").clone() + } + pub fn with_publish_outcome(mut self, outcome: Published) -> Self { self.publish_outcome = Some(outcome); self @@ -793,4 +852,95 @@ impl BeaconNodeApi for MockBeaconNode { .extend_from_slice(subscriptions); Ok(()) } + + async fn sync_duties( + &self, + epoch: Epoch, + indices: &[ValidatorIndex], + ) -> Result> { + self.guard("sync_duties")?; + self.sync_duty_requests + .lock() + .expect("lock") + .push((epoch, indices.to_vec())); + Ok(self + .sync_duties + .lock() + .expect("lock") + .iter() + .find(|(stored, _)| *stored == epoch) + .map(|(_, duties)| duties.clone()) + .unwrap_or_default()) + } + + async fn head_block_root(&self) -> Result { + self.guard("head_block_root")?; + if self.head_optimistic { + return Err(Error::BeaconNodeSyncing); + } + self.head_root.ok_or_else(|| Error::BeaconNode { + url: "mock".to_string(), + failure: BeaconNodeFailure::Request, + detail: "no head root".to_string(), + }) + } + + async fn submit_sync_committee_messages( + &self, + messages: &[altair::SyncCommitteeMessage], + ) -> Result { + self.guard("submit_sync_committee_messages")?; + self.submitted_sync_messages + .lock() + .expect("lock") + .extend_from_slice(messages); + Ok(messages.len()) + } + + async fn sync_committee_contribution( + &self, + slot: Slot, + subcommittee_index: u64, + beacon_block_root: Root, + ) -> Result { + self.guard("sync_committee_contribution")?; + self.contribution_requests + .lock() + .expect("lock") + .push((slot, subcommittee_index, beacon_block_root)); + let contribution = self + .contributions + .iter() + .find(|held| held.subcommittee_index == subcommittee_index) + .ok_or(Error::BeaconNodeStatus { + status: 404, + body: "no contribution".to_string(), + })?; + validate_sync_contribution(slot, subcommittee_index, beacon_block_root, contribution)?; + Ok(contribution.clone()) + } + + async fn publish_contribution_and_proofs( + &self, + contributions: &[altair::SignedContributionAndProof], + ) -> Result<()> { + self.guard("publish_contribution_and_proofs")?; + self.published_contributions + .lock() + .expect("lock") + .extend_from_slice(contributions); + Ok(()) + } + + async fn subscribe_sync_committees( + &self, + subscriptions: &[SyncCommitteeSubscriptionDto], + ) -> Result<()> { + self.guard("subscribe_sync_committees")?; + self.sync_subscriptions + .lock() + .expect("lock") + .extend_from_slice(subscriptions); + Ok(()) + } } diff --git a/crates/validator/src/beacon_node/mod.rs b/crates/validator/src/beacon_node/mod.rs index f14824c9..fc6eb130 100644 --- a/crates/validator/src/beacon_node/mod.rs +++ b/crates/validator/src/beacon_node/mod.rs @@ -17,9 +17,10 @@ use ethlambda_types::beacon::signing::compute_epoch_at_slot; use crate::beacon_node::block_contents::ProducedBlock; use crate::beacon_node::dto::{ AttesterDutyDto, CommitteeSubscriptionDto, ProposerDutyDto, ProposerPreparationDto, PtcDutyDto, - SingleAttestationDto, + SingleAttestationDto, SyncCommitteeSubscriptionDto, SyncDutyDto, }; use crate::error::Result; +use ethlambda_types::beacon::containers::altair; use ethlambda_types::beacon::containers::electra; use ethlambda_types::beacon::containers::gloas; @@ -486,4 +487,84 @@ pub trait BeaconNodeApi: Send + Sync { /// Tells the node which committees this client's validators care about /// this epoch, so it can manage subnet subscriptions on their behalf. async fn subscribe_committees(&self, subscriptions: &[CommitteeSubscriptionDto]) -> Result<()>; + + /// The sync committee duties for `indices` in the period `epoch` falls in. + /// `POST /eth/v1/validator/duties/sync/{epoch}`, whose answer carries no + /// `dependent_root`: the committee is fixed a period ahead. + async fn sync_duties(&self, epoch: Epoch, indices: &[ValidatorIndex]) + -> Result>; + + /// The root of the node's head block, which a sync committee message signs. + /// + /// # Contract: an optimistic head is `Err(BeaconNodeSyncing)` + /// + /// The answer carries `execution_optimistic`, and the optimistic-sync + /// specification forbids signing `DOMAIN_SYNC_COMMITTEE` over a head the + /// execution client has not validated. Mapping it to the same error a 503 + /// gets makes failover try the next node rather than hand this one's root + /// to the signer. + async fn head_block_root(&self) -> Result; + + /// Submit signed sync committee messages to the node's pool. Returns how + /// many it accepted, with the same partial-success reading as + /// [`Self::submit_attestations`]. + async fn submit_sync_committee_messages( + &self, + messages: &[altair::SyncCommitteeMessage], + ) -> Result; + + /// The best contribution the node holds for one subcommittee's messages on + /// `beacon_block_root` at `slot`. A node with nothing answers 404, which + /// surfaces as an error so failover tries the next node, as it does for + /// [`Self::aggregate_attestation`]. + /// + /// # Contract: the answer is for this request, or it is an `Err` + /// + /// `slot`, `subcommittee_index` and `beacon_block_root` must equal the + /// request, checked by [`validate_sync_contribution`] in the + /// implementation for the reason [`Self::attestation_data`] states: the + /// contribution is wrapped in a signature, so a wrong one must be failed + /// over from rather than signed for. + async fn sync_committee_contribution( + &self, + slot: Slot, + subcommittee_index: u64, + beacon_block_root: Root, + ) -> Result; + + /// Publish signed contributions. JSON: the endpoint lists no SSZ body. + async fn publish_contribution_and_proofs( + &self, + contributions: &[altair::SignedContributionAndProof], + ) -> Result<()>; + + /// Tells the node which sync committee subnets this client's validators + /// sit in, so it joins them. Like [`Self::subscribe_committees`] it is + /// state installed on a node and must be re-sent, since a node forgets it + /// on restart. + async fn subscribe_sync_committees( + &self, + subscriptions: &[SyncCommitteeSubscriptionDto], + ) -> Result<()>; +} + +/// Check that `contribution` is the one asked for, as the contract on +/// [`BeaconNodeApi::sync_committee_contribution`] requires. +pub fn validate_sync_contribution( + slot: Slot, + subcommittee_index: u64, + beacon_block_root: Root, + contribution: &altair::SyncCommitteeContribution, +) -> Result<()> { + if contribution.slot != slot + || contribution.subcommittee_index != subcommittee_index + || contribution.beacon_block_root != beacon_block_root + { + return Err(crate::error::Error::InconsistentResponse(format!( + "requested a sync contribution for slot {slot} subcommittee {subcommittee_index} \ + root {beacon_block_root:?}, node answered for slot {} subcommittee {} root {:?}", + contribution.slot, contribution.subcommittee_index, contribution.beacon_block_root + ))); + } + Ok(()) } diff --git a/crates/validator/src/duties.rs b/crates/validator/src/duties.rs index 6b9744df..b1073811 100644 --- a/crates/validator/src/duties.rs +++ b/crates/validator/src/duties.rs @@ -31,13 +31,19 @@ use std::collections::HashMap; use std::sync::Arc; use ethlambda_types::beacon::constants::GENESIS_SLOT; +use ethlambda_types::beacon::preset::{EPOCHS_PER_SYNC_COMMITTEE_PERIOD, SLOTS_PER_EPOCH}; use ethlambda_types::beacon::primitives::{Epoch, Root, Slot, ValidatorIndex}; use tracing::{info, warn}; use crate::beacon_node::BeaconNodeApi; -use crate::beacon_node::dto::{AttesterDutyDto, ProposerDutyDto, PtcDutyDto}; +use crate::beacon_node::dto::{AttesterDutyDto, ProposerDutyDto, PtcDutyDto, SyncDutyDto}; use crate::error::Result; +/// The sync committee period `epoch` belongs to. +pub fn sync_committee_period(epoch: Epoch) -> u64 { + epoch / EPOCHS_PER_SYNC_COMMITTEE_PERIOD +} + /// One epoch's schedule of some kind, and the block root it is derived from. #[derive(Debug, Clone)] struct EpochDuties { @@ -55,6 +61,11 @@ pub struct DutiesService { proposers: HashMap>, /// Payload timeliness committee duties, already narrowed to `indices`. ptc: HashMap>, + /// Sync committee duties by period, already narrowed to `indices`. Keyed + /// by period, not epoch, because a committee serves a whole period; and + /// with no `dependent_root` because the endpoint sends none: the committee + /// is fixed a period ahead, so each refresh simply replaces what is held. + sync: HashMap>, /// Whether the "no validator indices resolved" warning has already fired. /// Without this, an idle client would repeat it every epoch forever; with /// it, the operator still gets exactly one signal that duties are not @@ -70,6 +81,7 @@ impl DutiesService { by_epoch: HashMap::new(), proposers: HashMap::new(), ptc: HashMap::new(), + sync: HashMap::new(), warned_no_indices: false, } } @@ -254,6 +266,52 @@ impl DutiesService { Ok(()) } + /// Fetch the sync committee duties of `period` for this client's indices, + /// replacing whatever is held for it. + /// + /// Asked at the period's first epoch, which the node answers from the + /// committee that serves the whole period. Narrowed to this client's + /// indices on arrival, for the reason [`Self::refresh_ptc`] is. + pub async fn refresh_sync(&mut self, period: u64) -> Result<()> { + if self.indices.is_empty() { + return Ok(()); + } + let epoch = period * EPOCHS_PER_SYNC_COMMITTEE_PERIOD; + let fetched = self.beacon_node.sync_duties(epoch, &self.indices).await?; + let mine: Vec = fetched + .into_iter() + .filter(|duty| self.indices.contains(&duty.validator_index)) + .filter(|duty| !duty.validator_sync_committee_indices.is_empty()) + .collect(); + if mine.is_empty() { + tracing::debug!(period, "No sync committee duties this period"); + } else { + info!(period, count = mine.len(), "Sync committee duties updated"); + } + self.sync.insert(period, mine); + Ok(()) + } + + /// The sync committee duties held for `period`. + pub fn sync_for_period(&self, period: u64) -> Vec { + self.sync.get(&period).cloned().unwrap_or_default() + } + + /// The sync committee duties to perform at wall slot `slot`. + /// + /// Those of the period containing `slot + 1`: a member assigned to slot + /// `S` signs for `S - 1` and the message is included at `S`, so at the + /// last slot of a period the next committee is the one that signs. + pub fn sync_at_slot(&self, slot: Slot) -> Vec { + let period = sync_committee_period((slot + 1) / SLOTS_PER_EPOCH); + self.sync_for_period(period) + } + + /// Forget sync committee duties of periods before `period`. + pub fn prune_sync_before(&mut self, period: u64) { + self.sync.retain(|held, _| *held >= period); + } + /// Forget payload timeliness committee duties for epochs before `epoch`. pub fn prune_ptc_before(&mut self, epoch: Epoch) { self.ptc.retain(|held, _| *held >= epoch); @@ -757,6 +815,83 @@ mod tests { assert_eq!(service.ptc_at_slot(420, 13).len(), 1); } + fn sync_duty(validator_index: ValidatorIndex, seats: Vec) -> SyncDutyDto { + SyncDutyDto { + pubkey: "0x00".to_string(), + validator_index, + validator_sync_committee_indices: seats, + } + } + + #[tokio::test] + async fn sync_duties_are_fetched_at_the_periods_first_epoch_and_narrowed() { + let node = Arc::new(MockBeaconNode::new().with_sync_duties( + EPOCHS_PER_SYNC_COMMITTEE_PERIOD, + vec![sync_duty(7, vec![3]), sync_duty(99, vec![4])], + )); + let mut service = DutiesService::new(node, vec![7]); + + service.refresh_sync(1).await.expect("refreshes"); + + let held = service.sync_for_period(1); + assert_eq!(held.len(), 1); + assert_eq!(held[0].validator_index, 7); + assert!(service.sync_for_period(0).is_empty()); + } + + /// At a period's last slot the member signs for the slot after, which the + /// next committee owns. + #[tokio::test] + async fn the_last_slot_of_a_period_uses_the_next_periods_duties() { + let node = Arc::new( + MockBeaconNode::new() + .with_sync_duties(0, vec![sync_duty(7, vec![1])]) + .with_sync_duties( + EPOCHS_PER_SYNC_COMMITTEE_PERIOD, + vec![sync_duty(7, vec![200])], + ), + ); + let mut service = DutiesService::new(node, vec![7]); + service.refresh_sync(0).await.expect("0"); + service.refresh_sync(1).await.expect("1"); + + let period_slots = EPOCHS_PER_SYNC_COMMITTEE_PERIOD * SLOTS_PER_EPOCH; + assert_eq!( + service.sync_at_slot(period_slots - 2)[0].validator_sync_committee_indices, + vec![1] + ); + assert_eq!( + service.sync_at_slot(period_slots - 1)[0].validator_sync_committee_indices, + vec![200] + ); + } + + #[tokio::test] + async fn pruning_forgets_earlier_sync_periods() { + let node = Arc::new( + MockBeaconNode::new() + .with_sync_duties(0, vec![sync_duty(7, vec![1])]) + .with_sync_duties( + EPOCHS_PER_SYNC_COMMITTEE_PERIOD, + vec![sync_duty(7, vec![2])], + ), + ); + let mut service = DutiesService::new(node, vec![7]); + service.refresh_sync(0).await.expect("0"); + service.refresh_sync(1).await.expect("1"); + service.prune_sync_before(1); + assert!(service.sync_for_period(0).is_empty()); + assert_eq!(service.sync_for_period(1).len(), 1); + } + + #[tokio::test] + async fn no_indices_means_no_sync_duties_request() { + let node = Arc::new(MockBeaconNode::new()); + let mut service = DutiesService::new(node.clone(), Vec::new()); + service.refresh_sync(0).await.expect("idles"); + assert!(node.sync_duty_requests().is_empty()); + } + #[tokio::test] async fn no_indices_means_no_payload_committee_request() { let node = Arc::new(MockBeaconNode::new()); diff --git a/crates/validator/src/metrics.rs b/crates/validator/src/metrics.rs index a47da859..2da746bb 100644 --- a/crates/validator/src/metrics.rs +++ b/crates/validator/src/metrics.rs @@ -257,6 +257,46 @@ static PUBLICATION_DELAY_SECONDS: LazyLock = LazyLock::new(|| { .unwrap() }); +static SYNC_COMMITTEE_MESSAGES_PUBLISHED_TOTAL: LazyLock = LazyLock::new(|| { + register_int_counter!( + "ethlambda_validator_sync_committee_messages_published_total", + "Sync committee messages the beacon node accepted" + ) + .unwrap() +}); + +static SYNC_COMMITTEE_FAILURES_TOTAL: LazyLock = LazyLock::new(|| { + register_int_counter!( + "ethlambda_validator_sync_committee_failures_total", + "Slots whose sync committee message duty did not result in published messages" + ) + .unwrap() +}); + +static SYNC_CONTRIBUTIONS_PUBLISHED_TOTAL: LazyLock = LazyLock::new(|| { + register_int_counter!( + "ethlambda_validator_sync_contributions_published_total", + "Sync committee contributions published to the beacon node" + ) + .unwrap() +}); + +static SYNC_CONTRIBUTION_FAILURES_TOTAL: LazyLock = LazyLock::new(|| { + register_int_counter!( + "ethlambda_validator_sync_contribution_failures_total", + "Sync committee aggregation duties that ended in an error or ran past their slot" + ) + .unwrap() +}); + +static SYNC_DUTIES_HELD: LazyLock = LazyLock::new(|| { + register_int_gauge!( + "ethlambda_validator_sync_duties_held", + "Sync committee duties held for the current period" + ) + .unwrap() +}); + /// Register every series with the Prometheus registry so `/metrics` lists them /// at zero from startup, rather than only after whatever first touches them. /// @@ -282,6 +322,11 @@ pub fn init() { LazyLock::force(&ENVELOPE_FAILURES_TOTAL); LazyLock::force(&PAYLOAD_ATTESTATIONS_PUBLISHED_TOTAL); LazyLock::force(&PAYLOAD_ATTESTATION_FAILURES_TOTAL); + LazyLock::force(&SYNC_COMMITTEE_MESSAGES_PUBLISHED_TOTAL); + LazyLock::force(&SYNC_COMMITTEE_FAILURES_TOTAL); + LazyLock::force(&SYNC_CONTRIBUTIONS_PUBLISHED_TOTAL); + LazyLock::force(&SYNC_CONTRIBUTION_FAILURES_TOTAL); + LazyLock::force(&SYNC_DUTIES_HELD); LazyLock::force(&BLOCK_PUBLICATION_DELAY_SECONDS); LazyLock::force(&AGGREGATES_PUBLISHED_TOTAL); LazyLock::force(&AGGREGATION_FAILURES_TOTAL); @@ -355,6 +400,26 @@ pub fn inc_payload_attestation_failures() { PAYLOAD_ATTESTATION_FAILURES_TOTAL.inc(); } +pub fn inc_sync_committee_messages_published(count: u64) { + SYNC_COMMITTEE_MESSAGES_PUBLISHED_TOTAL.inc_by(count); +} + +pub fn inc_sync_committee_failures() { + SYNC_COMMITTEE_FAILURES_TOTAL.inc(); +} + +pub fn inc_sync_contributions_published(count: u64) { + SYNC_CONTRIBUTIONS_PUBLISHED_TOTAL.inc_by(count); +} + +pub fn inc_sync_contribution_failures() { + SYNC_CONTRIBUTION_FAILURES_TOTAL.inc(); +} + +pub fn set_sync_duties_held(count: u64) { + SYNC_DUTIES_HELD.set(count as i64); +} + /// Record how long after a slot's start its block was accepted. pub fn observe_block_publication_delay(seconds: f64) { BLOCK_PUBLICATION_DELAY_SECONDS.observe(seconds); diff --git a/crates/validator/src/signing.rs b/crates/validator/src/signing.rs index 30563481..12112b77 100644 --- a/crates/validator/src/signing.rs +++ b/crates/validator/src/signing.rs @@ -8,8 +8,10 @@ use ethlambda_types::beacon::config::Config; use ethlambda_types::beacon::constants::{ DOMAIN_AGGREGATE_AND_PROOF, DOMAIN_BEACON_ATTESTER, DOMAIN_BEACON_BUILDER, - DOMAIN_BEACON_PROPOSER, DOMAIN_PTC_ATTESTER, DOMAIN_RANDAO, DOMAIN_SELECTION_PROOF, + DOMAIN_BEACON_PROPOSER, DOMAIN_CONTRIBUTION_AND_PROOF, DOMAIN_PTC_ATTESTER, DOMAIN_RANDAO, + DOMAIN_SELECTION_PROOF, DOMAIN_SYNC_COMMITTEE, DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF, }; +use ethlambda_types::beacon::containers::altair::{ContributionAndProof, SyncAggregatorSelectionData}; use ethlambda_types::beacon::containers::gloas::{ ExecutionPayloadEnvelope, PayloadAttestationData, }; @@ -149,6 +151,45 @@ impl SigningContext { compute_signing_root(data.hash_tree_root(), domain) } + /// The root a sync committee message is computed over: the block root + /// itself, under the sync committee domain at the epoch of `slot`. + /// + /// `slot` is the message's own slot. The domain comes from the fork + /// schedule at that epoch rather than from any state's `fork`, which is + /// what a node validating the message does too, so a head lagging across a + /// fork boundary cannot make an honest message invalid. + pub fn sync_committee_message_signing_root(&self, slot: Slot, beacon_block_root: Root) -> Root { + let domain = self.domain(DOMAIN_SYNC_COMMITTEE, compute_epoch_at_slot(slot)); + compute_signing_root(beacon_block_root, domain) + } + + /// The root a sync committee aggregator's selection proof is computed + /// over: the slot and the subcommittee, under the selection domain. + /// + /// Both are in the message, unlike an attestation aggregator's proof which + /// signs the slot alone, because every subcommittee holds its own draw. + pub fn sync_selection_proof_signing_root(&self, slot: Slot, subcommittee_index: u64) -> Root { + let data = SyncAggregatorSelectionData { + slot, + subcommittee_index, + }; + let domain = self.domain( + DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF, + compute_epoch_at_slot(slot), + ); + compute_signing_root(data.hash_tree_root(), domain) + } + + /// The root a signed contribution is computed over: the whole + /// `ContributionAndProof`, at the epoch of the contribution's slot. + pub fn contribution_and_proof_signing_root(&self, message: &ContributionAndProof) -> Root { + let domain = self.domain( + DOMAIN_CONTRIBUTION_AND_PROOF, + compute_epoch_at_slot(message.contribution.slot), + ); + compute_signing_root(message.hash_tree_root(), domain) + } + /// Sign an already-computed signing root on behalf of `pubkey`. /// /// Every public signing method funnels through here, so there is one place @@ -268,6 +309,56 @@ impl SigningContext { self.sign_root(store, pubkey, self.payload_attestation_signing_root(data)) } + /// Sign a sync committee message for `slot` over `beacon_block_root`. + /// + /// Not slashable, and deduplicated per validator and slot by + /// [`crate::sync_committee`] only to avoid publishing the same message + /// twice. + pub fn sign_sync_committee_message( + &self, + store: &ValidatorStore, + pubkey: &BlsPubkey, + slot: Slot, + beacon_block_root: Root, + ) -> Result { + self.sign_root( + store, + pubkey, + self.sync_committee_message_signing_root(slot, beacon_block_root), + ) + } + + /// Sign the sync committee selection proof for `slot` and one + /// subcommittee. Deterministic, so not guarded, for the reason + /// [`Self::sign_selection_proof`] is not. + pub fn sign_sync_selection_proof( + &self, + store: &ValidatorStore, + pubkey: &BlsPubkey, + slot: Slot, + subcommittee_index: u64, + ) -> Result { + self.sign_root( + store, + pubkey, + self.sync_selection_proof_signing_root(slot, subcommittee_index), + ) + } + + /// Sign a `ContributionAndProof` on behalf of its aggregator `pubkey`. + pub fn sign_contribution_and_proof( + &self, + store: &ValidatorStore, + pubkey: &BlsPubkey, + message: &ContributionAndProof, + ) -> Result { + self.sign_root( + store, + pubkey, + self.contribution_and_proof_signing_root(message), + ) + } + /// Sign the block whose root is `block_root`, proposed for `slot`, on /// behalf of `pubkey`. /// @@ -581,6 +672,91 @@ mod tests { )); } + #[test] + fn a_sync_committee_message_verifies_under_its_own_root() { + let (store, pubkey) = store_with_key(); + let context = context(); + let root = Root::repeat_byte(5); + + let signature = context + .sign_sync_committee_message(&store, &pubkey, 3200, root) + .expect("signs"); + assert!(verify( + &pubkey, + &signature, + context.sync_committee_message_signing_root(3200, root) + )); + assert_eq!( + context.sync_committee_message_signing_root(3200, root), + compute_signing_root(root, context.domain(DOMAIN_SYNC_COMMITTEE, 100)), + "the root is signed bare, under the sync committee domain" + ); + } + + /// The domain follows the message's slot across a fork boundary. + #[test] + fn a_sync_message_domain_follows_the_fork_schedule_at_its_slot() { + let context = context(); + let after = context.config.altair_fork_epoch * preset::SLOTS_PER_EPOCH; + assert_ne!( + context.sync_committee_message_signing_root(after - 1, Root::ZERO), + context.sync_committee_message_signing_root(after, Root::ZERO), + ); + } + + #[test] + fn a_sync_selection_proof_verifies_and_names_the_subcommittee() { + let (store, pubkey) = store_with_key(); + let context = context(); + + let signature = context + .sign_sync_selection_proof(&store, &pubkey, 3200, 2) + .expect("signs"); + assert!(verify( + &pubkey, + &signature, + context.sync_selection_proof_signing_root(3200, 2) + )); + assert_ne!( + context.sync_selection_proof_signing_root(3200, 2), + context.sync_selection_proof_signing_root(3200, 3), + "each subcommittee has its own draw" + ); + assert_ne!( + context.sync_selection_proof_signing_root(3200, 0), + context.selection_proof_signing_root(3200), + "not replayable as an attestation selection proof" + ); + } + + #[test] + fn a_contribution_and_proof_verifies_under_its_own_root() { + use ethlambda_types::beacon::containers::altair::SyncCommitteeContribution; + + let (store, pubkey) = store_with_key(); + let context = context(); + let message = ContributionAndProof { + aggregator_index: 9, + contribution: SyncCommitteeContribution { + slot: 3200, + beacon_block_root: Root::repeat_byte(1), + subcommittee_index: 1, + aggregation_bits: Default::default(), + signature: BlsSignature([2; 96]), + }, + selection_proof: BlsSignature([3; 96]), + }; + + let signature = context + .sign_contribution_and_proof(&store, &pubkey, &message) + .expect("signs"); + assert!(verify( + &pubkey, + &signature, + context.contribution_and_proof_signing_root(&message) + )); + } + #[test] fn signing_a_randao_reveal_for_an_unknown_validator_is_an_error() { let store = ValidatorStore::new(); diff --git a/crates/validator/src/slot_clock.rs b/crates/validator/src/slot_clock.rs index e402b80b..34d12e15 100644 --- a/crates/validator/src/slot_clock.rs +++ b/crates/validator/src/slot_clock.rs @@ -152,6 +152,29 @@ impl SlotClock { self.offset_into(slot, bps) } + /// When the sync committee message for `slot` is signed. + /// + /// The fork of `slot` picks the offset, as it does for attestations: + /// gloas moves it earlier. + pub fn sync_message_time(&self, slot: Slot) -> SystemTime { + let bps = if self.is_gloas(slot) { + self.config.sync_message_due_bps_gloas + } else { + self.config.sync_message_due_bps + }; + self.offset_into(slot, bps) + } + + /// When a sync committee aggregator publishes its contribution for `slot`. + pub fn contribution_time(&self, slot: Slot) -> SystemTime { + let bps = if self.is_gloas(slot) { + self.config.contribution_due_bps_gloas + } else { + self.config.contribution_due_bps + }; + self.offset_into(slot, bps) + } + /// `bps` basis points of the way into `slot`. /// /// The specification's own `get_slot_component_duration_ms`, which is @@ -227,6 +250,33 @@ impl SlotClock { .unwrap_or(Duration::ZERO) } + /// How long from `now` until the sync committee message for `slot`, or zero + /// once that instant has passed. + pub fn until_sync_message(&self, slot: Slot, now: SystemTime) -> Duration { + self.sync_message_time(slot) + .duration_since(now) + .unwrap_or(Duration::ZERO) + } + + /// How long from `now` until the contribution for `slot`, or zero once + /// that instant has passed. + pub fn until_contribution(&self, slot: Slot, now: SystemTime) -> Duration { + self.contribution_time(slot) + .duration_since(now) + .unwrap_or(Duration::ZERO) + } + + /// How long from `now` until the first of `slot`'s duties that does not + /// wait on another: the attestation or the sync committee message. + /// + /// What the loop sleeps between a slot's proposal and the rest of its + /// duties. Sleeping on the attestation alone would hold a sync message + /// back when the network moves it earlier (gloas does). + pub fn until_first_slot_duty(&self, slot: Slot, now: SystemTime) -> Duration { + self.until_attestation(slot, now) + .min(self.until_sync_message(slot, now)) + } + /// The next slot to serve, given the last one served, and how long until /// it begins. /// @@ -588,6 +638,68 @@ mod tests { ); } + #[test] + fn the_sync_offsets_follow_the_fork_of_the_slot() { + let config = Config::mainnet().with_fork_epoch(ForkName::Gloas, 2); + let clock = SlotClock::from_config(GENESIS, &config); + let last_fulu = 2 * SLOTS_PER_EPOCH - 1; + let first_gloas = 2 * SLOTS_PER_EPOCH; + let ms = + |t: SystemTime, slot: u64| t.duration_since(clock.start_of(slot)).expect("after start"); + + assert_eq!( + ms(clock.sync_message_time(last_fulu), last_fulu), + Duration::from_millis(3_999) + ); + assert_eq!( + ms(clock.contribution_time(last_fulu), last_fulu), + Duration::from_millis(8_000) + ); + assert_eq!( + ms(clock.sync_message_time(first_gloas), first_gloas), + Duration::from_millis(3_000) + ); + assert_eq!( + ms(clock.contribution_time(first_gloas), first_gloas), + Duration::from_millis(6_000) + ); + } + + #[test] + fn the_wait_until_the_sync_message_is_the_rest_of_the_offset() { + let clock = clock(); + let now = at(5 * SECONDS_PER_SLOT + 1); + assert_eq!( + clock.until_sync_message(5, now), + Duration::from_millis(2_999) + ); + assert_eq!( + clock.until_contribution(5, now), + Duration::from_millis(6_999) + ); + assert_eq!( + clock.until_sync_message(5, at(5 * SECONDS_PER_SLOT + 9)), + Duration::ZERO + ); + } + + #[test] + fn the_first_slot_duty_is_the_earlier_of_attestation_and_sync_message() { + let mut config = Config::mainnet().with_fork_epoch(ForkName::Gloas, 100); + config.sync_message_due_bps = 2_000; + let clock = SlotClock::from_config(GENESIS, &config); + let start = clock.start_of(5); + assert_eq!( + clock.until_first_slot_duty(5, start), + Duration::from_millis(2_400), + "the sync message is due before the attestation" + ); + assert_eq!( + clock.until_first_slot_duty(5, start + Duration::from_secs(10)), + Duration::ZERO + ); + } + #[test] fn the_payload_attestation_deadline_is_three_quarters_in() { let config = Config::mainnet().with_fork_epoch(ForkName::Gloas, 0); From 5f47643a73ce9aefdd54cf5952aadd95f8eaa7aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:44:05 -0300 Subject: [PATCH 05/11] feat(validator): serve sync committee duties in the slot loop A client holding sync committee seats now signs the head root at the sync message deadline, aggregates for the subnets its selection proof picks at the contribution deadline, and re-sends its subnet subscriptions every epoch (the node forgets them on restart), adding the next period's once its boundary is four epochs away. The sync work runs beside the attester and PTC work under tokio::join!, each half bounded by the slot, because gloas moves the sync deadline before the attestation and an ordered loop would hold it back. A subnet whose contribution cannot be fetched or does not match the request is skipped without stopping the others. --- crates/validator/Cargo.toml | 2 +- crates/validator/src/beacon_node/mock.rs | 9 +- crates/validator/src/beacon_node/mod.rs | 7 +- crates/validator/src/lib.rs | 301 +++++++++++- crates/validator/src/signing.rs | 4 +- crates/validator/src/slot_clock.rs | 2 +- crates/validator/src/subscriptions.rs | 57 ++- crates/validator/src/sync_committee.rs | 600 +++++++++++++++++++++++ docs/cli.md | 25 +- docs/metrics.md | 5 + 10 files changed, 990 insertions(+), 22 deletions(-) create mode 100644 crates/validator/src/sync_committee.rs diff --git a/crates/validator/Cargo.toml b/crates/validator/Cargo.toml index 1270c639..c227e995 100644 --- a/crates/validator/Cargo.toml +++ b/crates/validator/Cargo.toml @@ -29,7 +29,7 @@ tracing.workspace = true # `time` for the duty loop's sleep between slots. Declared here rather than # relied on through workspace feature unification, so this crate still builds # if it is ever compiled on its own. -tokio = { workspace = true, features = ["time"] } +tokio = { workspace = true, features = ["time", "macros"] } reqwest = { workspace = true, features = ["json"] } # BLS12-381. Same version and backend the state transition verifies with, so a diff --git a/crates/validator/src/beacon_node/mock.rs b/crates/validator/src/beacon_node/mock.rs index 0f4ab658..fe4a952b 100644 --- a/crates/validator/src/beacon_node/mock.rs +++ b/crates/validator/src/beacon_node/mock.rs @@ -904,10 +904,11 @@ impl BeaconNodeApi for MockBeaconNode { beacon_block_root: Root, ) -> Result { self.guard("sync_committee_contribution")?; - self.contribution_requests - .lock() - .expect("lock") - .push((slot, subcommittee_index, beacon_block_root)); + self.contribution_requests.lock().expect("lock").push(( + slot, + subcommittee_index, + beacon_block_root, + )); let contribution = self .contributions .iter() diff --git a/crates/validator/src/beacon_node/mod.rs b/crates/validator/src/beacon_node/mod.rs index fc6eb130..c689d2a1 100644 --- a/crates/validator/src/beacon_node/mod.rs +++ b/crates/validator/src/beacon_node/mod.rs @@ -491,8 +491,11 @@ pub trait BeaconNodeApi: Send + Sync { /// The sync committee duties for `indices` in the period `epoch` falls in. /// `POST /eth/v1/validator/duties/sync/{epoch}`, whose answer carries no /// `dependent_root`: the committee is fixed a period ahead. - async fn sync_duties(&self, epoch: Epoch, indices: &[ValidatorIndex]) - -> Result>; + async fn sync_duties( + &self, + epoch: Epoch, + indices: &[ValidatorIndex], + ) -> Result>; /// The root of the node's head block, which a sync committee message signs. /// diff --git a/crates/validator/src/lib.rs b/crates/validator/src/lib.rs index 95c2c9cb..7b217ab0 100644 --- a/crates/validator/src/lib.rs +++ b/crates/validator/src/lib.rs @@ -46,6 +46,7 @@ pub(crate) mod secure_fs; pub mod signing; pub mod slot_clock; pub mod subscriptions; +pub mod sync_committee; pub use error::{Error, Result}; @@ -69,6 +70,7 @@ use crate::payload_attestation::PayloadAttestationService; use crate::proposal::ProposalService; use crate::signing::SigningContext; use crate::slot_clock::SlotClock; +use crate::sync_committee::SyncCommitteeService; /// Everything the client is configured with at startup. #[derive(Debug, Clone)] @@ -179,6 +181,7 @@ pub async fn run(config: ValidatorConfig) -> Result<()> { let attestation = AttestationService::new(beacon_node.clone(), context.clone()); let aggregation = AggregationService::new(beacon_node.clone(), context.clone()); let payload_attestation = PayloadAttestationService::new(beacon_node.clone(), context.clone()); + let sync_committee = SyncCommitteeService::new(beacon_node.clone(), context.clone()); let proposal = ProposalService::new( beacon_node.clone(), context.clone(), @@ -289,21 +292,26 @@ pub async fn run(config: ValidatorConfig) -> Result<()> { propose(&proposal, &clock, slot, &duty, &store).await; } - // The rest of the way to the attester offset, one third into the slot. - // Zero if the refresh or the proposal above already ran past it, in - // which case this slot's attestation is late rather than skipped. - tokio::time::sleep(clock.until_attestation(slot, SystemTime::now())).await; + // The rest of the way to the first of the slot's remaining duties: + // the attester offset, or the sync committee offset when the network + // puts it earlier. Zero if the refresh or the proposal above already + // ran past it, in which case the duty is late rather than skipped. + // Each branch of `serve_slot_duties` waits for its own offset. + tokio::time::sleep(clock.until_first_slot_duty(slot, SystemTime::now())).await; let slot_duties = duties.at_slot(slot, epoch); let committee = duties.ptc_at_slot(slot, epoch); + let sync_duties = duties.sync_at_slot(slot); serve_slot_duties( &attestation, &aggregation, &payload_attestation, + &sync_committee, &clock, slot, &slot_duties, &committee, + &sync_duties, &store, ) .await; @@ -327,19 +335,91 @@ async fn serve_slot_duties( attestation: &AttestationService, aggregation: &AggregationService, payload_attestation: &PayloadAttestationService, + sync_committee: &SyncCommitteeService, clock: &SlotClock, slot: u64, slot_duties: &[crate::beacon_node::dto::AttesterDutyDto], committee: &[crate::beacon_node::dto::PtcDutyDto], + sync_duties: &[crate::beacon_node::dto::SyncDutyDto], store: &RwLock, ) { - if !slot_duties.is_empty() { - attest_and_aggregate(attestation, aggregation, clock, slot, slot_duties, store).await; + // The sync committee work runs beside the attester and PTC work rather + // than after it: its deadline is not ordered against theirs (gloas moves + // it before the attestation) and each is bounded by the slot on its own. + let attest_then_ptc = async { + if !slot_duties.is_empty() { + tokio::time::sleep(clock.until_attestation(slot, SystemTime::now())).await; + attest_and_aggregate(attestation, aggregation, clock, slot, slot_duties, store).await; + } + + if clock.is_gloas(slot) && !committee.is_empty() { + tokio::time::sleep(clock.until_payload_attestation(slot, SystemTime::now())).await; + payload_attest(payload_attestation, clock, slot, committee, store).await; + } + }; + tokio::join!( + attest_then_ptc, + sync_committee_duty(sync_committee, clock, slot, sync_duties, store) + ); +} + +/// Run one slot's sync committee duty: sign the head at the message deadline, +/// then aggregate at the contribution deadline if a root was signed. +/// +/// Each half is bounded by the end of the slot, like the other duties, and +/// failures are logged and counted rather than propagated. Nothing here is +/// slashable, so abandoning a half costs only that half. +async fn sync_committee_duty( + service: &SyncCommitteeService, + clock: &SlotClock, + slot: u64, + duties: &[crate::beacon_node::dto::SyncDutyDto], + store: &RwLock, +) { + if duties.is_empty() { + return; } + tokio::time::sleep(clock.until_sync_message(slot, SystemTime::now())).await; + let budget = clock.remaining_in(slot, SystemTime::now()); + let root = + match tokio::time::timeout(budget, service.publish_messages(slot, duties, store)).await { + Ok(Ok(root)) => root, + Ok(Err(err)) => { + error!(%slot, %err, "Failed to publish sync committee messages for this slot"); + metrics::inc_sync_committee_failures(); + metrics::set_beacon_node_available(false); + return; + } + Err(_) => { + warn!( + %slot, + budget_ms = budget.as_millis() as u64, + "Sync committee messages ran past the end of their slot and were abandoned" + ); + metrics::inc_sync_committee_failures(); + return; + } + }; + let Some(root) = root else { + return; + }; - if clock.is_gloas(slot) && !committee.is_empty() { - tokio::time::sleep(clock.until_payload_attestation(slot, SystemTime::now())).await; - payload_attest(payload_attestation, clock, slot, committee, store).await; + tokio::time::sleep(clock.until_contribution(slot, SystemTime::now())).await; + let budget = clock.remaining_in(slot, SystemTime::now()); + match tokio::time::timeout(budget, service.aggregate(slot, root, duties, store)).await { + Ok(Ok(_)) => {} + Ok(Err(err)) => { + error!(%slot, %err, "Failed to publish this slot's sync contributions"); + metrics::inc_sync_contribution_failures(); + } + Err(_) => { + warn!( + %slot, + budget_ms = budget.as_millis() as u64, + "Sync aggregation ran past the end of its slot and was abandoned" + ); + metrics::inc_sync_contribution_failures(); + } } } @@ -638,6 +718,7 @@ async fn refresh_epoch( let changed = duties.refresh_around(epoch).await?; refresh_ptc(duties, epoch, context).await; + refresh_sync(beacon_node, duties, epoch, context).await; metrics::set_duties_held(duties.all().len() as u64); if changed { subscriptions::subscribe(beacon_node, &duties.all(), store, context).await?; @@ -674,6 +755,63 @@ async fn refresh_ptc( duties.prune_ptc_before(epoch); } +/// How many epochs before a period boundary the next period's subnets are +/// joined: lighthouse's lookahead, at the specification's upper bound +/// `SYNC_COMMITTEE_SUBNET_COUNT` for the random early-join window. +const SYNC_SUBSCRIPTION_LOOKAHEAD_EPOCHS: u64 = + ethlambda_types::beacon::constants::SYNC_COMMITTEE_SUBNET_COUNT as u64; + +/// Fetch the sync committee schedule for this period and the next, and ask the +/// node to join the subnets involved. +/// +/// Only from altair. Failures are logged and swallowed, like the payload +/// committee's: they must not cost this epoch's attester schedule. +/// +/// Subscriptions are sent every epoch because the node forgets them on +/// restart. The current period's run to its end; the next period's are added +/// once the boundary is within [`SYNC_SUBSCRIPTION_LOOKAHEAD_EPOCHS`], so the +/// subnets are already joined when the new committee starts signing. +async fn refresh_sync( + beacon_node: &Arc, + duties: &mut DutiesService, + epoch: u64, + context: &SigningContext, +) { + use ethlambda_types::beacon::fork::ForkName; + use ethlambda_types::beacon::preset::EPOCHS_PER_SYNC_COMMITTEE_PERIOD as PERIOD_EPOCHS; + + if context.config.fork_at_epoch(epoch) < ForkName::Altair { + return; + } + let period = crate::duties::sync_committee_period(epoch); + for target in [period, period + 1] { + if let Err(err) = duties.refresh_sync(target).await { + warn!( + period = target, + %err, + "Sync committee duties fetch failed; this client may miss its messages" + ); + } + } + duties.prune_sync_before(period); + metrics::set_sync_duties_held(duties.sync_for_period(period).len() as u64); + + let current = duties.sync_for_period(period); + if let Err(err) = + subscriptions::subscribe_sync(beacon_node, ¤t, (period + 1) * PERIOD_EPOCHS).await + { + warn!(%err, "Failed to subscribe to sync committee subnets"); + } + if epoch + SYNC_SUBSCRIPTION_LOOKAHEAD_EPOCHS >= (period + 1) * PERIOD_EPOCHS { + let next = duties.sync_for_period(period + 1); + if let Err(err) = + subscriptions::subscribe_sync(beacon_node, &next, (period + 2) * PERIOD_EPOCHS).await + { + warn!(%err, "Failed to subscribe to the next period's sync committee subnets"); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -996,10 +1134,12 @@ mod tests { &AttestationService::new(node.clone(), context.clone()), &AggregationService::new(node.clone(), context.clone()), &PayloadAttestationService::new(node.clone(), context.clone()), + &SyncCommitteeService::new(node.clone(), context.clone()), &clock, slot, &[], &committee, + &[], &store, ) .await; @@ -1008,6 +1148,147 @@ mod tests { assert_eq!(node.submitted_payload_attestations().len(), 1); } + fn altair_context() -> SigningContext { + SigningContext { + config: ethlambda_types::beacon::config::Config::mainnet() + .with_fork_epoch(ethlambda_types::beacon::fork::ForkName::Altair, 0), + genesis_validators_root: Root::ZERO, + } + } + + fn sync_duty(index: u64, seats: Vec) -> crate::beacon_node::dto::SyncDutyDto { + crate::beacon_node::dto::SyncDutyDto { + pubkey: crate::beacon_node::dto::encode_hex(&[0u8; 48]), + validator_index: index, + validator_sync_committee_indices: seats, + } + } + + /// Both periods' duties are fetched, and the current period's subnets are + /// subscribed every refresh, to the period's end. + #[tokio::test] + async fn sync_duties_for_both_periods_are_fetched_and_subscribed() { + use ethlambda_types::beacon::preset::EPOCHS_PER_SYNC_COMMITTEE_PERIOD as PERIOD; + + let node = Arc::new( + node() + .with_sync_duties(0, vec![sync_duty(11, vec![3, 130])]) + .with_sync_duties(PERIOD, vec![sync_duty(11, vec![200])]), + ); + let mut duties = DutiesService::new(node.clone(), Vec::new()); + let keys = [pubkey(1), pubkey(2)]; + for _ in 0..2 { + refresh_epoch( + &node, + &mut duties, + &keys, + 3, + None, + &empty_store(), + &altair_context(), + ) + .await + .expect("refreshes"); + } + + let requested: Vec = node + .sync_duty_requests() + .iter() + .map(|(epoch, _)| *epoch) + .collect(); + assert_eq!(requested, vec![0, PERIOD, 0, PERIOD]); + assert_eq!(duties.sync_for_period(1).len(), 1); + + let sent = node.sync_subscriptions(); + assert_eq!(sent.len(), 2, "re-sent every epoch, current period only"); + assert_eq!(sent[0].sync_committee_indices, vec![3, 130]); + assert_eq!(sent[0].until_epoch, PERIOD); + } + + /// Not asked before altair. + #[tokio::test] + async fn no_sync_duties_are_fetched_before_altair() { + let node = Arc::new(node()); + refresh(node.clone(), None).await; + assert!(node.sync_duty_requests().is_empty()); + } + + /// A failed sync fetch must not cost the epoch's attester schedule. + #[tokio::test] + async fn a_failed_sync_fetch_does_not_fail_the_refresh() { + let node = Arc::new(node().failing_call("sync_duties", "node is unhappy")); + let mut duties = DutiesService::new(node.clone(), Vec::new()); + let keys = [pubkey(1), pubkey(2)]; + refresh_epoch( + &node, + &mut duties, + &keys, + 3, + None, + &empty_store(), + &altair_context(), + ) + .await + .expect("the refresh survives it"); + assert_eq!(duties.indices(), &[11, 22]); + } + + /// A sync committee member with no other duty in the slot still signs. + #[tokio::test] + async fn a_sync_committee_member_with_no_other_duty_still_signs() { + use crate::beacon_node::mock::MockBeaconNode; + + let mut config = ethlambda_types::beacon::config::Config::mainnet() + .with_fork_epoch(ethlambda_types::beacon::fork::ForkName::Altair, 0); + config.slot_duration_ms = 1_000; + let context = Arc::new(SigningContext { + config: config.clone(), + genesis_validators_root: Root::ZERO, + }); + let now_secs = SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("after the epoch") + .as_secs(); + let clock = SlotClock::from_config(now_secs - 100, &config); + let slot = clock.now().expect("after genesis") + 1; + + let mut store = ValidatorStore::new(); + let secret: [u8; 32] = + hex::decode("000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f") + .expect("hex") + .try_into() + .expect("32 bytes"); + let key = store.insert_secret("test", &secret).expect("inserts"); + let store = RwLock::new(store); + + let node = Arc::new(MockBeaconNode::new().with_head_root(Root::repeat_byte(5))); + let duties = [crate::beacon_node::dto::SyncDutyDto { + pubkey: crate::beacon_node::dto::encode_hex(&key.0), + validator_index: 3, + validator_sync_committee_indices: vec![0], + }]; + + serve_slot_duties( + &AttestationService::new(node.clone(), context.clone()), + &AggregationService::new(node.clone(), context.clone()), + &PayloadAttestationService::new(node.clone(), context.clone()), + &SyncCommitteeService::new(node.clone(), context.clone()), + &clock, + slot, + &[], + &[], + &duties, + &store, + ) + .await; + + assert_eq!(node.attestation_data_call_count(), 0, "no attester duty"); + let sent = node.submitted_sync_messages(); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].slot, slot); + assert_eq!(sent[0].beacon_block_root, Root::repeat_byte(5)); + } + /// Before gloas there is no committee: even a stale schedule entry is not /// acted on. #[tokio::test] @@ -1027,10 +1308,12 @@ mod tests { &AttestationService::new(node.clone(), context.clone()), &AggregationService::new(node.clone(), context.clone()), &PayloadAttestationService::new(node.clone(), context.clone()), + &SyncCommitteeService::new(node.clone(), context.clone()), &clock, 5, &[], &[ptc_duty(3, 5)], + &[], &store, ) .await; diff --git a/crates/validator/src/signing.rs b/crates/validator/src/signing.rs index 12112b77..ced20d40 100644 --- a/crates/validator/src/signing.rs +++ b/crates/validator/src/signing.rs @@ -11,7 +11,9 @@ use ethlambda_types::beacon::constants::{ DOMAIN_BEACON_PROPOSER, DOMAIN_CONTRIBUTION_AND_PROOF, DOMAIN_PTC_ATTESTER, DOMAIN_RANDAO, DOMAIN_SELECTION_PROOF, DOMAIN_SYNC_COMMITTEE, DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF, }; -use ethlambda_types::beacon::containers::altair::{ContributionAndProof, SyncAggregatorSelectionData}; +use ethlambda_types::beacon::containers::altair::{ + ContributionAndProof, SyncAggregatorSelectionData, +}; use ethlambda_types::beacon::containers::gloas::{ ExecutionPayloadEnvelope, PayloadAttestationData, }; diff --git a/crates/validator/src/slot_clock.rs b/crates/validator/src/slot_clock.rs index 34d12e15..28aeb3db 100644 --- a/crates/validator/src/slot_clock.rs +++ b/crates/validator/src/slot_clock.rs @@ -675,7 +675,7 @@ mod tests { ); assert_eq!( clock.until_contribution(5, now), - Duration::from_millis(6_999) + Duration::from_millis(7_000) ); assert_eq!( clock.until_sync_message(5, at(5 * SECONDS_PER_SLOT + 9)), diff --git a/crates/validator/src/subscriptions.rs b/crates/validator/src/subscriptions.rs index b1819adb..5de97578 100644 --- a/crates/validator/src/subscriptions.rs +++ b/crates/validator/src/subscriptions.rs @@ -13,7 +13,11 @@ use tracing::{info, warn}; use crate::aggregation_selection::selection_for; use crate::beacon_node::BeaconNodeApi; -use crate::beacon_node::dto::{AttesterDutyDto, CommitteeSubscriptionDto}; +use ethlambda_types::beacon::primitives::Epoch; + +use crate::beacon_node::dto::{ + AttesterDutyDto, CommitteeSubscriptionDto, SyncCommitteeSubscriptionDto, SyncDutyDto, +}; use crate::error::Result; use crate::keys::ValidatorStore; use crate::signing::SigningContext; @@ -86,6 +90,33 @@ pub async fn subscribe( beacon_node.subscribe_committees(&subscriptions).await } +/// Tell the node which sync committee seats this client holds, so it joins +/// their subnets until `until_epoch` (exclusive). One entry per duty. +/// +/// Re-sent every epoch by the caller, since a node forgets on restart. +pub async fn subscribe_sync( + beacon_node: &Arc, + duties: &[SyncDutyDto], + until_epoch: Epoch, +) -> Result<()> { + if duties.is_empty() { + return Ok(()); + } + let subscriptions: Vec = duties + .iter() + .map(|duty| SyncCommitteeSubscriptionDto { + validator_index: duty.validator_index, + sync_committee_indices: duty.validator_sync_committee_indices.clone(), + until_epoch, + }) + .collect(); + info!( + count = subscriptions.len(), + until_epoch, "Subscribing to sync committee subnets" + ); + beacon_node.subscribe_sync_committees(&subscriptions).await +} + #[cfg(test)] mod tests { use super::*; @@ -155,6 +186,30 @@ mod tests { assert_eq!(sent[2].slot, 97); } + #[tokio::test] + async fn one_sync_subscription_is_sent_per_duty() { + let node = Arc::new(MockBeaconNode::new()); + let duties = vec![ + SyncDutyDto { + pubkey: "0x00".to_string(), + validator_index: 1, + validator_sync_committee_indices: vec![3, 130], + }, + SyncDutyDto { + pubkey: "0x00".to_string(), + validator_index: 2, + validator_sync_committee_indices: vec![400], + }, + ]; + subscribe_sync(&node, &duties, 256).await.expect("sends"); + subscribe_sync(&node, &[], 256).await.expect("no-op"); + + let sent = node.sync_subscriptions(); + assert_eq!(sent.len(), 2); + assert_eq!(sent[0].sync_committee_indices, vec![3, 130]); + assert_eq!(sent[1].until_epoch, 256); + } + #[tokio::test] async fn nothing_is_sent_when_there_are_no_duties() { let node = Arc::new(MockBeaconNode::new()); diff --git a/crates/validator/src/sync_committee.rs b/crates/validator/src/sync_committee.rs new file mode 100644 index 00000000..7501895e --- /dev/null +++ b/crates/validator/src/sync_committee.rs @@ -0,0 +1,600 @@ +//! Sync committee duties: signing the head each slot, and aggregating. +//! +//! # What is owed +//! +//! A member of the current sync committee signs the head block root once per +//! slot (`SyncCommitteeMessage`), at the sync-message deadline. The 512 seats +//! are split into four subcommittees; within each, a few members are selected +//! by a hash of their selection proof to fold the subcommittee's messages into +//! a `SyncCommitteeContribution` and publish it, which is how the messages +//! reach a block cheaply. +//! +//! # Which slot, and which root +//! +//! The message carries the wall slot and the head root at the deadline, which +//! may be an older block when slots were skipped. A member assigned to wall +//! slot `S` signs for `S - 1`; the caller picks the duties accordingly (see +//! [`crate::duties::DutiesService::sync_at_slot`]). +//! +//! # Optimistic heads +//! +//! The optimistic-sync specification forbids signing `DOMAIN_SYNC_COMMITTEE` +//! over an optimistic head, so [`BeaconNodeApi::head_block_root`] fails with +//! `BeaconNodeSyncing` for one, and nothing is signed. +//! +//! # Duplicates +//! +//! Not slashable. The slot is recorded per validator at signing time only so a +//! retried call does not publish the same message twice. In memory only. + +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::sync::Arc; + +use ethlambda_types::beacon::constants::{ + SYNC_COMMITTEE_SUBNET_COUNT, TARGET_AGGREGATORS_PER_SYNC_SUBCOMMITTEE, +}; +use ethlambda_types::beacon::containers::altair::{ + ContributionAndProof, SYNC_SUBCOMMITTEE_SIZE, SignedContributionAndProof, + SyncCommitteeContribution, SyncCommitteeMessage, +}; +use ethlambda_types::beacon::primitives::{BlsPubkey, BlsSignature, Root, Slot, ValidatorIndex}; +use sha2::{Digest, Sha256}; +use tokio::sync::RwLock; +use tracing::{error, info, warn}; + +use crate::beacon_node::dto::{SyncDutyDto, parse_pubkey}; +use crate::beacon_node::{BeaconNodeApi, validate_sync_contribution}; +use crate::error::{Error, Result}; +use crate::keys::ValidatorStore; +use crate::signing::SigningContext; + +/// How many slots of dedup history are kept. +const HISTORY_SLOTS: u64 = 64; + +/// The specification's `is_sync_committee_aggregator`: the first eight bytes of +/// the selection proof's SHA-256, little-endian, divide evenly by +/// `max(1, SYNC_SUBCOMMITTEE_SIZE / TARGET_AGGREGATORS_PER_SYNC_SUBCOMMITTEE)`. +pub fn is_sync_committee_aggregator(selection_proof: &BlsSignature) -> bool { + let modulo = (SYNC_SUBCOMMITTEE_SIZE as u64 / TARGET_AGGREGATORS_PER_SYNC_SUBCOMMITTEE).max(1); + let digest = Sha256::digest(selection_proof.0); + let mut head = [0u8; 8]; + head.copy_from_slice(&digest[..8]); + u64::from_le_bytes(head) % modulo == 0 +} + +/// The subnets a duty's seats fall in: `seat / SYNC_SUBCOMMITTEE_SIZE`. A seat +/// beyond the committee is dropped with a warning, since no subnet carries it. +pub fn subnets_of(duty: &SyncDutyDto) -> BTreeSet { + duty.validator_sync_committee_indices + .iter() + .filter_map(|seat| { + let subnet = seat / SYNC_SUBCOMMITTEE_SIZE as u64; + if subnet < SYNC_COMMITTEE_SUBNET_COUNT as u64 { + Some(subnet) + } else { + warn!( + validator = duty.validator_index, + seat, "Sync committee seat is outside the committee; ignoring it" + ); + None + } + }) + .collect() +} + +pub struct SyncCommitteeService { + beacon_node: Arc, + context: Arc, + /// Which (validator, slot) pairs already produced a message signature. + done: std::sync::Mutex>, +} + +impl SyncCommitteeService { + pub fn new(beacon_node: Arc, context: Arc) -> Self { + Self { + beacon_node, + context, + done: std::sync::Mutex::new(HashSet::new()), + } + } + + /// Sign and submit this client's sync committee messages for `slot`. + /// + /// Returns the root signed over, so the aggregation that follows asks for + /// contributions on the same one. `None` when there is nothing to do: no + /// pending duty, or a head that is optimistic. + pub async fn publish_messages( + &self, + slot: Slot, + duties: &[SyncDutyDto], + store: &RwLock, + ) -> Result> { + let pending: Vec<&SyncDutyDto> = { + let done = self.lock(); + duties + .iter() + .filter(|duty| !done.contains(&(duty.validator_index, slot))) + .collect() + }; + if pending.is_empty() { + return Ok(None); + } + + let root = match self.beacon_node.head_block_root().await { + Ok(root) => root, + Err(Error::BeaconNodeSyncing) => { + warn!(%slot, "Head is optimistic or the node is syncing; not signing sync committee messages"); + return Ok(None); + } + Err(err) => return Err(err), + }; + + let mut messages = Vec::with_capacity(pending.len()); + { + let _timing = crate::metrics::time_signing(); + let store = store.read().await; + for duty in pending { + let pubkey = match parse_pubkey(&duty.pubkey) { + Ok(pubkey) => pubkey, + Err(err) => { + error!(%slot, validator = duty.validator_index, %err, "Duty carried an unreadable pubkey"); + continue; + } + }; + if !self.record(duty.validator_index, slot) { + continue; + } + match self + .context + .sign_sync_committee_message(&store, &pubkey, slot, root) + { + Ok(signature) => messages.push(SyncCommitteeMessage { + slot, + beacon_block_root: root, + validator_index: duty.validator_index, + signature, + }), + Err(err) => { + error!(%slot, validator = duty.validator_index, %err, "Failed to sign sync committee message"); + crate::metrics::inc_signing_failures(); + } + } + } + } + if messages.is_empty() { + return Ok(None); + } + + let submitted = messages.len(); + let published = self + .beacon_node + .submit_sync_committee_messages(&messages) + .await?; + if published < submitted { + warn!(%slot, published, submitted, "Some sync committee messages in this slot's batch were rejected"); + } + info!(%slot, count = published, "Published sync committee messages"); + crate::metrics::inc_sync_committee_messages_published(published as u64); + Ok(Some(root)) + } + + /// Publish the contributions this client was selected to aggregate for + /// `slot` over `beacon_block_root`. Returns how many were published. + /// + /// One request per subnet, shared by every selected validator in it. A + /// subnet whose request fails is skipped with a warning so the others still + /// go out. + pub async fn aggregate( + &self, + slot: Slot, + beacon_block_root: Root, + duties: &[SyncDutyDto], + store: &RwLock, + ) -> Result { + // (validator, pubkey, subnet, selection proof) for each selected pair. + let mut selected: Vec<(ValidatorIndex, BlsPubkey, u64, BlsSignature)> = Vec::new(); + { + let store = store.read().await; + for duty in duties { + let pubkey = match parse_pubkey(&duty.pubkey) { + Ok(pubkey) => pubkey, + Err(err) => { + error!(%slot, validator = duty.validator_index, %err, "Duty carried an unreadable pubkey"); + continue; + } + }; + for subnet in subnets_of(duty) { + match self + .context + .sign_sync_selection_proof(&store, &pubkey, slot, subnet) + { + Ok(proof) if is_sync_committee_aggregator(&proof) => { + selected.push((duty.validator_index, pubkey, subnet, proof)); + } + Ok(_) => {} + Err(err) => { + error!(%slot, validator = duty.validator_index, %err, "Failed to sign sync selection proof"); + crate::metrics::inc_signing_failures(); + } + } + } + } + } + if selected.is_empty() { + return Ok(0); + } + + let mut fetched: HashMap> = HashMap::new(); + for (_, _, subnet, _) in &selected { + if fetched.contains_key(subnet) { + continue; + } + let answer = match self + .beacon_node + .sync_committee_contribution(slot, *subnet, beacon_block_root) + .await + { + // Checked again, as the attestation duty does: the trait is + // public and this is the last point before a signature. + Ok(contribution) => { + match validate_sync_contribution( + slot, + *subnet, + beacon_block_root, + &contribution, + ) { + Ok(()) => Some(contribution), + Err(err) => { + warn!(%slot, subnet, %err, "Discarding a mismatched sync contribution"); + None + } + } + } + Err(err) => { + warn!(%slot, subnet, %err, "No sync contribution for this subnet; skipping it"); + None + } + }; + fetched.insert(*subnet, answer); + } + + let mut signed = Vec::new(); + { + let store = store.read().await; + for (validator, pubkey, subnet, proof) in selected { + let Some(Some(contribution)) = fetched.get(&subnet) else { + continue; + }; + let message = ContributionAndProof { + aggregator_index: validator, + contribution: contribution.clone(), + selection_proof: proof, + }; + match self + .context + .sign_contribution_and_proof(&store, &pubkey, &message) + { + Ok(signature) => signed.push(SignedContributionAndProof { message, signature }), + Err(err) => { + error!(%slot, validator, %err, "Failed to sign contribution and proof"); + crate::metrics::inc_signing_failures(); + } + } + } + } + if signed.is_empty() { + return Ok(0); + } + + self.beacon_node + .publish_contribution_and_proofs(&signed) + .await?; + info!(%slot, count = signed.len(), "Published sync committee contributions"); + crate::metrics::inc_sync_contributions_published(signed.len() as u64); + Ok(signed.len()) + } + + fn record(&self, validator: ValidatorIndex, slot: Slot) -> bool { + let Ok(mut done) = self.done.lock() else { + error!(%slot, "Sync committee record is poisoned; refusing to sign"); + return false; + }; + done.retain(|(_, held)| held + HISTORY_SLOTS > slot); + done.insert((validator, slot)) + } + + fn lock(&self) -> std::sync::MutexGuard<'_, HashSet<(ValidatorIndex, Slot)>> { + self.done + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::beacon_node::dto::encode_hex; + use crate::beacon_node::mock::MockBeaconNode; + use ethlambda_types::beacon::config::Config; + + const DST: &[u8] = b"BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_"; + + fn secret() -> [u8; 32] { + hex::decode("000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f") + .expect("valid hex") + .try_into() + .expect("32 bytes") + } + + fn context() -> Arc { + Arc::new(SigningContext { + config: Config::mainnet(), + genesis_validators_root: Root::ZERO, + }) + } + + fn store() -> (RwLock, BlsPubkey) { + let mut store = ValidatorStore::new(); + let pubkey = store.insert_secret("test", &secret()).expect("inserts"); + (RwLock::new(store), pubkey) + } + + fn duty(pubkey: &BlsPubkey, validator_index: u64, seats: Vec) -> SyncDutyDto { + SyncDutyDto { + pubkey: encode_hex(&pubkey.0), + validator_index, + validator_sync_committee_indices: seats, + } + } + + fn verify(pubkey: &BlsPubkey, signature: &BlsSignature, root: Root) -> bool { + use blst::min_pk::{PublicKey, Signature}; + let pk = PublicKey::from_bytes(&pubkey.0).expect("valid pubkey"); + let sig = Signature::from_bytes(&signature.0).expect("valid signature"); + sig.verify(true, root.as_slice(), DST, &[], &pk, true) == blst::BLST_ERROR::BLST_SUCCESS + } + + fn contribution(slot: Slot, root: Root, subnet: u64) -> SyncCommitteeContribution { + SyncCommitteeContribution { + slot, + beacon_block_root: root, + subcommittee_index: subnet, + aggregation_bits: Default::default(), + signature: BlsSignature([1; 96]), + } + } + + /// A signature hashing to a multiple of the modulus, or to a non-multiple. + fn found(selected: bool) -> BlsSignature { + let modulo = + (SYNC_SUBCOMMITTEE_SIZE as u64 / TARGET_AGGREGATORS_PER_SYNC_SUBCOMMITTEE).max(1); + for seed in 0u32..100_000 { + let mut bytes = [0u8; 96]; + bytes[..4].copy_from_slice(&seed.to_le_bytes()); + let digest = Sha256::digest(bytes); + let mut head = [0u8; 8]; + head.copy_from_slice(&digest[..8]); + if (u64::from_le_bytes(head) % modulo == 0) == selected { + return BlsSignature(bytes); + } + } + panic!("no signature found"); + } + + #[test] + fn selection_reads_the_digest_little_endian_with_the_specs_modulus() { + assert!(is_sync_committee_aggregator(&found(true))); + assert!(!is_sync_committee_aggregator(&found(false))); + } + + #[test] + fn seats_map_to_subnets_and_stray_ones_are_dropped() { + let (_, pubkey) = store(); + let size = SYNC_SUBCOMMITTEE_SIZE as u64; + let subnets = subnets_of(&duty( + &pubkey, + 1, + vec![0, 1, size, 3 * size + 5, 4 * size, 10_000], + )); + assert_eq!(subnets, BTreeSet::from([0, 1, 3])); + } + + #[tokio::test] + async fn one_message_per_duty_is_signed_over_the_head_root_and_deduplicated() { + let root = Root::repeat_byte(7); + let node = Arc::new(MockBeaconNode::new().with_head_root(root)); + let (store, pubkey) = store(); + let context = context(); + let service = SyncCommitteeService::new(node.clone(), context.clone()); + let duties = vec![duty(&pubkey, 1, vec![3, 130]), duty(&pubkey, 2, vec![9])]; + + let signed_over = service + .publish_messages(3200, &duties, &store) + .await + .expect("publishes"); + assert_eq!(signed_over, Some(root)); + + let sent = node.submitted_sync_messages(); + assert_eq!(sent.len(), 2, "one per validator, however many seats"); + for message in &sent { + assert_eq!(message.slot, 3200); + assert_eq!(message.beacon_block_root, root); + assert!(verify( + &pubkey, + &message.signature, + context.sync_committee_message_signing_root(3200, root) + )); + } + + let again = service + .publish_messages(3200, &duties, &store) + .await + .expect("second call"); + assert_eq!(again, None); + assert_eq!(node.submitted_sync_messages().len(), 2, "no duplicates"); + } + + #[tokio::test] + async fn an_optimistic_head_signs_nothing() { + let mut node = MockBeaconNode::new().with_head_root(Root::repeat_byte(7)); + node.head_optimistic = true; + let node = Arc::new(node); + let (store, pubkey) = store(); + let service = SyncCommitteeService::new(node.clone(), context()); + + let result = service + .publish_messages(3200, &[duty(&pubkey, 1, vec![3])], &store) + .await + .expect("not an error"); + assert_eq!(result, None); + assert!(node.submitted_sync_messages().is_empty()); + } + + /// Duty with a seat in every subnet, on the first slot where the selection + /// is a mix, so "only selected pairs fetch" is not satisfied by all or none. + fn mixed_slot(context: &SigningContext, store: &ValidatorStore, pubkey: &BlsPubkey) -> Slot { + for slot in 3200..4200 { + let picks = (0..4) + .filter(|subnet| { + let proof = context + .sign_sync_selection_proof(store, pubkey, slot, *subnet) + .expect("signs"); + is_sync_committee_aggregator(&proof) + }) + .count(); + if picks > 0 && picks < 4 { + return slot; + } + } + panic!("no mixed slot found"); + } + + fn all_subnet_duty(pubkey: &BlsPubkey) -> SyncDutyDto { + let size = SYNC_SUBCOMMITTEE_SIZE as u64; + duty(pubkey, 9, vec![0, size, 2 * size, 3 * size]) + } + + #[tokio::test] + async fn only_selected_pairs_fetch_and_a_published_contribution_verifies() { + let (store, pubkey) = store(); + let context = context(); + let slot = { + let guard = store.read().await; + mixed_slot(&context, &guard, &pubkey) + }; + let root = Root::repeat_byte(4); + let mut node = MockBeaconNode::new(); + for subnet in 0..4 { + node = node.with_contribution(contribution(slot, root, subnet)); + } + let node = Arc::new(node); + let service = SyncCommitteeService::new(node.clone(), context.clone()); + + let published = service + .aggregate(slot, root, &[all_subnet_duty(&pubkey)], &store) + .await + .expect("aggregates"); + + let guard = store.read().await; + let expected: Vec = (0..4) + .filter(|subnet| { + let proof = context + .sign_sync_selection_proof(&guard, &pubkey, slot, *subnet) + .expect("signs"); + is_sync_committee_aggregator(&proof) + }) + .collect(); + let requested: Vec = node + .contribution_requests() + .iter() + .map(|(_, subnet, _)| *subnet) + .collect(); + assert_eq!(requested, expected, "only selected subnets are asked"); + assert_eq!(published, expected.len()); + + let sent = node.published_contributions(); + assert_eq!(sent.len(), expected.len()); + for signed in &sent { + assert!(verify( + &pubkey, + &signed.signature, + context.contribution_and_proof_signing_root(&signed.message) + )); + assert!(verify( + &pubkey, + &signed.message.selection_proof, + context.sync_selection_proof_signing_root( + slot, + signed.message.contribution.subcommittee_index + ) + )); + } + } + + #[tokio::test] + async fn a_mismatched_contribution_is_not_published() { + let (store, pubkey) = store(); + let context = context(); + let slot = { + let guard = store.read().await; + mixed_slot(&context, &guard, &pubkey) + }; + let root = Root::repeat_byte(4); + let mut node = MockBeaconNode::new(); + for subnet in 0..4 { + // Every answer is about another root. + node = node.with_contribution(contribution(slot, Root::repeat_byte(5), subnet)); + } + let node = Arc::new(node); + let service = SyncCommitteeService::new(node.clone(), context); + + let published = service + .aggregate(slot, root, &[all_subnet_duty(&pubkey)], &store) + .await + .expect("not an error"); + assert_eq!(published, 0); + assert!(node.published_contributions().is_empty()); + } + + #[tokio::test] + async fn a_missing_contribution_on_one_subnet_does_not_stop_another() { + let (store, pubkey) = store(); + let context = context(); + // A slot where at least two subnets are selected. + let (slot, picked) = { + let guard = store.read().await; + (3200..6000) + .find_map(|slot| { + let picked: Vec = (0..4) + .filter(|subnet| { + let proof = context + .sign_sync_selection_proof(&guard, &pubkey, slot, *subnet) + .expect("signs"); + is_sync_committee_aggregator(&proof) + }) + .collect(); + (picked.len() >= 2).then_some((slot, picked)) + }) + .expect("a slot selecting two subnets") + }; + let root = Root::repeat_byte(4); + // Only the last picked subnet has a contribution; the first answers 404. + let kept = *picked.last().expect("nonempty"); + let node = + Arc::new(MockBeaconNode::new().with_contribution(contribution(slot, root, kept))); + let service = SyncCommitteeService::new(node.clone(), context); + + let published = service + .aggregate(slot, root, &[all_subnet_duty(&pubkey)], &store) + .await + .expect("aggregates"); + assert_eq!(published, 1); + assert_eq!( + node.published_contributions()[0] + .message + .contribution + .subcommittee_index, + kept + ); + } +} diff --git a/docs/cli.md b/docs/cli.md index 358d8a3b..8f82dca1 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -346,9 +346,26 @@ this order: propose at the boundary, attest at `ATTESTATION_DUE_BPS_GLOAS`, aggregate at `AGGREGATE_DUE_BPS_GLOAS`, then the committee vote. The attestation and the committee vote are each bounded by the end of the slot. +Sync committee duties run beside the attester and committee work, not after it. +Each epoch the client fetches its sync duties for the current period and the +next (`POST /eth/v1/validator/duties/sync/{epoch}`, asked at the period's first +epoch) and posts `sync_committee_subscriptions` for the current period's seats, +until the period's end, so the node joins the subnets. The next period's are +added once its boundary is within four epochs. A member sleeps until +`SYNC_MESSAGE_DUE_BPS` (`SYNC_MESSAGE_DUE_BPS_GLOAS` from gloas), reads +`/eth/v1/beacon/blocks/head/root`, signs it and submits one message per +validator. It refuses to sign when the node reports the head optimistic. A +member selected by the selection-proof hash for a subnet then waits for +`CONTRIBUTION_DUE_BPS` (`CONTRIBUTION_DUE_BPS_GLOAS`), fetches the node's +contribution for that subnet and publishes it wrapped and signed. A subnet whose +contribution cannot be fetched is skipped without stopping the others. Duties +for wall slot `S` are those of the period containing `S + 1`, since members +assigned to slot `S` sign for `S - 1`. Both halves are bounded by the end of the +slot. + Not implemented: the builder flow and blinded blocks (the client asks for an -unblinded block and refuses a blinded one), sync-committee duties, voluntary -exits, doppelganger protection and remote signing. +unblinded block and refuses a blinded one), voluntary exits, doppelganger +protection and remote signing. ### Duty offsets come from the network @@ -359,7 +376,9 @@ specification states them as `SLOT_DURATION_MS` plus basis points of it, work out at 3999 ms and 8000 ms. Gloas moves both earlier (`ATTESTATION_DUE_BPS_GLOAS`, `AGGREGATE_DUE_BPS_GLOAS`) and adds `PAYLOAD_ATTESTATION_DUE_BPS` (and `PAYLOAD_DUE_BPS`, which the node uses), so -the clock picks the pair by the fork of each slot's own epoch. +the clock picks the pair by the fork of each slot's own epoch. The sync +committee offsets (`SYNC_MESSAGE_DUE_BPS`, `CONTRIBUTION_DUE_BPS` and their +`_GLOAS` variants) are read and picked the same way. `SECONDS_PER_SLOT` no longer exists in the specification and is accepted only as a fallback, since deployed nodes still send it. A node sending both is required diff --git a/docs/metrics.md b/docs/metrics.md index cbdc72d6..2cfbf5ff 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -611,6 +611,11 @@ and absent is not the same as zero. | `ethlambda_validator_envelope_failures_total` | Counter | Proposed gloas blocks whose self-built envelope was not published. The block is out and the proposal counts as made, but the slot's payload is withheld, so this is the failure that costs the payload. Should read zero | | `ethlambda_validator_payload_attestations_published_total` | Counter | Payload timeliness committee votes a beacon node accepted (gloas) | | `ethlambda_validator_payload_attestation_failures_total` | Counter | Slots whose payload timeliness committee duty did not result in published votes | +| `ethlambda_validator_sync_committee_messages_published_total` | Counter | Sync committee messages a beacon node accepted | +| `ethlambda_validator_sync_committee_failures_total` | Counter | Slots whose sync committee message duty ended in an error or ran past its slot. A head the node reports optimistic is not a failure: nothing is signed | +| `ethlambda_validator_sync_contributions_published_total` | Counter | Sync committee contributions this client published as an aggregator. Bursty, like `aggregates_published_total` | +| `ethlambda_validator_sync_contribution_failures_total` | Counter | Sync aggregation duties that ended in an error or ran past their slot. A subnet with no contribution to fetch is skipped and not counted | +| `ethlambda_validator_sync_duties_held` | Gauge | Sync committee duties held for the current period | | `ethlambda_validator_aggregates_published_total` | Counter | Aggregates accepted by a beacon node. Bursty rather than steady: a validator is selected a few times a day, so hours at zero are normal for a small deployment | | `ethlambda_validator_aggregation_failures_total` | Counter | Aggregation duties that ended in no published aggregate, including ones abandoned for overrunning the slot | | `ethlambda_validator_fee_recipient_mismatches_total` | Counter | Blocks paying execution rewards to an address this client did not request. Should read zero forever; a non-zero value means every proposal is paying somewhere else | From bd8d05a000d197bb765d0842cd133384a57dc6d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:45:54 -0300 Subject: [PATCH 06/11] feat(beacon): validate sync committee gossip The sync_committee_{subnet_id} and contribution topics were relayed to nobody: contributions were decoded and then ignored. The rules follow the altair p2p-interface, split into cheap and stateful halves like the other topics, and the committee is read by the message's slot so a lagging head does not reject honest messages. The spec's gossip vectors for both topics move from the ignored list into the runner. --- .../state_transition/src/beacon/gossip/mod.rs | 9 + .../src/beacon/gossip/sync_committee.rs | 768 ++++++++++++++++++ .../tests/beacon_spec/gossip.rs | 57 +- 3 files changed, 830 insertions(+), 4 deletions(-) create mode 100644 crates/blockchain/state_transition/src/beacon/gossip/sync_committee.rs diff --git a/crates/blockchain/state_transition/src/beacon/gossip/mod.rs b/crates/blockchain/state_transition/src/beacon/gossip/mod.rs index 187f612b..9c65933a 100644 --- a/crates/blockchain/state_transition/src/beacon/gossip/mod.rs +++ b/crates/blockchain/state_transition/src/beacon/gossip/mod.rs @@ -12,6 +12,7 @@ pub mod block; pub mod column; pub mod envelope; pub mod payload_attestation; +pub mod sync_committee; #[cfg(test)] pub(crate) mod test_support; @@ -22,6 +23,7 @@ pub use aggregate::SeenAggregates; pub use attestation::SeenAttestations; pub use envelope::SeenEnvelopes; pub use payload_attestation::SeenPayloadAttestations; +pub use sync_committee::{SeenSyncCommitteeMessages, SeenSyncContributions}; use std::num::NonZeroUsize; @@ -155,6 +157,8 @@ pub enum IgnoreReason { /// The head state's payload timeliness committee window cannot answer for /// the attested slot. PtcUnavailable, + /// The head state's sync committees cannot answer for the message's period. + SyncCommitteeUnavailable, } impl IgnoreReason { @@ -180,6 +184,7 @@ impl IgnoreReason { Self::NotCurrentSlot => "not_current_slot", Self::BlockNotAtSlot => "block_not_at_slot", Self::PtcUnavailable => "ptc_unavailable", + Self::SyncCommitteeUnavailable => "sync_committee_unavailable", } } } @@ -258,6 +263,9 @@ pub enum RejectReason { /// A payload attestation's validator is not in its slot's payload /// timeliness committee. NotInPtc, + /// A sync committee contribution's subcommittee index is not below + /// `SYNC_COMMITTEE_SUBNET_COUNT`. + SubcommitteeIndex, } impl RejectReason { @@ -303,6 +311,7 @@ impl RejectReason { Self::TooManyWithdrawals => "too_many_withdrawals", Self::PreGloasSlot => "pre_gloas_slot", Self::NotInPtc => "not_in_ptc", + Self::SubcommitteeIndex => "subcommittee_index", } } } diff --git a/crates/blockchain/state_transition/src/beacon/gossip/sync_committee.rs b/crates/blockchain/state_transition/src/beacon/gossip/sync_committee.rs new file mode 100644 index 00000000..d532eede --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/gossip/sync_committee.rs @@ -0,0 +1,768 @@ +//! Gossip validation for the altair `sync_committee_{subnet_id}` and +//! `sync_committee_contribution_and_proof` topics. +//! +//! The rules are the specification's `validate_sync_committee_message_gossip` +//! and the contribution-and-proof section of `specs/altair/p2p-interface.md`, +//! split by cost like [`super::payload_attestation`]'s: the `*_cheap_checks` +//! read the message, the clock and the seen caches; the `*_stateful_checks` +//! read the head state and verify signatures. Neither fulu nor gloas changes +//! the rules. +//! +//! Deliberate departures from the specification: +//! +//! - The committee is chosen by the message's slot and the signing domain by +//! the config's fork schedule (see [`crate::beacon::helpers::sync_committee`]), +//! not by `state.slot + 1` and `state.fork`. A head that lags across a period +//! or fork boundary would otherwise reject honest messages. +//! - The head state is read from the state cache, never rebuilt. A miss is +//! `IGNORE`, like the other topics' uncached states, and so is a period the +//! head state's committees cannot answer for ([`IgnoreReason::SyncCommitteeUnavailable`]). + +use std::num::NonZeroUsize; + +use ethlambda_storage::CacheKey; +use lru::LruCache; + +use super::{IgnoreReason, Outcome, RejectReason, is_current_slot}; +use crate::beacon::bls; +use crate::beacon::config::Config; +use crate::beacon::constants::SYNC_COMMITTEE_SUBNET_COUNT; +use crate::beacon::containers::BeaconState; +use crate::beacon::containers::altair::{ + SYNC_SUBCOMMITTEE_SIZE, SignedContributionAndProof, SyncCommitteeMessage, +}; +use crate::beacon::fork_choice::Store; +use crate::beacon::helpers::sync_committee::{ + contribution_and_proof_signing_root, get_sync_subcommittee_pubkeys, + is_sync_committee_aggregator, sync_committee_for_slot, sync_committee_message_signing_root, + sync_committee_seats, sync_selection_proof_signing_root, +}; +use crate::beacon::primitives::{Root, Slot, ValidatorIndex}; + +/// The first valid message per `(slot, validator index, subnet)`: the +/// specification's `seen.sync_message_validator_slots`. Bounded by capacity, +/// like [`super::SeenPayloadAttestations`]. +pub struct SeenSyncCommitteeMessages(LruCache<(Slot, ValidatorIndex, u64), ()>); + +impl SeenSyncCommitteeMessages { + pub fn new(capacity: NonZeroUsize) -> Self { + Self(LruCache::new(capacity)) + } + + pub fn contains(&self, slot: Slot, validator_index: ValidatorIndex, subnet_id: u64) -> bool { + self.0.contains(&(slot, validator_index, subnet_id)) + } + + /// Record the first valid message for its key. Returns `false`, changing + /// nothing, when one is already recorded. + pub fn record(&mut self, slot: Slot, validator_index: ValidatorIndex, subnet_id: u64) -> bool { + if self.0.contains(&(slot, validator_index, subnet_id)) { + return false; + } + self.0.put((slot, validator_index, subnet_id), ()); + true + } +} + +/// A subcommittee's aggregation bits, packed into one word. +fn pack_bits(signed: &SignedContributionAndProof) -> u128 { + let bits = &signed.message.contribution.aggregation_bits; + (0..SYNC_SUBCOMMITTEE_SIZE) + .filter(|&index| bits.get(index).unwrap_or(false)) + .fold(0u128, |packed, index| packed | (1 << index)) +} + +/// Accepted contributions, keyed the way the specification's `Seen` keys them: +/// `sync_contribution_aggregator_slots` and `sync_contribution_data`. +/// +/// Both are bounded by capacity. See [`super::SeenAggregates`] for why that +/// beats pruning on finality, and for the race [`Self::record`] closes. +pub struct SeenSyncContributions { + aggregators: LruCache<(Slot, ValidatorIndex, u64), ()>, + data: LruCache<(Slot, Root, u64), Vec>, +} + +impl SeenSyncContributions { + pub fn new(aggregators: NonZeroUsize, data: NonZeroUsize) -> Self { + Self { + aggregators: LruCache::new(aggregators), + data: LruCache::new(data), + } + } + + /// The two seen verdicts, in the specification's order: a superset already + /// accepted for the same data, then an aggregator already accepted for the + /// slot and subcommittee. Read-only. + fn verdict(&self, signed: &SignedContributionAndProof) -> Result<(), Outcome> { + let contribution = &signed.message.contribution; + let bits = pack_bits(signed); + let covered = self + .data + .peek(&( + contribution.slot, + contribution.beacon_block_root, + contribution.subcommittee_index, + )) + .is_some_and(|seen| seen.iter().any(|prior| bits & !prior == 0)); + if covered { + return Err(Outcome::Ignore(IgnoreReason::CoveredBits)); + } + let aggregator_seen = self.aggregators.contains(&( + contribution.slot, + signed.message.aggregator_index, + contribution.subcommittee_index, + )); + if aggregator_seen { + return Err(Outcome::Ignore(IgnoreReason::AlreadySeen)); + } + Ok(()) + } + + /// Record an accepted contribution. Re-runs both seen verdicts, so of two + /// validation tasks that both passed [`contribution_cheap_checks`] before + /// either settled, only the first records. Returns `false`, recording + /// nothing, for the second. + pub fn record(&mut self, signed: &SignedContributionAndProof) -> bool { + if self.verdict(signed).is_err() { + return false; + } + let contribution = &signed.message.contribution; + self.aggregators.put( + ( + contribution.slot, + signed.message.aggregator_index, + contribution.subcommittee_index, + ), + (), + ); + let key = ( + contribution.slot, + contribution.beacon_block_root, + contribution.subcommittee_index, + ); + let bits = pack_bits(signed); + match self.data.get_mut(&key) { + Some(existing) => existing.push(bits), + None => { + self.data.put(key, vec![bits]); + } + } + true + } +} + +// --- sync_committee_{subnet_id} ------------------------------------------- + +/// The rules that read only the message, the clock and the seen cache. The +/// caller records the message in `seen` once the whole rule answers +/// [`Outcome::Accept`]. +pub fn message_cheap_checks( + seen: &SeenSyncCommitteeMessages, + store: &Store, + message: &SyncCommitteeMessage, + subnet_id: u64, + now_ms: u64, +) -> Result<(), Outcome> { + // [REJECT] The subnet exists. Defensive: the topic parser never yields one + // that does not. + if subnet_id >= SYNC_COMMITTEE_SUBNET_COUNT as u64 { + return Err(Outcome::Reject(RejectReason::WrongSubnet)); + } + // [IGNORE] This is the first valid message from this validator for this + // slot and subnet. + if seen.contains(message.slot, message.validator_index, subnet_id) { + return Err(Outcome::Ignore(IgnoreReason::AlreadySeen)); + } + // [IGNORE] The message's slot is the current slot. + if !is_current_slot(&store.config(), message.slot, now_ms) { + return Err(Outcome::Ignore(IgnoreReason::NotCurrentSlot)); + } + Ok(()) +} + +/// The cached head state, or the verdict that says it is unavailable. +fn head_state(store: &Store) -> Result, Outcome> { + // `head` is the root alone: `beacon_head` would decode the whole head block + // for a slot nothing here reads. + let Ok(head_root) = store.head() else { + return Err(Outcome::Ignore(IgnoreReason::Internal)); + }; + store + .cached_state(CacheKey::BlockState(head_root)) + .ok_or(Outcome::Ignore(IgnoreReason::StateUnavailable)) +} + +/// The rules that read the head state and verify the signature. Runs on a +/// blocking thread. Returns the `(subcommittee, position)` seats the message +/// covers on `subnet_id`. +pub fn message_stateful_checks( + store: &Store, + message: &SyncCommitteeMessage, + subnet_id: u64, +) -> Result, Outcome> { + let state = head_state(store)?; + check_message(&state, &store.config(), message, Some(subnet_id)) +} + +/// The state-dependent rules for one message. With `subnet_id`, the seats kept +/// are those on that subnet; without one (the Beacon API, which accepts a +/// message for every subnet its validator sits on), all of them. +pub fn check_message( + state: &BeaconState, + config: &Config, + message: &SyncCommitteeMessage, + subnet_id: Option, +) -> Result, Outcome> { + // [REJECT] The validator index is valid. + let Ok(validator) = state.validator(message.validator_index) else { + return Err(Outcome::Reject(RejectReason::UnknownValidator)); + }; + // A period the head state's committees cannot answer for is not the + // sender's fault. + let Ok(committee) = sync_committee_for_slot(state, message.slot) else { + return Err(Outcome::Ignore(IgnoreReason::SyncCommitteeUnavailable)); + }; + let mut seats = sync_committee_seats(committee, &validator.pubkey); + match subnet_id { + Some(subnet) => { + seats.retain(|&(seat_subnet, _)| seat_subnet == subnet); + // [REJECT] The validator is in the subcommittee of this subnet. + if seats.is_empty() { + return Err(Outcome::Reject(RejectReason::WrongSubnet)); + } + } + None => { + // [REJECT] The validator is in the sync committee. + if seats.is_empty() { + return Err(Outcome::Reject(RejectReason::NotInCommittee)); + } + } + } + // [REJECT] The signature is valid. + let signing_root = sync_committee_message_signing_root( + config, + state.genesis_validators_root(), + message.slot, + message.beacon_block_root, + ); + if !bls::verify(&validator.pubkey, signing_root, &message.signature) { + return Err(Outcome::Reject(RejectReason::BadSignature)); + } + Ok(seats) +} + +/// The rules for a message submitted through the Beacon API: the clock, then +/// [`check_message`] over every subnet, so the signature is checked once. +pub fn check_submitted_message( + state: &BeaconState, + config: &Config, + message: &SyncCommitteeMessage, + now_ms: u64, +) -> Result, Outcome> { + // [IGNORE] The message's slot is the current slot. + if !is_current_slot(config, message.slot, now_ms) { + return Err(Outcome::Ignore(IgnoreReason::NotCurrentSlot)); + } + check_message(state, config, message, None) +} + +/// [`message_cheap_checks`] then [`message_stateful_checks`], for callers with +/// no reason to split them, such as the spec vectors. The caller records the +/// message in `seen` on success. +pub fn validate_message( + seen: &SeenSyncCommitteeMessages, + store: &Store, + message: &SyncCommitteeMessage, + subnet_id: u64, + now_ms: u64, +) -> Result, Outcome> { + message_cheap_checks(seen, store, message, subnet_id, now_ms)?; + message_stateful_checks(store, message, subnet_id) +} + +// --- sync_committee_contribution_and_proof -------------------------------- + +/// The rules that read only the message, the clock and the seen caches, in the +/// specification's order. +pub fn contribution_cheap_checks( + seen: &SeenSyncContributions, + store: &Store, + signed: &SignedContributionAndProof, + now_ms: u64, +) -> Result<(), Outcome> { + let contribution = &signed.message.contribution; + // [IGNORE] A superset was already seen; [IGNORE] this aggregator was. + seen.verdict(signed)?; + // [IGNORE] The contribution's slot is the current slot. + if !is_current_slot(&store.config(), contribution.slot, now_ms) { + return Err(Outcome::Ignore(IgnoreReason::NotCurrentSlot)); + } + // [REJECT] The subcommittee index is in range. + if contribution.subcommittee_index >= SYNC_COMMITTEE_SUBNET_COUNT as u64 { + return Err(Outcome::Reject(RejectReason::SubcommitteeIndex)); + } + // [REJECT] The contribution has at least one participant. + if contribution.aggregation_bits.count_ones() == 0 { + return Err(Outcome::Reject(RejectReason::NoParticipants)); + } + // [REJECT] The selection proof selects the aggregator. + if !is_sync_committee_aggregator(&signed.message.selection_proof) { + return Err(Outcome::Reject(RejectReason::NotAggregator)); + } + Ok(()) +} + +/// The rules that read the head state and verify three signatures. Runs on a +/// blocking thread. +pub fn contribution_stateful_checks(store: &Store, signed: &SignedContributionAndProof) -> Outcome { + match head_state(store) { + Ok(state) => check_contribution(&state, &store.config(), signed), + Err(outcome) => outcome, + } +} + +/// The state-dependent rules for one contribution. +pub fn check_contribution( + state: &BeaconState, + config: &Config, + signed: &SignedContributionAndProof, +) -> Outcome { + let message = &signed.message; + let contribution = &message.contribution; + let gvr = state.genesis_validators_root(); + // [REJECT] The aggregator's validator index is valid. + let Ok(aggregator) = state.validator(message.aggregator_index) else { + return Outcome::Reject(RejectReason::UnknownValidator); + }; + if contribution.subcommittee_index >= SYNC_COMMITTEE_SUBNET_COUNT as u64 { + return Outcome::Reject(RejectReason::SubcommitteeIndex); + } + let Ok(pubkeys) = + get_sync_subcommittee_pubkeys(state, contribution.slot, contribution.subcommittee_index) + else { + return Outcome::Ignore(IgnoreReason::SyncCommitteeUnavailable); + }; + // [REJECT] The aggregator is in the subcommittee. + if !pubkeys.contains(&aggregator.pubkey) { + return Outcome::Reject(RejectReason::NotInCommittee); + } + // [REJECT] The selection proof is valid. + let selection_root = sync_selection_proof_signing_root( + config, + gvr, + contribution.slot, + contribution.subcommittee_index, + ); + if !bls::verify(&aggregator.pubkey, selection_root, &message.selection_proof) { + return Outcome::Reject(RejectReason::SelectionProof); + } + // [REJECT] The aggregator's signature over the envelope is valid. + let envelope_root = contribution_and_proof_signing_root(config, gvr, message); + if !bls::verify(&aggregator.pubkey, envelope_root, &signed.signature) { + return Outcome::Reject(RejectReason::AggregatorSignature); + } + // [REJECT] The aggregate signature is valid over the participants. + let participants: Vec<_> = pubkeys + .iter() + .enumerate() + .filter(|(index, _)| contribution.aggregation_bits.get(*index).unwrap_or(false)) + .map(|(_, pubkey)| *pubkey) + .collect(); + let signing_root = sync_committee_message_signing_root( + config, + gvr, + contribution.slot, + contribution.beacon_block_root, + ); + if !bls::eth_fast_aggregate_verify(&participants, signing_root, &contribution.signature) { + return Outcome::Reject(RejectReason::AggregateSignature); + } + Outcome::Accept +} + +/// [`contribution_cheap_checks`] then [`contribution_stateful_checks`], for +/// callers with no reason to split them. The caller records the contribution in +/// `seen` on [`Outcome::Accept`]. +pub fn validate_contribution( + seen: &SeenSyncContributions, + store: &Store, + signed: &SignedContributionAndProof, + now_ms: u64, +) -> Outcome { + if let Err(outcome) = contribution_cheap_checks(seen, store, signed, now_ms) { + return outcome; + } + contribution_stateful_checks(store, signed) +} + +#[cfg(test)] +mod tests { + use ethlambda_types::beacon::containers::altair::{ + ContributionAndProof, SyncCommitteeContribution, + }; + use ethlambda_types::beacon::primitives::BlsSignature; + + use super::*; + use crate::beacon::gossip::test_support::{slot_start_ms, store}; + use crate::beacon::helpers::sync_committee::tests::state_with_committees; + use crate::beacon::helpers::test_state::sign_for; + use crate::beacon::preset; + + const SLOT: Slot = 5; + const VALIDATORS: usize = preset::SYNC_COMMITTEE_SIZE; + + fn root() -> Root { + Root::repeat_byte(4) + } + + fn caches() -> (SeenSyncCommitteeMessages, SeenSyncContributions) { + let capacity = NonZeroUsize::new(8).expect("non-zero"); + ( + SeenSyncCommitteeMessages::new(capacity), + SeenSyncContributions::new(capacity, capacity), + ) + } + + /// A store whose head state is the committee state, cached. + fn store_with_head() -> (Store, std::sync::Arc) { + let mut store = store(0); + let state = state_with_committees(VALIDATORS); + let head = store.head().expect("head root"); + store.insert_state(head, state).expect("insert head state"); + let cached = store + .cached_state(CacheKey::BlockState(head)) + .expect("the head state is cached"); + (store, cached) + } + + /// Validator `index` is at position `index` of the current committee. + fn message_from(index: u64, signer: usize) -> SyncCommitteeMessage { + let signing_root = sync_committee_message_signing_root( + &Config::mainnet().with_fork_epoch(crate::beacon::ForkName::Fulu, 0), + Root::ZERO, + SLOT, + root(), + ); + SyncCommitteeMessage { + slot: SLOT, + beacon_block_root: root(), + validator_index: index, + signature: sign_for(signer, signing_root), + } + } + + fn subnet_of(index: u64) -> u64 { + index / SYNC_SUBCOMMITTEE_SIZE as u64 + } + + #[test] + fn the_message_seen_cache_records_once_per_slot_validator_and_subnet() { + let (mut seen, _) = caches(); + assert!(!seen.contains(5, 9, 1)); + assert!(seen.record(5, 9, 1)); + assert!(seen.contains(5, 9, 1)); + assert!(!seen.record(5, 9, 1)); + assert!(seen.record(5, 9, 2)); + } + + #[test] + fn a_message_outside_the_current_slot_is_ignored() { + let (store, _) = store_with_head(); + let (seen, _) = caches(); + let message = message_from(0, 0); + let later = slot_start_ms(&store, SLOT + 3); + assert_eq!( + message_cheap_checks(&seen, &store, &message, 0, later), + Err(Outcome::Ignore(IgnoreReason::NotCurrentSlot)) + ); + let now = slot_start_ms(&store, SLOT); + assert_eq!( + message_cheap_checks(&seen, &store, &message, 0, now), + Ok(()) + ); + assert_eq!( + message_cheap_checks(&seen, &store, &message, 4, now), + Err(Outcome::Reject(RejectReason::WrongSubnet)) + ); + } + + #[test] + fn a_duplicate_is_ignored_on_the_same_subnet_and_not_on_another() { + let (store, _) = store_with_head(); + let (mut seen, _) = caches(); + let message = message_from(0, 0); + let now = slot_start_ms(&store, SLOT); + seen.record(SLOT, 0, 0); + assert_eq!( + message_cheap_checks(&seen, &store, &message, 0, now), + Err(Outcome::Ignore(IgnoreReason::AlreadySeen)) + ); + assert_eq!( + message_cheap_checks(&seen, &store, &message, 1, now), + Ok(()) + ); + } + + #[test] + fn a_valid_message_returns_exactly_its_seats() { + let (store, _) = store_with_head(); + let message = message_from(1, 1); + assert_eq!( + message_stateful_checks(&store, &message, subnet_of(1)), + Ok(vec![(subnet_of(1), 1 % SYNC_SUBCOMMITTEE_SIZE)]) + ); + } + + #[test] + fn the_wrong_subnet_a_non_member_and_a_bad_signature_are_rejected() { + let (store, state) = store_with_head(); + let config = store.config(); + let message = message_from(1, 1); + assert_eq!( + message_stateful_checks(&store, &message, (subnet_of(1) + 1) % 4), + Err(Outcome::Reject(RejectReason::WrongSubnet)) + ); + // A validator the registry has but the committee lacks: add one. + let mut forged = message_from(1, 2); + assert_eq!( + check_message(&state, &config, &forged, None), + Err(Outcome::Reject(RejectReason::BadSignature)) + ); + forged.validator_index = 100_000; + assert_eq!( + check_message(&state, &config, &forged, None), + Err(Outcome::Reject(RejectReason::UnknownValidator)) + ); + // With fewer validators than seats a committee still has non-members + // only when the registry is larger than the committee. + let big = state_with_committees(VALIDATORS + 1); + let outsider = VALIDATORS as u64; + big.validator(outsider).expect("the outsider is registered"); + let outsider_message = message_from(outsider, VALIDATORS); + assert_eq!( + check_message(&big, &config, &outsider_message, None), + Err(Outcome::Reject(RejectReason::NotInCommittee)) + ); + } + + #[test] + fn an_uncached_head_state_is_ignored() { + let store = store(0); + assert_eq!( + message_stateful_checks(&store, &message_from(0, 0), 0), + Err(Outcome::Ignore(IgnoreReason::StateUnavailable)) + ); + } + + #[test] + fn a_period_the_head_cannot_answer_is_ignored() { + let (store, state) = store_with_head(); + let mut message = message_from(0, 0); + message.slot = 3 * preset::SLOTS_PER_EPOCH * preset::EPOCHS_PER_SYNC_COMMITTEE_PERIOD; + assert_eq!( + check_message(&state, &store.config(), &message, None), + Err(Outcome::Ignore(IgnoreReason::SyncCommitteeUnavailable)) + ); + } + + // --- contributions ---------------------------------------------------- + + /// Searches for an aggregator among the subcommittee's first members: a + /// selection proof only selects one in `modulo` of them. + fn contribution_from( + config: &Config, + subcommittee: u64, + participants: &[usize], + ) -> SignedContributionAndProof { + let base = subcommittee as usize * SYNC_SUBCOMMITTEE_SIZE; + let signing_root = sync_committee_message_signing_root(config, Root::ZERO, SLOT, root()); + let signatures: Vec = participants + .iter() + .map(|&position| sign_for(base + position, signing_root)) + .collect(); + let mut bits = ::clone(&SyncCommitteeContribution { + slot: SLOT, + beacon_block_root: root(), + subcommittee_index: subcommittee, + aggregation_bits: Default::default(), + signature: BlsSignature::default(), + }); + for &position in participants { + bits.aggregation_bits.set(position, true).unwrap(); + } + bits.signature = bls::aggregate(&signatures).unwrap(); + let selection_root = + sync_selection_proof_signing_root(config, Root::ZERO, SLOT, subcommittee); + // The first member whose selection proof selects it. + let (aggregator, selection_proof) = (0..SYNC_SUBCOMMITTEE_SIZE) + .map(|position| (base + position, sign_for(base + position, selection_root))) + .find(|(_, proof)| is_sync_committee_aggregator(proof)) + .expect("some member of the subcommittee is selected"); + let message = ContributionAndProof { + aggregator_index: aggregator as u64, + contribution: bits, + selection_proof, + }; + let envelope = contribution_and_proof_signing_root(config, Root::ZERO, &message); + SignedContributionAndProof { + signature: sign_for(aggregator, envelope), + message, + } + } + + fn mainnet_fulu() -> Config { + Config::mainnet().with_fork_epoch(crate::beacon::ForkName::Fulu, 0) + } + + #[test] + fn a_valid_contribution_is_accepted() { + let (store, state) = store_with_head(); + let signed = contribution_from(&mainnet_fulu(), 1, &[0, 3]); + assert_eq!( + check_contribution(&state, &store.config(), &signed), + Outcome::Accept + ); + let (_, seen) = caches(); + let now = slot_start_ms(&store, SLOT); + assert_eq!( + validate_contribution(&seen, &store, &signed, now), + Outcome::Accept + ); + } + + #[test] + fn forged_signatures_are_each_rejected() { + let (store, state) = store_with_head(); + let config = store.config(); + let good = contribution_from(&mainnet_fulu(), 1, &[0, 3]); + + let mut bad = good.clone(); + bad.message.selection_proof = sign_for(0, Root::repeat_byte(1)); + // Either not selected, or a forged proof: only the latter reaches the signature. + if is_sync_committee_aggregator(&bad.message.selection_proof) { + assert_eq!( + check_contribution(&state, &config, &bad), + Outcome::Reject(RejectReason::SelectionProof) + ); + } + + let mut bad = good.clone(); + bad.signature = sign_for(0, Root::repeat_byte(1)); + assert_eq!( + check_contribution(&state, &config, &bad), + Outcome::Reject(RejectReason::AggregatorSignature) + ); + + let mut bad = good.clone(); + bad.message.contribution.signature = sign_for(0, Root::repeat_byte(1)); + // Re-sign the envelope so only the aggregate is wrong. + let aggregator = bad.message.aggregator_index as usize; + let envelope = contribution_and_proof_signing_root(&config, Root::ZERO, &bad.message); + bad.signature = sign_for(aggregator, envelope); + assert_eq!( + check_contribution(&state, &config, &bad), + Outcome::Reject(RejectReason::AggregateSignature) + ); + } + + #[test] + fn a_forged_selection_proof_is_rejected() { + let (store, state) = store_with_head(); + let config = store.config(); + let good = contribution_from(&mainnet_fulu(), 1, &[0]); + // Another member's proof for the same data is validly signed, but not by this aggregator. + let selection_root = sync_selection_proof_signing_root(&config, Root::ZERO, SLOT, 1); + let impostor = (0..SYNC_SUBCOMMITTEE_SIZE * 4) + .map(|index| sign_for(index, selection_root)) + .find(|proof| { + is_sync_committee_aggregator(proof) && *proof != good.message.selection_proof + }) + .expect("another selected signature exists"); + let mut bad = good; + bad.message.selection_proof = impostor; + assert_eq!( + check_contribution(&state, &config, &bad), + Outcome::Reject(RejectReason::SelectionProof) + ); + } + + #[test] + fn contribution_cheap_checks_cover_each_rule() { + let (store, _) = store_with_head(); + let (_, mut seen) = caches(); + let now = slot_start_ms(&store, SLOT); + let good = contribution_from(&mainnet_fulu(), 1, &[0, 3]); + assert_eq!(contribution_cheap_checks(&seen, &store, &good, now), Ok(())); + + // Not the current slot. + let later = slot_start_ms(&store, SLOT + 3); + assert_eq!( + contribution_cheap_checks(&seen, &store, &good, later), + Err(Outcome::Ignore(IgnoreReason::NotCurrentSlot)) + ); + // Subcommittee out of range. + let mut bad = good.clone(); + bad.message.contribution.subcommittee_index = 4; + assert_eq!( + contribution_cheap_checks(&seen, &store, &bad, now), + Err(Outcome::Reject(RejectReason::SubcommitteeIndex)) + ); + // No participants. + let mut bad = good.clone(); + bad.message.contribution.aggregation_bits = Default::default(); + assert_eq!( + contribution_cheap_checks(&seen, &store, &bad, now), + Err(Outcome::Reject(RejectReason::NoParticipants)) + ); + // Not selected. + let mut bad = good.clone(); + bad.message.selection_proof = (0u8..=255) + .map(|byte| BlsSignature([byte; 96])) + .find(|proof| !is_sync_committee_aggregator(proof)) + .expect("an unselected value exists"); + assert_eq!( + contribution_cheap_checks(&seen, &store, &bad, now), + Err(Outcome::Reject(RejectReason::NotAggregator)) + ); + + // Seen: the same aggregator, then a covered subset from another. + assert!(seen.record(&good)); + assert_eq!( + contribution_cheap_checks(&seen, &store, &good, now), + Err(Outcome::Ignore(IgnoreReason::CoveredBits)) + ); + let mut other_aggregator = good.clone(); + other_aggregator.message.contribution.aggregation_bits = Default::default(); + other_aggregator + .message + .contribution + .aggregation_bits + .set(0, true) + .unwrap(); + other_aggregator.message.aggregator_index += 1; + assert_eq!( + contribution_cheap_checks(&seen, &store, &other_aggregator, now), + Err(Outcome::Ignore(IgnoreReason::CoveredBits)) + ); + // The same aggregator with a bit the data lacks is still AlreadySeen. + let mut same_aggregator = good.clone(); + same_aggregator + .message + .contribution + .aggregation_bits + .set(5, true) + .unwrap(); + assert_eq!( + contribution_cheap_checks(&seen, &store, &same_aggregator, now), + Err(Outcome::Ignore(IgnoreReason::AlreadySeen)) + ); + } + + #[test] + fn recording_a_contribution_twice_reports_the_second_as_lost() { + let (_, mut seen) = caches(); + let good = contribution_from(&mainnet_fulu(), 1, &[0, 3]); + assert!(seen.record(&good)); + assert!(!seen.record(&good)); + } +} diff --git a/crates/blockchain/state_transition/tests/beacon_spec/gossip.rs b/crates/blockchain/state_transition/tests/beacon_spec/gossip.rs index e98ec5ed..cdd579b2 100644 --- a/crates/blockchain/state_transition/tests/beacon_spec/gossip.rs +++ b/crates/blockchain/state_transition/tests/beacon_spec/gossip.rs @@ -16,7 +16,7 @@ use std::sync::Arc; use ethlambda_state_transition::beacon::ForkName; use ethlambda_state_transition::beacon::config::Config; use ethlambda_state_transition::beacon::containers::{ - BeaconState, Checkpoint, DataColumnSidecar, SignedAggregateAndProof, SignedBeaconBlock, + BeaconState, Checkpoint, DataColumnSidecar, SignedAggregateAndProof, SignedBeaconBlock, altair, electra, gloas, phase0, }; use ethlambda_state_transition::beacon::fork_choice::{ @@ -24,7 +24,8 @@ use ethlambda_state_transition::beacon::fork_choice::{ }; use ethlambda_state_transition::beacon::gossip::{ self as rules, Outcome, SeenAggregates, SeenAttestations, SeenBlockColumns, SeenBlocks, - SeenColumns, SeenEnvelopes, SeenPayloadAttestations, + SeenColumns, SeenEnvelopes, SeenPayloadAttestations, SeenSyncCommitteeMessages, + SeenSyncContributions, }; use ethlambda_state_transition::beacon::helpers::accessors::CommitteeCache; use ethlambda_state_transition::beacon::primitives::Root; @@ -43,6 +44,8 @@ const HANDLERS: &[&str] = &[ "gossip_beacon_attestation", "gossip_execution_payload_envelope", "gossip_payload_attestation_message", + "gossip_sync_committee_message", + "gossip_sync_committee_contribution_and_proof", ]; /// The forks each of [`HANDLERS`] validates. A case from any other fork is @@ -64,6 +67,10 @@ fn validated_forks(handler: &str) -> &'static [ForkName] { "gossip_execution_payload_envelope" | "gossip_payload_attestation_message" => { &[ForkName::Gloas] } + // Altair's rules, which neither fulu nor gloas changes. + "gossip_sync_committee_message" | "gossip_sync_committee_contribution_and_proof" => { + &[ForkName::Fulu, ForkName::Gloas] + } other => panic!("{other} is not in HANDLERS, so it has no validated forks"), } } @@ -92,8 +99,6 @@ const IGNORED_HANDLERS: &[&str] = &[ "gossip_partial_data_column_sidecar", "gossip_proposer_preferences", "gossip_proposer_slashing", - "gossip_sync_committee_contribution_and_proof", - "gossip_sync_committee_message", "gossip_voluntary_exit", ]; @@ -415,6 +420,8 @@ fn run_case(case: &Case) -> Result<(), String> { let mut seen_attestations = SeenAttestations::new(capacity); let mut seen_envelopes = SeenEnvelopes::new(capacity); let mut seen_payload_attestations = SeenPayloadAttestations::new(capacity); + let mut seen_sync_messages = SeenSyncCommitteeMessages::new(capacity); + let mut seen_sync_contributions = SeenSyncContributions::new(capacity, capacity); for (index, message) in meta.messages.iter().enumerate() { let now_ms = config.genesis_time_ms() @@ -534,6 +541,48 @@ fn run_case(case: &Case) -> Result<(), String> { } outcome } + "sync_committee" => { + let sync_message = + altair::SyncCommitteeMessage::from_ssz_bytes(&case.ssz_bytes(&message.message)) + .map_err(|err| format!("decoding {}: {err:?}", message.message))?; + let subnet_id = message + .subnet_id + .ok_or("a sync_committee message names its subnet")?; + let outcome = match rules::sync_committee::validate_message( + &seen_sync_messages, + &store, + &sync_message, + subnet_id, + now_ms, + ) { + Ok(_) => Outcome::Accept, + Err(outcome) => outcome, + }; + if outcome == Outcome::Accept { + seen_sync_messages.record( + sync_message.slot, + sync_message.validator_index, + subnet_id, + ); + } + outcome + } + "sync_committee_contribution_and_proof" => { + let signed = altair::SignedContributionAndProof::from_ssz_bytes( + &case.ssz_bytes(&message.message), + ) + .map_err(|err| format!("decoding {}: {err:?}", message.message))?; + let outcome = rules::sync_committee::validate_contribution( + &seen_sync_contributions, + &store, + &signed, + now_ms, + ); + if outcome == Outcome::Accept { + seen_sync_contributions.record(&signed); + } + outcome + } other => return Err(format!("topic {other} has no runner")), }; check(message, outcome).map_err(|err| format!("message {index}: {err}"))?; From 0495c42e3350d8fbee2edfd59e765622c0ec050a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:50:56 -0300 Subject: [PATCH 07/11] feat(beacon): pack sync committee votes and wire the pool through the node Block production always carried the empty sync aggregate, forfeiting the committee's rewards. Both proposal paths now take a sync aggregate as an input, and verified_sync_aggregate returns it only when it passes exactly the check process_sync_aggregate will apply, so a stale or foreign vote costs rewards and never the block. The RpcToP2P protocol gains the three sync committee calls, the p2p actor handles them through a stub module the p2p stage fills in, and one shared pool is created at startup for p2p and the Beacon API. The spec deviations this work introduces are recorded. --- bin/ethlambda/src/main.rs | 6 + .../src/beacon/block_production.rs | 140 +++++++++++++++++- .../src/beacon/gloas_block_production.rs | 77 +++++++++- crates/net/api/src/lib.rs | 19 +++ crates/net/p2p/src/beacon/mod.rs | 1 + crates/net/p2p/src/beacon/sync_committee.rs | 45 ++++++ crates/net/p2p/src/lib.rs | 36 ++++- crates/net/p2p/src/req_resp/handlers.rs | 1 + crates/net/rpc/src/beacon/gloas_proposal.rs | 6 + crates/net/rpc/src/beacon/proposal.rs | 2 + crates/net/rpc/src/lib.rs | 48 +++++- docs/metrics.md | 13 +- docs/spec_deviations.md | 66 +++++++++ 13 files changed, 445 insertions(+), 15 deletions(-) create mode 100644 crates/net/p2p/src/beacon/sync_committee.rs diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index f5037732..2fba8156 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -721,6 +721,10 @@ async fn run_node(options: Options) -> eyre::Result<()> { // by block production and `GET .../pool/payload_attestations`. let payload_attestation_pool = ethlambda_state_transition::beacon::payload_attestation_pool::SharedPayloadAttestationPool::default(); + // Filled by gossip and the Beacon API's sync committee endpoints, read by + // block production and the contribution endpoint. + let sync_committee_pool = + ethlambda_state_transition::beacon::sync_committee_pool::SharedSyncCommitteePool::default(); let p2p = P2P::spawn( built, setup.store.clone(), @@ -728,6 +732,7 @@ async fn run_node(options: Options) -> eyre::Result<()> { discovery, attestation_pool.clone(), payload_attestation_pool.clone(), + sync_committee_pool.clone(), ) .await .wrap_err("failed to start discv5 discovery")?; @@ -769,6 +774,7 @@ async fn run_node(options: Options) -> eyre::Result<()> { p2p: rpc_p2p, attestation_pool: attestation_pool.clone(), payload_attestation_pool: payload_attestation_pool.clone(), + sync_committee_pool: sync_committee_pool.clone(), custody_columns: rpc_custody_columns, engine: rpc_engine, }, diff --git a/crates/blockchain/state_transition/src/beacon/block_production.rs b/crates/blockchain/state_transition/src/beacon/block_production.rs index 77b97454..ec4965eb 100644 --- a/crates/blockchain/state_transition/src/beacon/block_production.rs +++ b/crates/blockchain/state_transition/src/beacon/block_production.rs @@ -38,12 +38,13 @@ use super::bls; use super::config::Config; use super::error::{Error, Result, verify}; use super::helpers::accessors::{ - CommitteeCache, get_beacon_proposer_index, get_block_root, get_current_epoch, - get_previous_epoch, get_randao_mix, + CommitteeCache, get_beacon_proposer_index, get_block_root, get_block_root_at_slot, + get_current_epoch, get_domain, get_previous_epoch, get_randao_mix, }; use super::helpers::electra::{ get_attesting_indices, get_indexed_attestation, is_valid_indexed_attestation, }; +use super::helpers::misc::compute_signing_root; use super::lean_boundary::lean_state_unreachable; use super::stf::{self, ExecutionEngine}; @@ -108,6 +109,52 @@ pub fn empty_sync_aggregate() -> SyncAggregate { } } +/// `candidate` when it passes exactly the check `process_sync_aggregate` will +/// hold it to, else [`empty_sync_aggregate`]. +/// +/// `state` is the block's pre-state advanced to the block's slot. The +/// participants are `current_sync_committee`'s members by bits, signing the +/// block root at `state.slot - 1` under `DOMAIN_SYNC_COMMITTEE` at that slot's +/// epoch. A pooled aggregate can fail this when the proposer's parent is not +/// the root the committee signed, or when the head moved across a period: it +/// then costs the rewards, never the block. +pub fn verified_sync_aggregate(state: &BeaconState, candidate: SyncAggregate) -> SyncAggregate { + let Ok((committee, _)) = state.sync_committees() else { + return empty_sync_aggregate(); + }; + let participants: Vec<_> = committee + .pubkeys + .iter() + .enumerate() + .filter(|(position, _)| { + candidate + .sync_committee_bits + .get(*position) + .unwrap_or(false) + }) + .map(|(_, pubkey)| *pubkey) + .collect(); + let previous_slot = state.slot().saturating_sub(1); + let Ok(block_root) = get_block_root_at_slot(state, previous_slot) else { + return empty_sync_aggregate(); + }; + let domain = get_domain( + state, + constants::DOMAIN_SYNC_COMMITTEE, + Some(compute_epoch_at_slot(previous_slot)), + ); + let signing_root = compute_signing_root(block_root, domain); + if bls::eth_fast_aggregate_verify( + &participants, + signing_root, + &candidate.sync_committee_signature, + ) { + candidate + } else { + empty_sync_aggregate() + } +} + /// The inverse of `get_execution_requests_list`: the execution client's /// EIP-7685 request list back into the block body's `ExecutionRequests`. /// @@ -296,6 +343,9 @@ pub struct BlockInputs { pub execution_payload: ExecutionPayload, pub blob_kzg_commitments: Vec, pub execution_requests: ExecutionRequests, + /// The block's sync aggregate: [`empty_sync_aggregate`], or what + /// [`verified_sync_aggregate`] vouched for. + pub sync_aggregate: SyncAggregate, } /// The unsigned block for the slot `state` has been advanced to, with its @@ -303,8 +353,8 @@ pub struct BlockInputs { /// /// The body votes the state's own `eth1_data` and carries no deposits (the /// deposit contract's log has been replaced by EIP-6110's requests), no -/// slashings, exits or credential changes (this node pools none), and an empty -/// sync aggregate. The block is run through `process_block` on a copy of +/// slashings, exits or credential changes (this node pools none), and the +/// sync aggregate it is given. The block is run through `process_block` on a copy of /// `state` with an execution engine that accepts the payload, which is the /// node's own execution client's payload; that run is also what rejects a body /// the network would, before anything is signed. @@ -321,7 +371,7 @@ pub fn assemble_block( .attestations .try_into() .map_err(|_| Error::SpecAssert("len(attestations) <= MAX_ATTESTATIONS_ELECTRA"))?, - sync_aggregate: empty_sync_aggregate(), + sync_aggregate: inputs.sync_aggregate, execution_payload: inputs.execution_payload, blob_kzg_commitments: inputs .blob_kzg_commitments @@ -431,6 +481,7 @@ mod tests { fn an_assembled_block_passes_process_block_and_names_its_post_state() { let state = state_to_build_on(); let inputs = BlockInputs { + sync_aggregate: empty_sync_aggregate(), randao_reveal: randao_reveal(&state), graffiti: Bytes32::repeat_byte(7), attestations: Vec::new(), @@ -469,6 +520,7 @@ mod tests { let mut payload = payload_for(&state); payload.parent_hash = ExecutionBlockHash::repeat_byte(9); let inputs = BlockInputs { + sync_aggregate: empty_sync_aggregate(), randao_reveal: randao_reveal(&state), graffiti: Bytes32::ZERO, attestations: Vec::new(), @@ -654,6 +706,84 @@ mod tests { assert!(parse_execution_requests(&[vec![0x7f, 0]]).is_err()); } + /// A pool holding messages from `positions` of the committee, signed the way + /// `process_sync_aggregate` will check them (over the block root at + /// `state.slot - 1`, under the state's own domain), plus that root. + fn pooled_aggregate( + state: &BeaconState, + signed_root: Option, + positions: &[usize], + ) -> (SyncAggregate, Root) { + use crate::beacon::sync_committee_pool::SyncCommitteePool; + use ethlambda_types::beacon::containers::altair::{ + SYNC_SUBCOMMITTEE_SIZE, SyncCommitteeMessage, + }; + + let previous_slot = state.slot() - 1; + let parent_root = get_block_root_at_slot(state, previous_slot).unwrap(); + let root = signed_root.unwrap_or(parent_root); + let domain = get_domain( + state, + constants::DOMAIN_SYNC_COMMITTEE, + Some(compute_epoch_at_slot(previous_slot)), + ); + let signing_root = compute_signing_root(root, domain); + let (committee, _) = state.sync_committees().unwrap(); + let mut pool = SyncCommitteePool::default(); + for &position in positions { + let pubkey = committee.pubkeys[position]; + let index = (0..64) + .find(|&index| state.validator(index).unwrap().pubkey == pubkey) + .expect("the committee is drawn from the registry"); + let message = SyncCommitteeMessage { + slot: previous_slot, + beacon_block_root: parent_root, + validator_index: index, + signature: sign_for(index as usize, signing_root), + }; + let seats = [( + (position / SYNC_SUBCOMMITTEE_SIZE) as u64, + position % SYNC_SUBCOMMITTEE_SIZE, + )]; + pool.insert_message(&message, &seats); + } + ( + pool.sync_aggregate(previous_slot, parent_root) + .expect("something was pooled"), + parent_root, + ) + } + + #[test] + fn a_pooled_sync_aggregate_passes_assemble_block() { + let state = state_to_build_on(); + let (candidate, _) = pooled_aggregate(&state, None, &[0, 1, 5]); + let verified = verified_sync_aggregate(&state, candidate.clone()); + assert_eq!(verified, candidate); + assert!(verified.sync_committee_bits.count_ones() >= 3); + let inputs = BlockInputs { + randao_reveal: randao_reveal(&state), + graffiti: Bytes32::repeat_byte(7), + attestations: Vec::new(), + execution_payload: payload_for(&state), + blob_kzg_commitments: Vec::new(), + execution_requests: ExecutionRequests::default(), + sync_aggregate: verified.clone(), + }; + let block = assemble_block(&state, inputs, &Config::mainnet()).unwrap(); + assert_eq!(block.body.sync_aggregate, verified); + } + + #[test] + fn a_sync_aggregate_over_the_wrong_root_is_replaced_by_the_empty_one() { + let state = state_to_build_on(); + let (wrong, _) = pooled_aggregate(&state, Some(Root::repeat_byte(1)), &[0, 1]); + assert_eq!( + verified_sync_aggregate(&state, wrong), + empty_sync_aggregate() + ); + } + #[test] fn the_empty_sync_aggregate_signs_with_the_point_at_infinity() { let aggregate = empty_sync_aggregate(); diff --git a/crates/blockchain/state_transition/src/beacon/gloas_block_production.rs b/crates/blockchain/state_transition/src/beacon/gloas_block_production.rs index a9800cba..9db202fb 100644 --- a/crates/blockchain/state_transition/src/beacon/gloas_block_production.rs +++ b/crates/blockchain/state_transition/src/beacon/gloas_block_production.rs @@ -21,7 +21,7 @@ use std::collections::BTreeMap; -use super::block_production::{empty_sync_aggregate, pack_attestations}; +use super::block_production::pack_attestations; use super::bls; use super::config::Config; use super::error::{Error, Result, verify}; @@ -37,6 +37,7 @@ use ethlambda_types::beacon::{ constants, containers::{ BeaconState, + altair::SyncAggregate, capella::Withdrawal, electra, gloas::{ @@ -345,6 +346,9 @@ pub struct GloasBlockInputs { /// The payload's own requests, which the bid commits to by root and the /// envelope carries. pub execution_requests: ExecutionRequests, + /// The block's sync aggregate: `empty_sync_aggregate`, or what + /// `verified_sync_aggregate` vouched for. + pub sync_aggregate: SyncAggregate, } /// A produced block and the unsigned envelope that reveals its payload. @@ -393,7 +397,7 @@ pub fn assemble_gloas_block( eth1_data: state.eth1_data().clone(), graffiti: inputs.graffiti, attestations: inputs.attestations.into(), - sync_aggregate: empty_sync_aggregate(), + sync_aggregate: inputs.sync_aggregate, signed_execution_payload_bid: SignedExecutionPayloadBid { message: bid, signature: BlsSignature(bls::G2_POINT_AT_INFINITY), @@ -502,7 +506,7 @@ fn check_envelope( pub mod test_support { use super::*; use crate::beacon::ForkName; - use crate::beacon::block_production::advance_to_slot; + use crate::beacon::block_production::{advance_to_slot, empty_sync_aggregate}; use crate::beacon::helpers::accessors::get_domain; use crate::beacon::helpers::fulu::initialize_proposer_lookahead; use crate::beacon::helpers::gloas::compute_ptc; @@ -606,6 +610,7 @@ pub mod test_support { assemble_gloas_block( state, GloasBlockInputs { + sync_aggregate: empty_sync_aggregate(), randao_reveal: randao_reveal(state), graffiti: Bytes32::repeat_byte(7), attestations: Vec::new(), @@ -655,7 +660,8 @@ mod gloas_block_production_tests { use super::super::stf::ExecutionEngine; use super::test_support::*; use super::*; - use crate::beacon::helpers::accessors::get_domain; + use crate::beacon::block_production::empty_sync_aggregate; + use crate::beacon::helpers::accessors::{get_block_root_at_slot, get_domain}; use crate::beacon::helpers::misc::compute_signing_root; use crate::beacon::helpers::test_state::sign_for; @@ -691,6 +697,66 @@ mod gloas_block_production_tests { } } + /// A sync aggregate of committee `positions` signing `root` under the + /// state's own domain for slot `state.slot - 1`. + fn sync_aggregate_over(state: &BeaconState, root: Root, positions: &[usize]) -> SyncAggregate { + let previous_slot = state.slot() - 1; + let domain = get_domain( + state, + constants::DOMAIN_SYNC_COMMITTEE, + Some(previous_slot / preset::SLOTS_PER_EPOCH), + ); + let signing_root = compute_signing_root(root, domain); + let (committee, _) = state.sync_committees().unwrap(); + let mut aggregate = empty_sync_aggregate(); + let mut signatures = Vec::new(); + for &position in positions { + let pubkey = committee.pubkeys[position]; + let index = (0..64) + .find(|&index| state.validator(index).unwrap().pubkey == pubkey) + .expect("the committee is drawn from the registry"); + signatures.push(sign_for(index as usize, signing_root)); + aggregate.sync_committee_bits.set(position, true).unwrap(); + } + aggregate.sync_committee_signature = bls::aggregate(&signatures).unwrap(); + aggregate + } + + #[test] + fn a_verified_sync_aggregate_is_packed_and_a_wrong_one_is_replaced() { + use crate::beacon::block_production::verified_sync_aggregate; + + let state = state_to_build_on(); + let parent_root = get_block_root_at_slot(&state, state.slot() - 1).unwrap(); + let good = sync_aggregate_over(&state, parent_root, &[0, 1, 2]); + assert_eq!(verified_sync_aggregate(&state, good.clone()), good); + let wrong = sync_aggregate_over(&state, Root::repeat_byte(1), &[0, 1, 2]); + assert_eq!( + verified_sync_aggregate(&state, wrong), + empty_sync_aggregate() + ); + + let requests = ExecutionRequests::default(); + let inputs = gloas_payload_inputs(&state, true, &requests, &config()).unwrap(); + let produced = assemble_gloas_block( + &state, + GloasBlockInputs { + sync_aggregate: good.clone(), + randao_reveal: randao_reveal(&state), + graffiti: Bytes32::repeat_byte(7), + attestations: Vec::new(), + payload_attestations: Vec::new(), + parent_execution_requests: requests, + execution_payload: payload_for(&inputs), + blob_kzg_commitments: Vec::new(), + execution_requests: ExecutionRequests::default(), + }, + &config(), + ) + .unwrap(); + assert_eq!(produced.block.body.sync_aggregate, good); + } + #[test] fn a_self_built_block_passes_the_stf_with_a_real_signature_and_its_envelope_verifies() { let state = state_to_build_on(); @@ -820,6 +886,7 @@ mod gloas_block_production_tests { let result = assemble_gloas_block( &state, GloasBlockInputs { + sync_aggregate: empty_sync_aggregate(), randao_reveal: randao_reveal(&state), graffiti: Bytes32::ZERO, attestations: Vec::new(), @@ -850,6 +917,7 @@ mod gloas_block_production_tests { let produced = assemble_gloas_block( &state, GloasBlockInputs { + sync_aggregate: empty_sync_aggregate(), randao_reveal: randao_reveal(&state), graffiti: Bytes32::ZERO, attestations: Vec::new(), @@ -1003,6 +1071,7 @@ mod gloas_block_production_tests { assemble_gloas_block( &state, GloasBlockInputs { + sync_aggregate: empty_sync_aggregate(), randao_reveal: randao_reveal(&state), graffiti: Bytes32::ZERO, attestations: packed, diff --git a/crates/net/api/src/lib.rs b/crates/net/api/src/lib.rs index 7c3c3305..6e1bd5be 100644 --- a/crates/net/api/src/lib.rs +++ b/crates/net/api/src/lib.rs @@ -4,6 +4,7 @@ use ethlambda_types::{ attestation::{SignedAggregatedAttestation, SignedAttestation}, beacon::containers::{ DataColumnSidecar, SignedAggregateAndProof, SignedBeaconBlock, + altair::{SignedContributionAndProof, SyncCommitteeMessage}, electra::SingleAttestation, gloas::{PayloadAttestationMessage, SignedExecutionPayloadEnvelope}, }, @@ -350,6 +351,24 @@ pub trait RpcToP2P: Send + Sync { /// until the end of the paired slot, so their committees' attestations /// reach this node's pool. `(subnet_id, slot)` pairs. fn subscribe_attestation_subnets(&self, subnets: Vec<(u64, u64)>) -> Result<(), ActorError>; + /// Gossip one sync committee message on `sync_committee_{id}` for every id + /// in `subnet_ids` (every subnet its validator has a seat in, computed by + /// the caller from the head state), and mark it seen. Checked by the caller. + fn publish_sync_committee_message( + &self, + subnet_ids: Vec, + message: SyncCommitteeMessage, + ) -> Result<(), ActorError>; + /// Gossip one signed contribution on `sync_committee_contribution_and_proof`, + /// and mark it seen. Checked by the caller. + fn publish_sync_committee_contribution( + &self, + contribution: SignedContributionAndProof, + ) -> Result<(), ActorError>; + /// Join sync committee subnets until the paired epoch (exclusive), so a + /// validator client's committee members can publish on them and its + /// aggregators hear their subcommittee. `(subnet_id, until_epoch)` pairs. + fn subscribe_sync_committee_subnets(&self, subnets: Vec<(u64, u64)>) -> Result<(), ActorError>; /// Gossip a block a validator client signed, and import it: gossip never /// delivers a node its own messages, so without the second half this node /// would not follow its own proposal. Checked by the caller as above. diff --git a/crates/net/p2p/src/beacon/mod.rs b/crates/net/p2p/src/beacon/mod.rs index 8ebc3721..e61ed319 100644 --- a/crates/net/p2p/src/beacon/mod.rs +++ b/crates/net/p2p/src/beacon/mod.rs @@ -23,6 +23,7 @@ pub mod messages; pub mod protocols; pub mod subnets; pub mod swarm; +pub mod sync_committee; pub mod topics; pub mod transition; pub mod verdict; diff --git a/crates/net/p2p/src/beacon/sync_committee.rs b/crates/net/p2p/src/beacon/sync_committee.rs new file mode 100644 index 00000000..a4e11853 --- /dev/null +++ b/crates/net/p2p/src/beacon/sync_committee.rs @@ -0,0 +1,45 @@ +//! Sync committee subnets and publishing for the beacon wire. +//! +//! Sync subnets are joined on demand only, from a validator client's +//! `sync_committee_subscriptions` request, and advertised in MetaData's +//! `syncnets` (never the ENR). Nothing here subscribes without a request. +//! +//! These are the entry points the `RpcToP2P` handlers call. The bodies are +//! filled in by the p2p stage of the sync committee work; until then they do +//! nothing, so the workspace builds with the handlers wired. + +use ethlambda_types::beacon::containers::altair::{ + SignedContributionAndProof, SyncCommitteeMessage, +}; +use ethlambda_types::beacon::primitives::Epoch; + +use crate::P2PServer; + +/// Gossip `message` on `sync_committee_{id}` for each of `subnet_ids`, and +/// mark each `(slot, validator, subnet)` seen so a peer's echo is ignored. +pub(crate) fn publish_sync_committee_message( + _server: &mut P2PServer, + _subnet_ids: Vec, + _message: SyncCommitteeMessage, +) { +} + +/// Gossip `signed` on `sync_committee_contribution_and_proof`, and mark it +/// seen. +pub(crate) fn publish_sync_committee_contribution( + _server: &mut P2PServer, + _signed: SignedContributionAndProof, +) { +} + +/// Join each `(subnet_id, until_epoch)` (exclusive), extending an existing +/// join. +pub(crate) fn join_sync_committee_subnets(_server: &mut P2PServer, _subnets: Vec<(u64, Epoch)>) {} + +/// Leave every subnet whose `until_epoch` has been reached. +#[allow(dead_code)] +pub(crate) fn leave_expired_sync_committee_subnets(_server: &mut P2PServer) {} + +/// Drop pooled messages older than the retained window. +#[allow(dead_code)] +pub(crate) fn prune_sync_committee_pool(_server: &P2PServer) {} diff --git a/crates/net/p2p/src/lib.rs b/crates/net/p2p/src/lib.rs index ff52d419..bd328ac0 100644 --- a/crates/net/p2p/src/lib.rs +++ b/crates/net/p2p/src/lib.rs @@ -46,7 +46,8 @@ use ethlambda_network_api::{ rpc_to_p2p::{ PublishBeaconAggregate, PublishBeaconAttestation, PublishBeaconBlock, PublishExecutionPayloadEnvelope, PublishPayloadAttestationMessage, - SubscribeAttestationSubnets, + PublishSyncCommitteeContribution, PublishSyncCommitteeMessage, SubscribeAttestationSubnets, + SubscribeSyncCommitteeSubnets, }, }; use ethlambda_state_transition::beacon::aggregate::MAX_AGGREGATES_PER_SLOT; @@ -56,7 +57,9 @@ use ethlambda_state_transition::beacon::gossip::{ payload_attestation::SeenPayloadAttestations, }; use ethlambda_state_transition::beacon::{ - attestation_pool::SharedAttestationPool, payload_attestation_pool::SharedPayloadAttestationPool, + attestation_pool::SharedAttestationPool, + payload_attestation_pool::SharedPayloadAttestationPool, + sync_committee_pool::SharedSyncCommitteePool, }; use ethlambda_storage::{Chain, Store}; use ethlambda_types::beacon::preset::{MAX_VALIDATORS_PER_COMMITTEE, SLOTS_PER_EPOCH}; @@ -1093,6 +1096,7 @@ impl P2P { discovery: Option, attestation_pool: SharedAttestationPool, payload_attestation_pool: SharedPayloadAttestationPool, + sync_committee_pool: SharedSyncCommitteePool, ) -> Result { let discovery = match discovery { Some(config) => Some(spawn_discovery(config).await?), @@ -1156,6 +1160,7 @@ impl P2P { )), attestation_pool, payload_attestation_pool, + sync_committee_pool, aggregator_subnets: HashMap::new(), }; let discovery_enabled = server.discovery.is_some(); @@ -1296,6 +1301,14 @@ pub struct P2PServer { /// lean never touches it. pub(crate) payload_attestation_pool: SharedPayloadAttestationPool, + /// Accepted sync committee messages and contributions, shared with the + /// Beacon API that serves and fills the same pool (block production reads + /// it). Filled by `verdict::forward`; lean never touches it. + // Read once the sync committee gossip verdicts land; see + // `beacon::sync_committee`. + #[allow(dead_code)] + pub(crate) sync_committee_pool: SharedSyncCommitteePool, + /// The attestation subnets joined for a validator client's aggregators, /// each with the last slot it is needed for. Short-lived by design: never /// advertised in `attnets`, and left once the slot has passed. The @@ -1618,6 +1631,24 @@ impl Handler for P2PServer { } } +impl Handler for P2PServer { + async fn handle(&mut self, msg: PublishSyncCommitteeMessage, _ctx: &Context) { + beacon::sync_committee::publish_sync_committee_message(self, msg.subnet_ids, msg.message); + } +} + +impl Handler for P2PServer { + async fn handle(&mut self, msg: PublishSyncCommitteeContribution, _ctx: &Context) { + beacon::sync_committee::publish_sync_committee_contribution(self, msg.contribution); + } +} + +impl Handler for P2PServer { + async fn handle(&mut self, msg: SubscribeSyncCommitteeSubnets, _ctx: &Context) { + beacon::sync_committee::join_sync_committee_subnets(self, msg.subnets); + } +} + impl Handler for P2PServer { async fn handle(&mut self, msg: PublishBeaconAttestation, _ctx: &Context) { publish_beacon_attestation(self, msg.subnet_id, msg.attestation).await; @@ -2783,6 +2814,7 @@ pub(crate) mod test_support { )), attestation_pool: Default::default(), payload_attestation_pool: Default::default(), + sync_committee_pool: Default::default(), aggregator_subnets: HashMap::new(), } } diff --git a/crates/net/p2p/src/req_resp/handlers.rs b/crates/net/p2p/src/req_resp/handlers.rs index daad0659..7ee43882 100644 --- a/crates/net/p2p/src/req_resp/handlers.rs +++ b/crates/net/p2p/src/req_resp/handlers.rs @@ -2790,6 +2790,7 @@ pub(crate) mod tests { )), attestation_pool: Default::default(), payload_attestation_pool: Default::default(), + sync_committee_pool: Default::default(), aggregator_subnets: HashMap::new(), } } diff --git a/crates/net/rpc/src/beacon/gloas_proposal.rs b/crates/net/rpc/src/beacon/gloas_proposal.rs index fb9218a1..2d133508 100644 --- a/crates/net/rpc/src/beacon/gloas_proposal.rs +++ b/crates/net/rpc/src/beacon/gloas_proposal.rs @@ -646,6 +646,8 @@ fn assemble( pack_payload_attestations(&state, head_root, head_slot, messages, config); let commitments = built.blobs_bundle.commitments; let inputs = |attestations, payload_attestations| GloasBlockInputs { + sync_aggregate: ethlambda_state_transition::beacon::block_production::empty_sync_aggregate( + ), randao_reveal, graffiti, attestations, @@ -1104,6 +1106,8 @@ mod tests { assemble_gloas_block( state, GloasBlockInputs { + sync_aggregate: + ethlambda_state_transition::beacon::block_production::empty_sync_aggregate(), randao_reveal: randao_reveal(state), graffiti: Bytes32::ZERO, attestations: Vec::new(), @@ -1346,6 +1350,8 @@ mod tests { let produced = assemble_gloas_block( &state, GloasBlockInputs { + sync_aggregate: + ethlambda_state_transition::beacon::block_production::empty_sync_aggregate(), randao_reveal: randao_reveal(&state), graffiti: Bytes32::ZERO, attestations: Vec::new(), diff --git a/crates/net/rpc/src/beacon/proposal.rs b/crates/net/rpc/src/beacon/proposal.rs index 52be05c6..6c20ae73 100644 --- a/crates/net/rpc/src/beacon/proposal.rs +++ b/crates/net/rpc/src/beacon/proposal.rs @@ -396,6 +396,8 @@ async fn produce( .block_candidates(); let attestations = pack_attestations(&state, candidates); let inputs = |attestations| BlockInputs { + sync_aggregate: ethlambda_state_transition::beacon::block_production::empty_sync_aggregate( + ), randao_reveal, graffiti, attestations, diff --git a/crates/net/rpc/src/lib.rs b/crates/net/rpc/src/lib.rs index e03ae786..3899cef7 100644 --- a/crates/net/rpc/src/lib.rs +++ b/crates/net/rpc/src/lib.rs @@ -4,7 +4,9 @@ use axum::{Extension, Router}; use ethlambda_blockchain::{EventBus, SyncStatusController}; use ethlambda_network_api::RpcToP2PRef; use ethlambda_state_transition::beacon::{ - attestation_pool::SharedAttestationPool, payload_attestation_pool::SharedPayloadAttestationPool, + attestation_pool::SharedAttestationPool, + payload_attestation_pool::SharedPayloadAttestationPool, + sync_committee_pool::SharedSyncCommitteePool, }; use ethlambda_storage::Store; use ethlambda_types::aggregator::AggregatorController; @@ -202,6 +204,9 @@ pub struct BeaconApiHandles { /// Filled by gossip and the payload attestation pool endpoint, read by /// block production and the pool's GET. pub payload_attestation_pool: SharedPayloadAttestationPool, + /// Filled by gossip and the sync committee endpoints, read by block + /// production and the contribution endpoint. + pub sync_committee_pool: SharedSyncCommitteePool, /// The columns this node custodies: what `payload_attestation_data` checks /// a block's blob availability against, and what block production tells /// the execution client it samples for when asking it to build a gloas @@ -231,6 +236,7 @@ pub async fn start_beacon_rpc_server( .layer(Extension(handles.p2p)) .layer(Extension(handles.attestation_pool)) .layer(Extension(handles.payload_attestation_pool)) + .layer(Extension(handles.sync_committee_pool)) .layer(Extension(handles.custody_columns)) .layer(Extension(beacon::validator::FeeRecipients::default())) .layer(Extension(handles.engine)); @@ -443,6 +449,18 @@ pub(crate) mod test_utils { pub(crate) payload_attestations: std::sync::Mutex< Vec, >, + /// `(subnet ids, message)` per sync committee message published. + pub(crate) sync_messages: std::sync::Mutex< + Vec<( + Vec, + ethlambda_types::beacon::containers::altair::SyncCommitteeMessage, + )>, + >, + pub(crate) sync_contributions: std::sync::Mutex< + Vec, + >, + /// `(subnet id, until epoch)` pairs asked for. + pub(crate) sync_subscriptions: std::sync::Mutex>, } impl ethlambda_network_api::RpcToP2P for RecordingNetwork { @@ -500,6 +518,34 @@ pub(crate) mod test_utils { self.payload_attestations.lock().unwrap().push(message); Ok(()) } + + fn publish_sync_committee_message( + &self, + subnet_ids: Vec, + message: ethlambda_types::beacon::containers::altair::SyncCommitteeMessage, + ) -> Result<(), spawned_concurrency::error::ActorError> { + self.sync_messages + .lock() + .unwrap() + .push((subnet_ids, message)); + Ok(()) + } + + fn publish_sync_committee_contribution( + &self, + contribution: ethlambda_types::beacon::containers::altair::SignedContributionAndProof, + ) -> Result<(), spawned_concurrency::error::ActorError> { + self.sync_contributions.lock().unwrap().push(contribution); + Ok(()) + } + + fn subscribe_sync_committee_subnets( + &self, + subnets: Vec<(u64, u64)>, + ) -> Result<(), spawned_concurrency::error::ActorError> { + self.sync_subscriptions.lock().unwrap().extend(subnets); + Ok(()) + } } /// [`beacon_fixture`]'s chain with three gloas blocks on top of its head. diff --git a/docs/metrics.md b/docs/metrics.md index cbdc72d6..27a0f9ce 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -362,8 +362,9 @@ own section. These are ethlambda-specific, not part of the leanMetrics spec. | `lean_beacon_gossip_verdict_expired_total` | Counter | Verdicts that arrived after gossipsub evicted the message, so an Accept propagated nothing | When `report_message_validation_result` returns `false` | kind | | `kind` is the topic kind, with every `data_column_sidecar_{subnet}` sharing the -label `data_column_sidecar` and every `beacon_attestation_{subnet_id}` sharing -`beacon_attestation`. The gloas topics `execution_payload` and +label `data_column_sidecar`, every `beacon_attestation_{subnet_id}` sharing +`beacon_attestation` and every `sync_committee_{subnet_id}` sharing +`sync_committee`. The gloas topics `execution_payload` and `payload_attestation_message` are labelled by their own name. `queue` means IGNORE to gossipsub while the chain actor still receives the object and parks it; of the gloas topics only `execution_payload` answers it, for an envelope @@ -390,7 +391,13 @@ only), `not_aggregator` (aggregate only), `not_in_committee`, `aggregator_signature` (aggregate only), `aggregate_signature` (aggregate only), `target_not_ancestor`, `wrong_subnet` (attestation only), and gloas's `data_index_out_of_range`, `same_slot_payload_flag` and `payload_invalid` on the -reject side. `already_seen`, `overloaded` and `unsupported_fork` are shared with the +reject side. The sync committee topics add `sync_committee_unavailable` (the head +state's committees cannot answer for the message's period) on the ignore side +and `subcommittee_index` (contribution only) on the reject side; they reuse +`not_current_slot`, `already_seen`, `covered_bits`, `no_participants`, +`not_aggregator`, `not_in_committee`, `unknown_validator`, `selection_proof`, +`aggregator_signature`, `aggregate_signature`, `wrong_subnet` and +`bad_signature`. `already_seen`, `overloaded` and `unsupported_fork` are shared with the other topics: `unsupported_fork` is an ignore reason for a message of a fork this build has no gossip rules for, so an honest peer past the fork epoch is not scored as a bad decoder. Gloas blocks, data columns, aggregates and attestations diff --git a/docs/spec_deviations.md b/docs/spec_deviations.md index f4a13152..db2162f9 100644 --- a/docs/spec_deviations.md +++ b/docs/spec_deviations.md @@ -455,3 +455,69 @@ node, and a pre-gloas block's is its parent. Both walks stop at finality. blocks. - A hash of zero is "nothing to say" on either fork, so a payload whose hash is zero (the fixtures' placeholder) sends no `forkchoiceUpdated`. + +## Sync committee gossip resolves the committee by the message's slot and the domain by the fork schedule + +`sync_committee_{subnet_id}` and `sync_committee_contribution_and_proof` +validate against the cached head state, choosing the committee from the +message's slot and the signing domain from the config's fork schedule. + +- **Specification:** `p2p-interface.md`'s `get_sync_subcommittee_pubkeys` and + `validator.md`'s `compute_subnets_for_sync_committee` pick the committee by + `state.slot + 1`, and `get_domain(state, ..)` takes the fork version from + `state.fork`. +- **ethlambda:** the committee is chosen by `message.slot + 1` + (`current_sync_committee` in the head's own period, `next_sync_committee` in + the one after), and the domain comes from `Config`'s schedule + (`helpers::sync_committee`). The validator client signs with the same + domain. When the head is in the message's period and fork the answer is the + specification's. When it lags across a period or fork boundary, the + specification would reject honest messages and this does not. +- **Unanswerable cases are IGNORE:** a head state that is not cached is + `IGNORE` (`state_unavailable`), like every other topic, and a period the head + state's two committees cannot cover is `IGNORE sync_committee_unavailable`, + since neither is the sender's fault. + +## Sync committee subnets are joined on request, immediately, and advertised in MetaData only + +- **Specification:** `validator.md` ("Sync committee subnet stability") has a + validator join its subnets a random 1 to `SYNC_COMMITTEE_SUBNET_COUNT` epochs + before its period starts, and has the node advertise them in the ENR's + `syncnets`. +- **ethlambda:** a subnet is joined only when a validator client posts + `POST /eth/v1/validator/sync_committee_subscriptions`, at once, until + `until_epoch` (exclusive) clamped to the end of the next period. The node + never subscribes without a request, because validating every subnet would + cost each follower up to `SYNC_COMMITTEE_SIZE` BLS verifications a slot. + `syncnets` appears in MetaData, never in the ENR: ethrex's `DiscoveryServer` + cannot replace the served record at runtime, the same known gap as the + `eth2` entry. Peers therefore find this node's sync subnets by chance, while + publishing still reaches the mesh, because a joined subnet is subscribed. +- The `sync_committee_contribution_and_proof` topic is the exception: it is + subscribed under every digest and always validated, and accepted + contributions are relayed and pooled for block production. + +## Block production packs only the parent's sync committee votes + +`validator.md` ("Sync committee") describes block packing in terms of +contributions only. + +- **ethlambda:** a block at slot `N` packs only what is pooled for + `(N - 1, parent_root)`. For each subcommittee it takes the best pooled + contribution and adds every pooled direct message at a position that + contribution does not cover. The result is verified exactly as + `process_sync_aggregate` will check it + (`block_production::verified_sync_aggregate`); a failure, or a proposer + whose parent is not the root the committee signed, gets the empty aggregate. + Every "retry without operations" fallback also drops it. That costs rewards, + never the block. +- Messages from earlier slots over the same root are not packed. + +## The validator client signs a sync committee message at the deadline only + +`validator.md` ("Prepare sync committee message") has a member sign as soon as +it sees the block for the slot, or at the sync-message deadline, whichever +comes first. The validator client signs once, at the deadline +(`SYNC_MESSAGE_DUE_BPS`, or its gloas variant), over the head root it then +reads, and refuses to when that root is optimistic +(`specs/bellatrix/optimistic-sync.md`). From 00ab809986b3f2ef232a45d6210adc5a7da391e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:05:13 -0300 Subject: [PATCH 08/11] feat(rpc): serve the sync committee endpoints and pack the pooled aggregate A validator client on a sync committee needs somewhere to send its messages and contributions, and a way to ask for the subnets to be joined. Add the four beacon-APIs endpoints (pool/sync_committees, sync_committee_contribution, contribution_and_proofs, sync_committee_subscriptions). Submissions run the same checks as gossip before they are published, and are pooled here because gossip never echoes a node its own messages. Block production now takes the sync aggregate pooled for (slot - 1, parent_root) instead of always the empty one. It is verified against the block's own pre-state, so a stale or wrong-root candidate costs the rewards and never the block, and the "retry without operations" fallbacks drop it too. --- crates/net/rpc/src/beacon/gloas_proposal.rs | 125 ++- crates/net/rpc/src/beacon/mod.rs | 2 + crates/net/rpc/src/beacon/proposal.rs | 36 +- crates/net/rpc/src/beacon/sync_committee.rs | 909 ++++++++++++++++++ .../rpc/src/beacon/validator_client_tests.rs | 5 +- docs/rpc.md | 66 +- 6 files changed, 1118 insertions(+), 25 deletions(-) create mode 100644 crates/net/rpc/src/beacon/sync_committee.rs diff --git a/crates/net/rpc/src/beacon/gloas_proposal.rs b/crates/net/rpc/src/beacon/gloas_proposal.rs index 2d133508..4f6897d2 100644 --- a/crates/net/rpc/src/beacon/gloas_proposal.rs +++ b/crates/net/rpc/src/beacon/gloas_proposal.rs @@ -46,7 +46,7 @@ use ethlambda_engine::{ use ethlambda_network_api::RpcToP2PRef; use ethlambda_state_transition::beacon::{ attestation_pool::SharedAttestationPool, - block_production::advance_to_slot, + block_production::{advance_to_slot, empty_sync_aggregate, verified_sync_aggregate}, bls, fork_choice::{ get_head_node, gloas_verify_data_column_sidecar, @@ -63,6 +63,7 @@ use ethlambda_state_transition::beacon::{ }, payload_attestation_pool::SharedPayloadAttestationPool, stf::gloas::verify_execution_payload_envelope_signature, + sync_committee_pool::SharedSyncCommitteePool, }; use ethlambda_storage::Store; use ethlambda_types::{ @@ -71,6 +72,7 @@ use ethlambda_types::{ constants, containers::{ self, BeaconState, DataColumnSidecar, + altair::SyncAggregate, deneb::Blob, gloas::{ BeaconBlock, ExecutionPayloadEnvelope, ExecutionRequests, @@ -244,6 +246,7 @@ async fn post_produce_block( Extension(engine): Extension>, Extension(pool): Extension, Extension(ptc_pool): Extension, + Extension(sync_pool): Extension, Extension(fee_recipients): Extension, Extension(custody): Extension, Extension(cache): Extension, @@ -291,6 +294,7 @@ async fn post_produce_block( &engine, &pool, &ptc_pool, + &sync_pool, &fee_recipients, &custody, slot, @@ -405,6 +409,7 @@ async fn produce( engine: &EngineClient, pool: &SharedAttestationPool, ptc_pool: &SharedPayloadAttestationPool, + sync_pool: &SharedSyncCommitteePool, fee_recipients: &FeeRecipients, custody: &NodeCustodyColumns, slot: Slot, @@ -440,6 +445,12 @@ async fn produce( .lock() .expect("payload attestation pool lock poisoned") .messages_for(slot.saturating_sub(1), prepared.head_root); + // What the committee signed at the previous slot over this block's parent; + // `assemble` verifies it against the block's own pre-state. + let sync_candidate = sync_pool + .lock() + .expect("sync committee pool lock poisoned") + .sync_aggregate(slot.saturating_sub(1), prepared.head_root); let config = store.config(); let assembled = { let built = built.clone(); @@ -451,6 +462,7 @@ async fn produce( execution_requests, candidates, messages, + sync_candidate, randao_reveal, graffiti, ) @@ -631,6 +643,7 @@ fn assemble( execution_requests: ExecutionRequests, candidates: Vec, messages: Vec, + sync_candidate: Option, randao_reveal: BlsSignature, graffiti: Bytes32, ) -> Result { @@ -645,9 +658,15 @@ fn assemble( let payload_attestations = pack_payload_attestations(&state, head_root, head_slot, messages, config); let commitments = built.blobs_bundle.commitments; - let inputs = |attestations, payload_attestations| GloasBlockInputs { - sync_aggregate: ethlambda_state_transition::beacon::block_production::empty_sync_aggregate( - ), + // Verified exactly as `process_sync_aggregate` will check it, against the + // state the block is built on; a candidate that fails becomes the empty + // aggregate rather than a block the chain would refuse. + let sync_aggregate = sync_candidate.map_or_else(empty_sync_aggregate, |candidate| { + verified_sync_aggregate(&state, candidate) + }); + let has_sync_aggregate = sync_aggregate != empty_sync_aggregate(); + let inputs = |attestations, payload_attestations, sync_aggregate| GloasBlockInputs { + sync_aggregate, randao_reveal, graffiti, attestations, @@ -658,14 +677,16 @@ fn assemble( execution_requests: execution_requests.clone(), }; let operations = attestations.len() + payload_attestations.len(); - match assemble_gloas_block(&state, inputs(attestations, payload_attestations), config) { + let attempt = inputs(attestations, payload_attestations, sync_aggregate); + match assemble_gloas_block(&state, attempt, config) { Ok(produced) => Ok(produced), - // The packers check every operation's signature against this state, so - // this should not happen; but a block without them still earns the - // proposal, and one that fails to build earns nothing. - Err(err) if operations > 0 => { + // The packers check every operation's signature against this state, and + // the sync aggregate was verified above, so this should not happen; but + // a block without them still earns the proposal, and one that fails to build earns nothing. + Err(err) if operations > 0 || has_sync_aggregate => { warn!(slot = state.slot(), %err, "Block with operations failed to build; retrying without"); - assemble_gloas_block(&state, inputs(Vec::new(), Vec::new()), config) + let bare = inputs(Vec::new(), Vec::new(), empty_sync_aggregate()); + assemble_gloas_block(&state, bare, config) .map_err(|_| ApiError::Internal("the block failed to build")) } Err(_) => Err(ApiError::Internal("the block failed to build")), @@ -924,6 +945,7 @@ mod tests { .with_state(store) .layer(Extension(engine)) .layer(Extension(SharedAttestationPool::default())) + .layer(Extension(SharedSyncCommitteePool::default())) .layer(Extension(SharedPayloadAttestationPool::default())) .layer(Extension(FeeRecipients::default())) .layer(Extension(NodeCustodyColumns::default())) @@ -1122,6 +1144,89 @@ mod tests { .unwrap() } + /// A sync aggregate of committee `positions` signing `root` under the + /// state's own domain for slot `state.slot - 1`, the check + /// `process_sync_aggregate` holds a block's aggregate to. + fn sync_aggregate_over(state: &BeaconState, root: H256, positions: &[usize]) -> SyncAggregate { + use ethlambda_state_transition::beacon::helpers::test_state::sign_for; + let previous_slot = state.slot() - 1; + let domain = get_domain( + state, + constants::DOMAIN_SYNC_COMMITTEE, + Some(compute_epoch_at_slot(previous_slot)), + ); + let signing_root = compute_signing_root(root, domain); + let (committee, _) = state.sync_committees().unwrap(); + let mut aggregate = empty_sync_aggregate(); + let mut signatures = Vec::new(); + for &position in positions { + let pubkey = committee.pubkeys[position]; + let index = (0..64) + .find(|&index| state.validator(index).unwrap().pubkey == pubkey) + .expect("the committee is drawn from the registry"); + signatures.push(sign_for(index as usize, signing_root)); + aggregate.sync_committee_bits.set(position, true).unwrap(); + } + aggregate.sync_committee_signature = bls::aggregate(&signatures).unwrap(); + aggregate + } + + /// `assemble` over the fixture's build state with `candidate` pooled. + fn assembled_with(state: &BeaconState, candidate: Option) -> GloasProduced { + use ethlambda_state_transition::beacon::gloas_block_production::test_support::{ + payload_for, randao_reveal, + }; + let requests = ExecutionRequests::default(); + let inputs = gloas_payload_inputs(state, true, &requests, &config()).unwrap(); + let built = BuiltGloasPayload { + execution_payload: payload_for(&inputs), + block_value: Default::default(), + blobs_bundle: Default::default(), + execution_requests: Vec::new(), + }; + let prepared = Prepared { + state: state.clone(), + inputs, + proposer: get_beacon_proposer_index(state).unwrap(), + head_root: H256::repeat_byte(5), + head_slot: state.slot() - 1, + parent_requests: requests.clone(), + }; + assemble( + &config(), + prepared, + built, + requests, + Vec::new(), + Vec::new(), + candidate, + randao_reveal(state), + Bytes32::ZERO, + ) + .unwrap() + } + + #[test] + fn a_pooled_sync_aggregate_is_included_and_a_wrong_one_becomes_the_empty_aggregate() { + use ethlambda_state_transition::beacon::{ + gloas_block_production::test_support::state_to_build_on, + helpers::accessors::get_block_root_at_slot, + }; + let state = state_to_build_on(); + let parent_root = get_block_root_at_slot(&state, state.slot() - 1).unwrap(); + + let good = sync_aggregate_over(&state, parent_root, &[0, 1, 2]); + let produced = assembled_with(&state, Some(good.clone())); + assert_eq!(produced.block.body.sync_aggregate, good); + + let wrong = sync_aggregate_over(&state, H256::repeat_byte(1), &[0, 1, 2]); + let produced = assembled_with(&state, Some(wrong)); + assert_eq!(produced.block.body.sync_aggregate, empty_sync_aggregate()); + + let produced = assembled_with(&state, None); + assert_eq!(produced.block.body.sync_aggregate, empty_sync_aggregate()); + } + fn signed_envelope( post: &BeaconState, produced: &GloasProduced, diff --git a/crates/net/rpc/src/beacon/mod.rs b/crates/net/rpc/src/beacon/mod.rs index 3b94fbb1..fab3cc15 100644 --- a/crates/net/rpc/src/beacon/mod.rs +++ b/crates/net/rpc/src/beacon/mod.rs @@ -26,6 +26,7 @@ pub(crate) mod pool; pub(crate) mod proposal; pub(crate) mod ptc; pub(crate) mod states; +pub(crate) mod sync_committee; pub(crate) mod validator; #[cfg(test)] mod validator_client_tests; @@ -118,6 +119,7 @@ pub(crate) fn routes(version: &'static str, peer_id: String) -> Router { .merge(proposal::routes()) .merge(gloas_proposal::routes()) .merge(ptc::routes()) + .merge(sync_committee::routes()) } #[cfg(test)] diff --git a/crates/net/rpc/src/beacon/proposal.rs b/crates/net/rpc/src/beacon/proposal.rs index 6c20ae73..c4f08576 100644 --- a/crates/net/rpc/src/beacon/proposal.rs +++ b/crates/net/rpc/src/beacon/proposal.rs @@ -29,10 +29,11 @@ use ethlambda_network_api::RpcToP2PRef; use ethlambda_state_transition::beacon::{ attestation_pool::SharedAttestationPool, block_production::{ - BlockInputs, advance_to_slot, assemble_block, pack_attestations, parse_execution_requests, - payload_inputs, + BlockInputs, advance_to_slot, assemble_block, empty_sync_aggregate, pack_attestations, + parse_execution_requests, payload_inputs, verified_sync_aggregate, }, stf::verify_block_signature, + sync_committee_pool::SharedSyncCommitteePool, }; use ethlambda_storage::Store; use ethlambda_types::{ @@ -283,12 +284,14 @@ fn require_fulu_slot(config: &Config, slot: Slot, message: &'static str) -> Resu } } +#[allow(clippy::too_many_arguments)] async fn get_block( Path(slot): Path, Query(query): Query, State(store): State, Extension(engine): Extension>, Extension(pool): Extension, + Extension(sync_pool): Extension, Extension(fee_recipients): Extension, headers: HeaderMap, ) -> Response { @@ -315,6 +318,7 @@ async fn get_block( &store, &engine, &pool, + &sync_pool, &fee_recipients, slot, query.randao_reveal, @@ -358,10 +362,12 @@ async fn get_block( /// The block for `slot`, the payload's value in wei as a decimal string, and /// the block's fork. +#[allow(clippy::too_many_arguments)] async fn produce( store: &Store, engine: &EngineClient, pool: &SharedAttestationPool, + sync_pool: &SharedSyncCommitteePool, fee_recipients: &FeeRecipients, slot: Slot, randao_reveal: BlsSignature, @@ -395,9 +401,20 @@ async fn produce( .expect("attestation pool lock poisoned") .block_candidates(); let attestations = pack_attestations(&state, candidates); - let inputs = |attestations| BlockInputs { - sync_aggregate: ethlambda_state_transition::beacon::block_production::empty_sync_aggregate( - ), + // What the committee signed at the previous slot over this block's parent, + // verified exactly as `process_sync_aggregate` will check it against the + // state the block is built on; a candidate that fails becomes the empty + // aggregate rather than a block the chain would refuse. + let candidate = sync_pool + .lock() + .expect("sync committee pool lock poisoned") + .sync_aggregate(slot - 1, head_root); + let sync_aggregate = candidate.map_or_else(empty_sync_aggregate, |candidate| { + verified_sync_aggregate(&state, candidate) + }); + let has_sync_aggregate = sync_aggregate != empty_sync_aggregate(); + let inputs = |attestations, sync_aggregate| BlockInputs { + sync_aggregate, randao_reveal, graffiti, attestations, @@ -406,14 +423,14 @@ async fn produce( execution_requests: execution_requests.clone(), }; let attestation_count = attestations.len(); - let block = match assemble_block(&state, inputs(attestations), &config) { + let block = match assemble_block(&state, inputs(attestations, sync_aggregate), &config) { Ok(block) => block, // `pack_attestations` checks every attestation's signature against this // state, so this should not happen; but a block without them still // earns the proposal, and one that fails to build earns nothing. - Err(err) if attestation_count > 0 => { - warn!(%slot, %err, "Block with attestations failed to build; retrying without"); - assemble_block(&state, inputs(Vec::new()), &config) + Err(err) if attestation_count > 0 || has_sync_aggregate => { + warn!(%slot, %err, "Block with operations failed to build; retrying without"); + assemble_block(&state, inputs(Vec::new(), empty_sync_aggregate()), &config) .map_err(|_| ApiError::Internal("the block failed to build"))? } Err(_) => return Err(ApiError::Internal("the block failed to build")), @@ -541,6 +558,7 @@ mod tests { .with_state(store) .layer(Extension(None::)) .layer(Extension(SharedAttestationPool::default())) + .layer(Extension(SharedSyncCommitteePool::default())) .layer(Extension(FeeRecipients::default())); let uri = format!( "/eth/v3/validator/blocks/{}?randao_reveal=0x{}", diff --git a/crates/net/rpc/src/beacon/sync_committee.rs b/crates/net/rpc/src/beacon/sync_committee.rs new file mode 100644 index 00000000..eca46f2b --- /dev/null +++ b/crates/net/rpc/src/beacon/sync_committee.rs @@ -0,0 +1,909 @@ +//! The sync committee endpoints of the Beacon API: +//! `POST /eth/v1/beacon/pool/sync_committees` (`submitPoolSyncCommitteeSignatures`), +//! `GET /eth/v1/validator/sync_committee_contribution` +//! (`produceSyncCommitteeContribution`), +//! `POST /eth/v1/validator/contribution_and_proofs` (`publishContributionAndProofs`) +//! and `POST /eth/v1/validator/sync_committee_subscriptions` +//! (`prepareSyncCommitteeSubnets`). +//! +//! All four are JSON only: beacon-APIs lists no SSZ body for any of them. +//! +//! Submissions are checked with the same rules this node applies to its peers' +//! gossip (`gossip::sync_committee`) before they are published, since a peer +//! that relays invalid messages is scored down. They are also pooled here, +//! because gossip never echoes a node its own messages and a block this node +//! proposes, or a contribution it serves, must include its validators' own. +//! None of it reaches the chain actor: sync committee votes have no +//! fork-choice effect. + +use std::collections::BTreeMap; + +use axum::{ + Extension, Router, + body::Bytes, + extract::{Query, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{get, post}, +}; +use ethlambda_blockchain::{SyncStatusController, metrics::SyncStatus}; +use ethlambda_network_api::RpcToP2PRef; +use ethlambda_state_transition::beacon::{ + gossip::{ + Outcome, + sync_committee::{ + SeenSyncContributions, check_contribution, check_submitted_message, + contribution_cheap_checks, + }, + }, + helpers::altair::compute_sync_committee_period, + sync_committee_pool::SharedSyncCommitteePool, +}; +use ethlambda_storage::Store; +use ethlambda_types::beacon::{ + constants::SYNC_COMMITTEE_SUBNET_COUNT, + containers::altair::{ + SYNC_SUBCOMMITTEE_SIZE, SignedContributionAndProof, SyncCommitteeMessage, + }, + preset, + primitives::{Epoch, Root, Slot}, + signing::compute_epoch_at_slot, +}; +use serde::{Deserialize, Deserializer, Serialize}; +use tracing::{debug, warn}; + +use crate::beacon::{ApiError, node::wall_slot, validator::head}; +use crate::shared::optimistic::block_is_optimistic; + +pub(crate) fn routes() -> Router { + Router::new() + .route( + "/eth/v1/beacon/pool/sync_committees", + post(post_pool_sync_committees), + ) + .route( + "/eth/v1/validator/sync_committee_contribution", + get(get_sync_committee_contribution), + ) + .route( + "/eth/v1/validator/contribution_and_proofs", + post(post_contribution_and_proofs), + ) + .route( + "/eth/v1/validator/sync_committee_subscriptions", + post(post_sync_committee_subscriptions), + ) +} + +/// One rejected item, in the Beacon API's `IndexedErrorMessage` shape: its +/// position in the submitted array, and why. +#[derive(Debug, Serialize)] +struct Failure { + index: usize, + message: String, +} + +/// `200` when nothing failed, else the Beacon API's `IndexedErrorMessage` +/// naming each failed item by position; the rest were still published. +fn batch_response(failures: Vec, message: &'static str) -> Response { + if failures.is_empty() { + return StatusCode::OK.into_response(); + } + let body = serde_json::json!({ "code": 400, "message": message, "failures": failures }); + let mut response = crate::json_response(body); + *response.status_mut() = StatusCode::BAD_REQUEST; + response +} + +/// An outcome's `"{outcome}: {reason}"` text, for a failure entry. +fn describe(outcome: &Outcome) -> String { + let (kind, reason) = outcome.labels(); + format!("{kind}: {reason}") +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as u64) + .unwrap_or(0) +} + +/// `POST /eth/v1/beacon/pool/sync_committees`. +/// +/// Every message is checked against the head state (the clock, the validator's +/// seats in the committee its slot names, its signature), pooled, then +/// published on each subnet its validator sits on. Not gated on syncing, like +/// the attestation submission: a node that is behind simply refuses what it +/// cannot check. The checks run on a blocking thread, one BLS verification +/// per message. +async fn post_pool_sync_committees( + State(store): State, + Extension(p2p): Extension, + Extension(pool): Extension, + body: Bytes, +) -> Response { + let Ok(messages) = serde_json::from_slice::>(&body) else { + return ApiError::BadRequest("invalid request body").into_response(); + }; + let batch = tokio::task::spawn_blocking(move || -> Result, ApiError> { + let (_head_root, state) = head(&store)?; + let config = store.config(); + let now_ms = now_ms(); + let mut failures = Vec::new(); + for (index, message) in messages.into_iter().enumerate() { + let (slot, validator) = (message.slot, message.validator_index); + let seats = match check_submitted_message(&state, &config, &message, now_ms) { + Ok(seats) => seats, + Err(outcome) => { + let reason = describe(&outcome); + warn!(%slot, validator, %reason, "Refused a submitted sync committee message"); + failures.push(Failure { + index, + message: reason, + }); + continue; + } + }; + pool.lock() + .expect("sync committee pool lock poisoned") + .insert_message(&message, &seats); + // Every distinct subnet the validator sits on, ascending. + let mut subnets: Vec = seats.iter().map(|&(subnet, _)| subnet).collect(); + subnets.sort_unstable(); + subnets.dedup(); + match p2p.publish_sync_committee_message(subnets, message) { + Ok(()) => debug!(%slot, validator, "Accepted sync committee message for gossip"), + Err(_) => failures.push(Failure { + index, + message: "the network actor is not running".to_owned(), + }), + } + } + Ok(failures) + }) + .await; + match batch { + Ok(Ok(failures)) => batch_response( + failures, + "some sync committee messages failed validation and were not published", + ), + Ok(Err(err)) => err.into_response(), + Err(_) => ApiError::Internal("checking the messages failed").into_response(), + } +} + +#[derive(Debug, Deserialize)] +struct ContributionQuery { + slot: Slot, + subcommittee_index: u64, + beacon_block_root: Root, +} + +/// `GET /eth/v1/validator/sync_committee_contribution`: the best contribution +/// this node can assemble from what it has pooled for `(slot, +/// beacon_block_root)` on one subcommittee, a 404 when it has nothing. +/// +/// `503` while syncing or when the voted block is optimistic: an aggregator +/// must not sign over a contribution this node cannot vouch for (optimistic +/// sync's "Participating in Sync Committees" rule), and a validator client +/// reads the status as "try the next node". +async fn get_sync_committee_contribution( + State(store): State, + Extension(pool): Extension, + Extension(sync_status): Extension, + Query(query): Query, +) -> Response { + if query.subcommittee_index >= SYNC_COMMITTEE_SUBNET_COUNT as u64 { + return ApiError::BadRequest("subcommittee_index is out of range").into_response(); + } + if sync_status.get() == SyncStatus::Syncing { + return ApiError::ServiceUnavailable("node is syncing").into_response(); + } + if block_is_optimistic(&store, query.beacon_block_root) { + return ApiError::ServiceUnavailable("the block is unknown or optimistic").into_response(); + } + let contribution = pool + .lock() + .expect("sync committee pool lock poisoned") + .contribution( + query.slot, + query.beacon_block_root, + query.subcommittee_index, + ); + match contribution { + Some(contribution) => crate::json_response(serde_json::json!({ "data": contribution })), + None => ApiError::NotFound("no sync committee messages to aggregate").into_response(), + } +} + +/// `POST /eth/v1/validator/contribution_and_proofs`: signed contributions, +/// validated with the `sync_committee_contribution_and_proof` gossip rules +/// (`gossip::sync_committee`), pooled for block production, then gossiped. +/// +/// Each is checked against a fresh seen cache rather than P2P's, which holds +/// what peers sent: a node never receives its own messages, so it would say +/// nothing about these, and the validator client already signs one +/// contribution per aggregator and subnet. +async fn post_contribution_and_proofs( + State(store): State, + Extension(p2p): Extension, + Extension(pool): Extension, + body: Bytes, +) -> Response { + let Ok(contributions) = serde_json::from_slice::>(&body) else { + return ApiError::BadRequest("invalid request body").into_response(); + }; + let batch = tokio::task::spawn_blocking(move || -> Result, ApiError> { + let (_head_root, state) = head(&store)?; + let config = store.config(); + let now_ms = now_ms(); + let capacity = std::num::NonZeroUsize::MIN; + let mut failures = Vec::new(); + for (index, signed) in contributions.into_iter().enumerate() { + let slot = signed.message.contribution.slot; + let aggregator = signed.message.aggregator_index; + let seen = SeenSyncContributions::new(capacity, capacity); + let outcome = contribution_cheap_checks(&seen, &store, &signed, now_ms).map_or_else( + |outcome| outcome, + |()| check_contribution(&state, &config, &signed), + ); + if outcome != Outcome::Accept { + let reason = describe(&outcome); + warn!(%slot, aggregator, %reason, "Refused a submitted sync contribution"); + failures.push(Failure { + index, + message: reason, + }); + continue; + } + pool.lock() + .expect("sync committee pool lock poisoned") + .insert_contribution(signed.message.contribution.clone()); + match p2p.publish_sync_committee_contribution(signed) { + Ok(()) => debug!(%slot, aggregator, "Accepted sync contribution for gossip"), + Err(_) => failures.push(Failure { + index, + message: "the network actor is not running".to_owned(), + }), + } + } + Ok(failures) + }) + .await; + match batch { + Ok(Ok(failures)) => batch_response( + failures, + "some sync contributions failed validation and were not published", + ), + Ok(Err(err)) => err.into_response(), + Err(_) => ApiError::Internal("checking the contributions failed").into_response(), + } +} + +/// An integer the validator client may quote or not. beacon-APIs quotes +/// every integer, and clients differ in how strictly they follow it. +#[derive(Debug, Clone, Copy)] +struct Flexible(u64); + +impl<'de> Deserialize<'de> for Flexible { + fn deserialize>(deserializer: D) -> Result { + struct Visitor; + impl serde::de::Visitor<'_> for Visitor { + type Value = Flexible; + fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("an unsigned integer, quoted or not") + } + fn visit_u64(self, value: u64) -> Result { + Ok(Flexible(value)) + } + fn visit_str(self, value: &str) -> Result { + value.trim().parse().map(Flexible).map_err(E::custom) + } + } + deserializer.deserialize_any(Visitor) + } +} + +/// One entry of `prepareSyncCommitteeSubnets`' body. `validator_index` is +/// part of the request but this node has no use for it: the positions say +/// which subnets to join. +#[derive(Debug, Deserialize)] +struct SyncCommitteeSubscription { + sync_committee_indices: Vec, + until_epoch: Flexible, +} + +/// `POST /eth/v1/validator/sync_committee_subscriptions`: join the subnets a +/// validator's committee positions sit on, until the epoch given. +/// +/// The subnet of a position is `index / SYNC_SUBCOMMITTEE_SIZE`. Entries are +/// grouped by subnet keeping the latest `until_epoch`, which is clamped to the +/// end of the next sync committee period: a longer request would keep the node +/// validating a subnet's gossip well past anything a duty lookahead can need. +/// A position outside the committee is a `400` and nothing is joined. +async fn post_sync_committee_subscriptions( + State(store): State, + Extension(p2p): Extension, + body: Bytes, +) -> Response { + let Ok(subscriptions) = serde_json::from_slice::>(&body) else { + return ApiError::BadRequest("invalid request body").into_response(); + }; + let wall_epoch: Epoch = compute_epoch_at_slot(wall_slot(&store)); + let clamp = (compute_sync_committee_period(wall_epoch) + 2) + .saturating_mul(preset::EPOCHS_PER_SYNC_COMMITTEE_PERIOD); + + let mut until_by_subnet: BTreeMap = BTreeMap::new(); + for subscription in subscriptions { + let until = subscription.until_epoch.0.min(clamp); + for Flexible(position) in subscription.sync_committee_indices { + if position >= preset::SYNC_COMMITTEE_SIZE as u64 { + return ApiError::BadRequest("sync committee index is out of range") + .into_response(); + } + let subnet = position / SYNC_SUBCOMMITTEE_SIZE as u64; + let held = until_by_subnet.entry(subnet).or_insert(until); + *held = (*held).max(until); + } + } + if until_by_subnet.is_empty() { + return StatusCode::OK.into_response(); + } + let pairs: Vec<(u64, u64)> = until_by_subnet.into_iter().collect(); + match p2p.subscribe_sync_committee_subnets(pairs) { + Ok(()) => StatusCode::OK.into_response(), + Err(_) => ApiError::Internal("the network actor is not running").into_response(), + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::test_utils::RecordingNetwork; + use axum::{body::Body, http::Request}; + use ethlambda_state_transition::beacon::{ + bls, + helpers::{ + sync_committee::{ + contribution_and_proof_signing_root, is_sync_committee_aggregator, + sync_committee_for_slot, sync_committee_message_signing_root, sync_committee_seats, + sync_selection_proof_signing_root, + }, + test_state::{secret_key_for, sign_for, with_signing_validators_at}, + }, + }; + use ethlambda_types::beacon::{ + config::Config, + containers::{ + BeaconState, + altair::{ContributionAndProof, SyncCommittee, SyncCommitteeContribution}, + }, + fork::ForkName, + primitives::{BlsPubkey, ValidatorIndex}, + signing::compute_start_slot_at_epoch, + }; + use http_body_util::BodyExt as _; + use tower::ServiceExt as _; + + /// The registry is larger than the 64 validators the committees are drawn + /// from, so the rest are not members. + const VALIDATORS: usize = 80; + const MEMBERS: usize = 64; + + struct Fixture { + store: Store, + state: BeaconState, + head_root: Root, + network: Arc, + pool: SharedSyncCommitteePool, + sync_status: SyncStatusController, + } + + /// A fulu head in the current wall-clock epoch with real sync committees: + /// position `p` of the current committee is validator `p % MEMBERS`, of the + /// next one validator `(p + 1) % MEMBERS`. Each member therefore holds + /// several seats, spread over every subnet. + fn fixture() -> Fixture { + let mut state = with_signing_validators_at(ForkName::Fulu, VALIDATORS); + let committee = |shift: usize| SyncCommittee { + pubkeys: (0..preset::SYNC_COMMITTEE_SIZE) + .map(|position| { + let index = (position + shift) % MEMBERS; + BlsPubkey(secret_key_for(index).sk_to_pk().to_bytes()) + }) + .collect::>() + .try_into() + .expect("built at the committee's exact length"), + aggregate_pubkey: Default::default(), + }; + let (current, next) = state.sync_committees_mut().expect("fulu has committees"); + *current = committee(0); + *next = committee(1); + state.apply_pending_mutations(); + + let config = Config::mainnet(); + let (probe, _) = crate::test_utils::beacon_store_with_config(state.clone(), config.clone()); + let wall_epoch = compute_epoch_at_slot(wall_slot(&probe)); + let BeaconState::Fulu(fulu) = &mut state else { + unreachable!("built as fulu") + }; + fulu.slot = compute_start_slot_at_epoch(wall_epoch); + let (store, head_root) = crate::test_utils::beacon_store_with_config(state.clone(), config); + Fixture { + store, + state, + head_root, + network: Arc::new(RecordingNetwork::default()), + pool: SharedSyncCommitteePool::default(), + sync_status: SyncStatusController::new(SyncStatus::Synced), + } + } + + /// The wall slot, once at least a second of it is left, so a request does + /// not straddle a slot boundary. + async fn current_slot(store: &Store) -> Slot { + let config = store.config(); + let into_slot_ms = + now_ms().saturating_sub(config.genesis_time_ms()) % config.slot_duration_ms; + if into_slot_ms + 1_000 > config.slot_duration_ms { + tokio::time::sleep(std::time::Duration::from_millis(1_100)).await; + } + wall_slot(store) + } + + fn app(fixture: &Fixture) -> Router { + let network: RpcToP2PRef = fixture.network.clone(); + routes() + .with_state(fixture.store.clone()) + .layer(Extension(network)) + .layer(Extension(fixture.pool.clone())) + .layer(Extension(fixture.sync_status.clone())) + } + + async fn send(app: Router, request: Request) -> (StatusCode, serde_json::Value) { + let response = app.oneshot(request).await.unwrap(); + let status = response.status(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json = if body.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&body).unwrap() + }; + (status, json) + } + + async fn post_json( + fixture: &Fixture, + uri: &str, + body: Vec, + ) -> (StatusCode, serde_json::Value) { + let request = Request::post(uri) + .header("content-type", "application/json") + .body(Body::from(body)) + .unwrap(); + send(app(fixture), request).await + } + + async fn submit( + fixture: &Fixture, + messages: &[SyncCommitteeMessage], + ) -> (StatusCode, serde_json::Value) { + let body = serde_json::to_vec(messages).unwrap(); + post_json(fixture, "/eth/v1/beacon/pool/sync_committees", body).await + } + + /// `validator`'s correctly signed message for `slot` over the head. + fn message(fixture: &Fixture, validator: ValidatorIndex, slot: Slot) -> SyncCommitteeMessage { + let signing_root = sync_committee_message_signing_root( + &fixture.store.config(), + fixture.state.genesis_validators_root(), + slot, + fixture.head_root, + ); + SyncCommitteeMessage { + slot, + beacon_block_root: fixture.head_root, + validator_index: validator, + signature: sign_for(validator as usize, signing_root), + } + } + + /// Every seat `validator` holds in the committee that signs at `slot`. + fn seats_of(fixture: &Fixture, validator: ValidatorIndex, slot: Slot) -> Vec<(u64, usize)> { + let pubkey = fixture.state.validator(validator).unwrap().pubkey; + let committee = sync_committee_for_slot(&fixture.state, slot).unwrap(); + sync_committee_seats(committee, &pubkey) + } + + fn subnets_of(seats: &[(u64, usize)]) -> Vec { + let mut subnets: Vec = seats.iter().map(|&(subnet, _)| subnet).collect(); + subnets.dedup(); + subnets + } + + #[tokio::test] + async fn a_valid_message_is_pooled_and_published_on_every_subnet_of_its_seats() { + let fixture = fixture(); + let slot = current_slot(&fixture.store).await; + let message = message(&fixture, 3, slot); + let (status, _) = submit(&fixture, std::slice::from_ref(&message)).await; + assert_eq!(status, StatusCode::OK); + + let seats = seats_of(&fixture, 3, slot); + let published = fixture.network.sync_messages.lock().unwrap(); + assert_eq!(published.len(), 1); + assert_eq!(published[0].0, subnets_of(&seats)); + assert!( + published[0].0.len() > 1, + "the fixture spreads seats over subnets" + ); + assert_eq!(published[0].1, message); + + let pool = fixture.pool.lock().unwrap(); + for &(subnet, position) in &seats { + let held = pool + .contribution(slot, fixture.head_root, subnet) + .expect("the message is pooled on every subnet it sits on"); + assert!(held.aggregation_bits.get(position).unwrap()); + } + } + + #[tokio::test] + async fn refused_messages_are_reported_by_position_and_not_published() { + let fixture = fixture(); + let slot = current_slot(&fixture.store).await; + let mut forged = message(&fixture, 3, slot); + forged.signature = message(&fixture, 4, slot).signature; + let stale = message(&fixture, 5, slot - 3); + let outsider = message(&fixture, MEMBERS as u64 + 1, slot); + + for (refused, expected) in [ + (forged, "bad_signature"), + (stale, "not_current_slot"), + (outsider, "not_in_committee"), + ] { + let (status, json) = submit(&fixture, &[refused]).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(json["code"], 400); + assert_eq!(json["failures"][0]["index"], 0); + let text = json["failures"][0]["message"].as_str().unwrap(); + assert!(text.contains(expected), "{text} should name {expected}"); + } + assert!(fixture.network.sync_messages.lock().unwrap().is_empty()); + assert!( + fixture + .pool + .lock() + .unwrap() + .contribution(slot, fixture.head_root, 0) + .is_none() + ); + } + + #[tokio::test] + async fn a_malformed_body_is_a_400_without_failures() { + let fixture = fixture(); + let (status, json) = post_json( + &fixture, + "/eth/v1/beacon/pool/sync_committees", + b"{\"not\": \"a list\"}".to_vec(), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(json["code"], 400); + assert!(json.get("failures").is_none()); + } + + #[tokio::test] + async fn a_mixed_batch_publishes_only_the_valid_entries() { + let fixture = fixture(); + let slot = current_slot(&fixture.store).await; + let mut bad = message(&fixture, 1, slot); + bad.signature = message(&fixture, 2, slot).signature; + let good = message(&fixture, 6, slot); + let (status, json) = submit(&fixture, &[bad, good.clone()]).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + let failures = json["failures"].as_array().unwrap(); + assert_eq!(failures.len(), 1); + assert_eq!(failures[0]["index"], 0); + let published = fixture.network.sync_messages.lock().unwrap(); + assert_eq!(published.len(), 1); + assert_eq!(published[0].1, good); + } + + async fn get_contribution( + fixture: &Fixture, + slot: Slot, + subcommittee: u64, + root: Root, + ) -> (StatusCode, serde_json::Value) { + let uri = format!( + "/eth/v1/validator/sync_committee_contribution?slot={slot}&subcommittee_index={subcommittee}&beacon_block_root={root}" + ); + send(app(fixture), Request::get(uri).body(Body::empty()).unwrap()).await + } + + #[tokio::test] + async fn a_contribution_is_built_from_pooled_messages_and_verifies() { + let fixture = fixture(); + let slot = current_slot(&fixture.store).await; + let validators = [3u64, 9, 20]; + let messages: Vec<_> = validators + .iter() + .map(|&validator| message(&fixture, validator, slot)) + .collect(); + let (status, _) = submit(&fixture, &messages).await; + assert_eq!(status, StatusCode::OK); + + let subnet = 1; + let (status, json) = get_contribution(&fixture, slot, subnet, fixture.head_root).await; + assert_eq!(status, StatusCode::OK); + assert!(json.get("version").is_none()); + let contribution: SyncCommitteeContribution = + serde_json::from_value(json["data"].clone()).unwrap(); + assert_eq!(contribution.slot, slot); + assert_eq!(contribution.subcommittee_index, subnet); + assert_eq!(contribution.beacon_block_root, fixture.head_root); + + let mut expected: Vec = validators + .iter() + .flat_map(|&validator| seats_of(&fixture, validator, slot)) + .filter(|&(seat_subnet, _)| seat_subnet == subnet) + .map(|(_, position)| position) + .collect(); + expected.sort_unstable(); + let set: Vec = (0..SYNC_SUBCOMMITTEE_SIZE) + .filter(|&position| contribution.aggregation_bits.get(position).unwrap()) + .collect(); + assert_eq!(set, expected); + + let committee = sync_committee_for_slot(&fixture.state, slot).unwrap(); + let participants: Vec<_> = set + .iter() + .map(|&position| committee.pubkeys[subnet as usize * SYNC_SUBCOMMITTEE_SIZE + position]) + .collect(); + let signing_root = sync_committee_message_signing_root( + &fixture.store.config(), + fixture.state.genesis_validators_root(), + slot, + fixture.head_root, + ); + assert!(bls::eth_fast_aggregate_verify( + &participants, + signing_root, + &contribution.signature + )); + } + + #[tokio::test] + async fn a_contribution_with_nothing_pooled_is_a_404() { + let fixture = fixture(); + let slot = current_slot(&fixture.store).await; + let (status, json) = get_contribution(&fixture, slot, 0, fixture.head_root).await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(json["code"], 404); + } + + #[tokio::test] + async fn a_contribution_for_subcommittee_four_is_a_400() { + let fixture = fixture(); + let slot = current_slot(&fixture.store).await; + let (status, _) = get_contribution(&fixture, slot, 4, fixture.head_root).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn a_contribution_while_syncing_or_for_an_unknown_block_is_a_503() { + let mut fixture = fixture(); + let slot = current_slot(&fixture.store).await; + let (status, _) = get_contribution(&fixture, slot, 0, Root::repeat_byte(9)).await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + + fixture.sync_status = SyncStatusController::new(SyncStatus::Syncing); + let (status, _) = get_contribution(&fixture, slot, 0, fixture.head_root).await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + } + + /// A contribution from `aggregator` on `subnet`, covering the aggregator's + /// own seats there, with every signature a real one. + fn contribution_from( + fixture: &Fixture, + aggregator: ValidatorIndex, + subnet: u64, + slot: Slot, + ) -> SignedContributionAndProof { + let config = fixture.store.config(); + let gvr = fixture.state.genesis_validators_root(); + let seats: Vec<_> = seats_of(fixture, aggregator, slot) + .into_iter() + .filter(|&(seat_subnet, _)| seat_subnet == subnet) + .collect(); + let mut contribution = SyncCommitteeContribution { + slot, + beacon_block_root: fixture.head_root, + subcommittee_index: subnet, + aggregation_bits: Default::default(), + signature: Default::default(), + }; + let message_root = + sync_committee_message_signing_root(&config, gvr, slot, fixture.head_root); + let signatures: Vec<_> = seats + .iter() + .map(|&(_, position)| { + contribution.aggregation_bits.set(position, true).unwrap(); + sign_for(aggregator as usize, message_root) + }) + .collect(); + contribution.signature = bls::aggregate(&signatures).unwrap(); + let selection_proof = sign_for( + aggregator as usize, + sync_selection_proof_signing_root(&config, gvr, slot, subnet), + ); + let message = ContributionAndProof { + aggregator_index: aggregator, + contribution, + selection_proof, + }; + let signature = sign_for( + aggregator as usize, + contribution_and_proof_signing_root(&config, gvr, &message), + ); + SignedContributionAndProof { message, signature } + } + + /// A `(validator, subnet)` pair whose selection proof does (or does not) + /// select it, found by search: selection is a hash of the signature. + fn pair_where_selected(fixture: &Fixture, slot: Slot, selected: bool) -> (u64, u64) { + let config = fixture.store.config(); + let gvr = fixture.state.genesis_validators_root(); + (0..MEMBERS as u64) + .flat_map(|validator| { + (0..SYNC_COMMITTEE_SUBNET_COUNT as u64).map(move |subnet| (validator, subnet)) + }) + .find(|&(validator, subnet)| { + let proof = sign_for( + validator as usize, + sync_selection_proof_signing_root(&config, gvr, slot, subnet), + ); + is_sync_committee_aggregator(&proof) == selected + && seats_of(fixture, validator, slot) + .iter() + .any(|&(seat_subnet, _)| seat_subnet == subnet) + }) + .expect("an eighth of the pairs are selected and most are not") + } + + async fn post_contributions( + fixture: &Fixture, + contributions: &[SignedContributionAndProof], + ) -> (StatusCode, serde_json::Value) { + let body = serde_json::to_vec(contributions).unwrap(); + post_json(fixture, "/eth/v1/validator/contribution_and_proofs", body).await + } + + #[tokio::test] + async fn a_valid_contribution_is_pooled_and_published() { + let fixture = fixture(); + let slot = current_slot(&fixture.store).await; + let (aggregator, subnet) = pair_where_selected(&fixture, slot, true); + let signed = contribution_from(&fixture, aggregator, subnet, slot); + let (status, json) = post_contributions(&fixture, std::slice::from_ref(&signed)).await; + assert_eq!(status, StatusCode::OK, "{json}"); + + assert_eq!( + *fixture.network.sync_contributions.lock().unwrap(), + vec![signed.clone()] + ); + let pooled = fixture + .pool + .lock() + .unwrap() + .contribution(slot, fixture.head_root, subnet) + .unwrap(); + assert_eq!( + pooled.aggregation_bits, + signed.message.contribution.aggregation_bits + ); + } + + #[tokio::test] + async fn an_unselected_aggregator_and_a_forged_signature_are_refused() { + let fixture = fixture(); + let slot = current_slot(&fixture.store).await; + + let (aggregator, subnet) = pair_where_selected(&fixture, slot, false); + let unselected = contribution_from(&fixture, aggregator, subnet, slot); + let (status, json) = post_contributions(&fixture, &[unselected]).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(json["failures"][0]["index"], 0); + assert!( + json["failures"][0]["message"] + .as_str() + .unwrap() + .contains("not_aggregator") + ); + + let (aggregator, subnet) = pair_where_selected(&fixture, slot, true); + let mut forged = contribution_from(&fixture, aggregator, subnet, slot); + forged.message.contribution.signature = sign_for(0, Root::repeat_byte(1)); + // The envelope covers the contribution, so re-sign it: only the + // aggregate signature is wrong. + forged.signature = sign_for( + aggregator as usize, + contribution_and_proof_signing_root( + &fixture.store.config(), + fixture.state.genesis_validators_root(), + &forged.message, + ), + ); + let (status, json) = post_contributions(&fixture, &[forged]).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert!( + json["failures"][0]["message"] + .as_str() + .unwrap() + .contains("aggregate_signature") + ); + assert!( + fixture + .network + .sync_contributions + .lock() + .unwrap() + .is_empty() + ); + } + + async fn subscribe( + fixture: &Fixture, + body: serde_json::Value, + ) -> (StatusCode, serde_json::Value) { + post_json( + fixture, + "/eth/v1/validator/sync_committee_subscriptions", + serde_json::to_vec(&body).unwrap(), + ) + .await + } + + #[tokio::test] + async fn subscriptions_group_by_subnet_keep_the_latest_epoch_and_clamp_it() { + let fixture = fixture(); + let size = SYNC_SUBCOMMITTEE_SIZE; + let wall_epoch = compute_epoch_at_slot(wall_slot(&fixture.store)); + let clamp = (compute_sync_committee_period(wall_epoch) + 2) + * preset::EPOCHS_PER_SYNC_COMMITTEE_PERIOD; + let body = serde_json::json!([ + { "validator_index": "1", "sync_committee_indices": ["0", (size + 5).to_string()], "until_epoch": "100" }, + { "validator_index": "2", "sync_committee_indices": ["3"], "until_epoch": "250" }, + { "validator_index": "3", "sync_committee_indices": [(3 * size).to_string()], "until_epoch": (clamp + 1000).to_string() }, + ]); + let (status, _) = subscribe(&fixture, body).await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + *fixture.network.sync_subscriptions.lock().unwrap(), + vec![(0, 250.min(clamp)), (1, 100.min(clamp)), (3, clamp)] + ); + } + + #[tokio::test] + async fn a_subscription_past_the_committee_is_a_400_and_joins_nothing() { + let fixture = fixture(); + let past = preset::SYNC_COMMITTEE_SIZE; + let body = serde_json::json!([ + { "validator_index": "1", "sync_committee_indices": ["0", past.to_string()], "until_epoch": "100" }, + ]); + let (status, _) = subscribe(&fixture, body).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert!( + fixture + .network + .sync_subscriptions + .lock() + .unwrap() + .is_empty() + ); + } +} diff --git a/crates/net/rpc/src/beacon/validator_client_tests.rs b/crates/net/rpc/src/beacon/validator_client_tests.rs index 93196049..e30034a3 100644 --- a/crates/net/rpc/src/beacon/validator_client_tests.rs +++ b/crates/net/rpc/src/beacon/validator_client_tests.rs @@ -12,13 +12,15 @@ use std::sync::Arc; use axum::Extension; use ethlambda_blockchain::{SyncStatusController, metrics::SyncStatus}; use ethlambda_network_api::RpcToP2PRef; -use ethlambda_state_transition::beacon::attestation_pool::SharedAttestationPool; use ethlambda_state_transition::beacon::helpers::{ accessors::get_domain, fulu::initialize_proposer_lookahead, test_state::{sign_for, with_signing_validators_at}, }; use ethlambda_state_transition::beacon::payload_attestation_pool::SharedPayloadAttestationPool; +use ethlambda_state_transition::beacon::{ + attestation_pool::SharedAttestationPool, sync_committee_pool::SharedSyncCommitteePool, +}; use ethlambda_types::{ beacon::{ constants::DOMAIN_BEACON_ATTESTER, @@ -108,6 +110,7 @@ async fn spawn_server( .layer(Extension(SyncStatusController::new(SyncStatus::Synced))) .layer(Extension(p2p)) .layer(Extension(SharedAttestationPool::default())) + .layer(Extension(SharedSyncCommitteePool::default())) .layer(Extension(payload_pool.clone())) .layer(Extension(crate::CustodyColumns(Vec::new()))) .layer(Extension(crate::beacon::validator::FeeRecipients::default())) diff --git a/docs/rpc.md b/docs/rpc.md index f5e3a5d9..c25ec9d0 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -252,6 +252,10 @@ surface rather than sitting beside it; a `/lean/v0` path on a beacon node is a | `POST` | `/eth/v1/validator/beacon_committee_subscriptions` | *(status only)* | Aggregators' entries join their committee's subnet | | `GET` | `/eth/v2/validator/aggregate_attestation` | JSON | The pooled votes for a data root and committee, aggregated | | `POST` | `/eth/v2/validator/aggregate_and_proofs` | *(status only)* | Validate and gossip `SignedAggregateAndProof`s | +| `POST` | `/eth/v1/beacon/pool/sync_committees` | *(status only)* | Validate, pool and gossip `SyncCommitteeMessage`s | +| `GET` | `/eth/v1/validator/sync_committee_contribution` | JSON | The pooled messages for `(slot, subcommittee_index, beacon_block_root)`, aggregated | +| `POST` | `/eth/v1/validator/contribution_and_proofs` | *(status only)* | Validate, pool and gossip `SignedContributionAndProof`s | +| `POST` | `/eth/v1/validator/sync_committee_subscriptions` | *(status only)* | Join sync committee subnets until an epoch | | `GET` | `/eth/v3/validator/blocks/{slot}` | SSZ or JSON | An unsigned fulu block built on the head (`produceBlockV3`) | | `POST` | `/eth/v4/validator/blocks/{slot}` | SSZ or JSON | An unsigned self-built gloas block, with its envelope and blobs when asked (`produceBlockV4`) | | `GET` | `/eth/v1/validator/execution_payload_envelopes/{slot}/{beacon_block_root}` | SSZ or JSON | The unsigned envelope `produceBlockV4` built (gloas) | @@ -280,9 +284,8 @@ the chain actor writes, so no request waits on the actor. state; see `docs/spec_deviations.md`). A validator is matched by pubkey and gets every seat it holds, since the committee is drawn with replacement; one with no seat is left out. An unknown index is a `400`, and the endpoint is a - `503` while the node is syncing. This node serves no sync committee message - or contribution endpoint yet, so a validator client that gets duties here - cannot publish what they ask for. + `503` while the node is syncing. What a duty asks for is served by the four + sync committee endpoints in [Sync committee](#sync-committee) below. - **`attestation_data`** follows phase0's `validator.md`: the head block, the epoch's boundary block as target, and as source the current justified checkpoint of the head state advanced to the slot's epoch (through fork @@ -348,8 +351,9 @@ the chain actor writes, so no request waits on the actor. is packed only if its target root is the advanced state's own block root for that epoch and its aggregate signature verifies against that state, so an aggregate made on another branch cannot fail the whole block. The body also - votes the state's own `eth1_data`, and carries an empty sync aggregate and no - slashings, exits or credential changes. The state root comes from running + votes the state's own `eth1_data`, and carries the sync aggregate described + under [Sync committee](#sync-committee) (empty when nothing verifiable is + pooled) and no slashings, exits or credential changes. The state root comes from running the block through `process_block`. The answer is fulu `BlockContents`, with `Eth-Execution-Payload-Blinded: false`; there is no builder flow. It is a **`503`** without a configured execution client, or when the payload carries @@ -438,6 +442,58 @@ envelope and all `NUMBER_OF_COLUMNS` gloas data column sidecars are handed to P2P, which gossips them together. A node that does not subscribe to a column's subnet publishes through gossipsub fanout. +### Sync committee + +All four endpoints are JSON only (beacon-APIs lists no SSZ body for them), and +read integers quoted or bare. All share one `SharedSyncCommitteePool`, which +P2P fills from accepted gossip as well. None of it reaches the chain actor: +sync committee votes have no fork-choice effect. + +- **`POST /eth/v1/beacon/pool/sync_committees`** takes a JSON array of + `SyncCommitteeMessage`s and needs no `Eth-Consensus-Version`. Each is + checked against the head state with the rules `sync_committee_{subnet_id}` + gossip applies (the current slot; the validator holds a seat in the + committee its slot names, which is the next committee at a period's last + slot; the signature), then pooled once per seat and published on every + distinct subnet its seats sit on. One BLS verification per message, on a + blocking thread. Not gated on syncing, like `pool/attestations`. A refused + message comes back in an `IndexedErrorMessage` (`400`) as + `{outcome}: {reason}` with its position, and the rest still go out. A body + that is not a list of messages is a `400` with no `failures`; a dead network + actor is a per-item failure. +- **`GET /eth/v1/validator/sync_committee_contribution?slot=&subcommittee_index=&beacon_block_root=`** + answers `{data: SyncCommitteeContribution}` (no `version`): the best pooled + contribution for the key, extended by every pooled direct message at a + position it does not cover. `404` when the pool holds nothing for it, `400` + when `subcommittee_index` is not below `SYNC_COMMITTEE_SUBNET_COUNT`, and + `503` while the node is syncing or when the block is optimistic or unknown + (an aggregator must not sign over a root this node cannot vouch for). +- **`POST /eth/v1/validator/contribution_and_proofs`** takes a JSON array of + `SignedContributionAndProof`s. Each passes the checks + `sync_committee_contribution_and_proof` gossip applies (current slot, + subcommittee in range, a participant, the selection proof selects the + aggregator, aggregator is in the subcommittee, and the selection proof, + envelope and aggregate signatures) against a fresh seen cache, since P2P's + holds what peers sent. A valid one is pooled and gossiped; the failure shape + is the same as above. +- **`POST /eth/v1/validator/sync_committee_subscriptions`** takes + `[{validator_index, sync_committee_indices, until_epoch}]`. The subnet of a + position is `index / SYNC_SUBCOMMITTEE_SIZE`; entries are grouped by subnet + keeping the latest `until_epoch` (exclusive), clamped to the end of the next + sync committee period, and handed to P2P, which joins the subnets + immediately. A position at or above `SYNC_COMMITTEE_SIZE` is a `400` and + nothing is joined. A node forgets these on restart, so a client repeats them + every epoch. `sync_committee_selections` (distributed validators) is not + served; beacon-APIs lets a node leave it out. + +**Block production.** The block at slot `N` packs only what is pooled for +`(N - 1, parent_root)`: per subcommittee the best contribution, extended by +pooled direct messages at uncovered positions. The result is verified exactly +as `process_sync_aggregate` will check it, against the state the block is built +on; if it fails, or nothing is pooled, the block carries the empty aggregate. +Fulu (`blocks/{slot}`) and gloas (`produceBlockV4`) do the same, and the +"retry without operations" fallback of each also drops the sync aggregate. + ### Payload timeliness committee - **`POST /eth/v1/validator/duties/ptc/{epoch}`** takes a JSON array of quoted From 3d00794b53270ede9dcdf2cd52096a16b47c6120 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:08:38 -0300 Subject: [PATCH 09/11] feat(p2p): validate, relay and pool sync committee gossip Sync committee messages and contributions had no consumer, so the node relayed none of them and a validator client could not take part in a sync committee through it. The contribution topic is always validated; the four message subnets are joined only on a validator client's request, since validating all of them permanently would cost every follower up to a committee's worth of BLS verifications per slot. Joined subnets live on the wire so MetaData `syncnets` advertises exactly them, follow every held digest across fork boundaries, and are left by the 12 s sweep once their until_epoch passes. Stateful checks draw from their own permit pool, since their burst coincides with the attestations'. Accepted gossip is pooled for block production and never reaches the chain actor. --- bin/ethlambda/src/beacon.rs | 14 +- crates/net/p2p/src/beacon/decode.rs | 66 +++ crates/net/p2p/src/beacon/handler.rs | 48 ++- crates/net/p2p/src/beacon/mod.rs | 19 +- crates/net/p2p/src/beacon/sync_committee.rs | 442 +++++++++++++++++++- crates/net/p2p/src/beacon/topics.rs | 73 +++- crates/net/p2p/src/beacon/transition.rs | 43 ++ crates/net/p2p/src/beacon/verdict.rs | 308 +++++++++++++- crates/net/p2p/src/gossipsub/handler.rs | 145 ++++++- crates/net/p2p/src/gossipsub/mod.rs | 2 +- crates/net/p2p/src/lib.rs | 63 ++- crates/net/p2p/src/req_resp/handlers.rs | 12 + docs/beacon_wire.md | 40 +- 13 files changed, 1218 insertions(+), 57 deletions(-) diff --git a/bin/ethlambda/src/beacon.rs b/bin/ethlambda/src/beacon.rs index 4af0cb8e..7ef96a76 100644 --- a/bin/ethlambda/src/beacon.rs +++ b/bin/ethlambda/src/beacon.rs @@ -258,14 +258,12 @@ pub fn wire_params( "Backboning attestation subnets" ); - // Say plainly what is still advertised without being backed by behavior, - // so a running node never implies more than it does. Storing and serving - // the custodied columns logged above is no longer in that gap, and neither - // is the attestation subnet backbone; sync committee subnet subscription, - // and publishing, still are. - warn!( - "Advertising cgc={custody_group_count} while subscribing to no sync committee \ - subnet, and publishing nothing" + // Say plainly what the node does about sync committees, so a running node + // never implies more than it does: its subnets are joined only when a + // validator client asks, and `syncnets` advertises exactly those. + info!( + "Advertising cgc={custody_group_count}; sync committee subnets are joined only on a \ + validator client's request" ); Ok(BeaconWireParams { diff --git a/crates/net/p2p/src/beacon/decode.rs b/crates/net/p2p/src/beacon/decode.rs index b4fac1c0..5e7f2e70 100644 --- a/crates/net/p2p/src/beacon/decode.rs +++ b/crates/net/p2p/src/beacon/decode.rs @@ -237,6 +237,22 @@ pub fn decode_payload_attestation_message( gloas::PayloadAttestationMessage::from_ssz_bytes(bytes).map_err(|_| DecodeError::Ssz) } +/// Decode a `sync_committee_{subnet_id}` payload. Altair on and the same +/// container at every fork, so no fork lookup is needed. +pub fn decode_sync_committee_message( + bytes: &[u8], +) -> Result { + altair::SyncCommitteeMessage::from_ssz_bytes(bytes).map_err(|_| DecodeError::Ssz) +} + +/// Decode a `sync_committee_contribution_and_proof` payload. Fork-invariant, +/// like [`decode_sync_committee_message`]. +pub fn decode_sync_committee_contribution( + bytes: &[u8], +) -> Result { + altair::SignedContributionAndProof::from_ssz_bytes(bytes).map_err(|_| DecodeError::Ssz) +} + /// Decode a `beacon_aggregate_and_proof` payload, at the fork its slot names. pub fn decode_aggregate_and_proof( config: &Config, @@ -662,6 +678,56 @@ mod tests { assert!(decode_execution_payload_envelope(&[0xff; 3]).is_err()); } + #[test] + fn a_sync_committee_message_round_trips() { + let message = altair::SyncCommitteeMessage { + slot: slot_of(10), + beacon_block_root: Root::repeat_byte(4), + validator_index: 321, + signature: Default::default(), + }; + let bytes = message.to_ssz(); + assert_eq!(decode_sync_committee_message(&bytes), Ok(message)); + for length in 0..bytes.len() { + assert_eq!( + decode_sync_committee_message(&bytes[..length]), + Err(DecodeError::Ssz) + ); + } + assert_eq!( + decode_sync_committee_message(&[0xff; 3]), + Err(DecodeError::Ssz) + ); + } + + #[test] + fn a_sync_committee_contribution_round_trips() { + let signed = altair::SignedContributionAndProof { + message: altair::ContributionAndProof { + aggregator_index: 7, + contribution: altair::SyncCommitteeContribution { + slot: slot_of(10), + beacon_block_root: Root::repeat_byte(5), + subcommittee_index: 2, + aggregation_bits: Default::default(), + signature: Default::default(), + }, + selection_proof: Default::default(), + }, + signature: Default::default(), + }; + let bytes = signed.to_ssz(); + assert_eq!(decode_sync_committee_contribution(&bytes), Ok(signed)); + assert_eq!( + decode_sync_committee_contribution(&bytes[..bytes.len() - 1]), + Err(DecodeError::Ssz) + ); + assert_eq!( + decode_sync_committee_contribution(&[0xff; 3]), + Err(DecodeError::Ssz) + ); + } + #[test] fn an_unsubscribed_topic_is_refused() { let config = Config::mainnet(); diff --git a/crates/net/p2p/src/beacon/handler.rs b/crates/net/p2p/src/beacon/handler.rs index b8065a37..8fa0692e 100644 --- a/crates/net/p2p/src/beacon/handler.rs +++ b/crates/net/p2p/src/beacon/handler.rs @@ -116,7 +116,9 @@ impl StatusVersion { /// peer-score penalties for silence on them, and claiming none while serving /// two loses the peers looking for exactly that. /// -/// `syncnets` stays all-zero: no sync-committee subnet is subscribed. +/// `syncnets` names the sync committee subnets joined on a validator client's +/// request (see [`BeaconWire::sync_committee_subnets`]), so it is all-zero +/// until one asks. /// `custody_group_count` is `CUSTODY_REQUIREMENT`, the floor a peer may demand, /// not this node's actual custody: `sampling_size` raises what it stores and /// serves (`BeaconWire::custody_columns`) to cover at least `SAMPLES_PER_SLOT` @@ -125,6 +127,7 @@ impl StatusVersion { pub fn build_metadata(wire: &BeaconWire, protocol: &str) -> Option { let seq_number = wire.metadata_seq_number; let attnets = attnets(wire); + let syncnets = syncnets(wire); match protocol { protocols::METADATA_V1 => Some(BeaconMetaData::V1(MetaDataV1 { seq_number, @@ -133,12 +136,12 @@ pub fn build_metadata(wire: &BeaconWire, protocol: &str) -> Option Some(BeaconMetaData::V2(MetaDataV2 { seq_number, attnets, - syncnets: SyncnetsBits::default(), + syncnets, })), protocols::METADATA_V3 => Some(BeaconMetaData::V3(MetaDataV3 { seq_number, attnets, - syncnets: SyncnetsBits::default(), + syncnets, custody_group_count: constants::CUSTODY_REQUIREMENT, })), _ => None, @@ -161,6 +164,16 @@ fn attnets(wire: &BeaconWire) -> AttnetsBits { attnets } +/// The `syncnets` bitfield for the sync committee subnets currently joined. +/// An id past the bitfield's width is dropped, as in [`attnets`]. +fn syncnets(wire: &BeaconWire) -> SyncnetsBits { + let mut syncnets = SyncnetsBits::default(); + for &subnet_id in wire.sync_committee_subnets.keys() { + let _ = syncnets.set(subnet_id as usize, true); + } + syncnets +} + /// Open the handshake on a newly established connection. /// /// `status/1` rather than `status/2`: every mainnet client still answers v1, @@ -243,6 +256,7 @@ mod tests { metadata_seq_number: 0, custody_columns: Vec::new(), attestation_subnets: Vec::new(), + sync_committee_subnets: Default::default(), } } @@ -417,10 +431,36 @@ mod tests { "subnet {subnet} advertised wrongly" ); } - // Still nothing claimed on the sync-committee side. + // Nothing claimed on the sync-committee side until a subnet is joined. assert_eq!(v3.syncnets, SyncnetsBits::default()); } + /// `syncnets` names exactly the joined sync committee subnets, in every + /// version that carries the field. + #[test] + fn syncnets_advertises_the_joined_sync_committee_subnets() { + let mut wire = wire(); + wire.sync_committee_subnets.insert(1, 10); + wire.sync_committee_subnets.insert(3, 10); + let Some(BeaconMetaData::V2(v2)) = build_metadata(&wire, protocols::METADATA_V2) else { + panic!("v2 requested"); + }; + let Some(BeaconMetaData::V3(v3)) = build_metadata(&wire, protocols::METADATA_V3) else { + panic!("v3 requested"); + }; + for syncnets in [&v2.syncnets, &v3.syncnets] { + for subnet in 0..4usize { + assert_eq!( + syncnets.get(subnet).unwrap_or(false), + subnet == 1 || subnet == 3, + "sync subnet {subnet} advertised wrongly" + ); + } + } + // attnets is untouched. + assert_eq!(v3.attnets, AttnetsBits::default()); + } + /// A subnet id the compiled bitfield has no room for is dropped rather than /// wrapped onto some other subnet's bit, which would advertise a subnet /// this node never subscribed to. diff --git a/crates/net/p2p/src/beacon/mod.rs b/crates/net/p2p/src/beacon/mod.rs index e61ed319..d2039d41 100644 --- a/crates/net/p2p/src/beacon/mod.rs +++ b/crates/net/p2p/src/beacon/mod.rs @@ -28,9 +28,11 @@ pub mod topics; pub mod transition; pub mod verdict; +use std::collections::BTreeMap; + use ethlambda_types::beacon::config::Config; use ethlambda_types::beacon::fork::ForkName; -use ethlambda_types::beacon::primitives::{ForkDigest, Root, Slot}; +use ethlambda_types::beacon::primitives::{Epoch, ForkDigest, Root, Slot}; /// Everything the beacon wire needs after startup has computed it. /// @@ -77,6 +79,13 @@ pub struct BeaconWire { /// Read by `build_metadata` for the `attnets` bitfield it advertises, so /// what this node claims to serve is what it actually subscribed to. pub attestation_subnets: Vec, + /// The sync committee subnets joined on a validator client's request, each + /// with the epoch (exclusive) it is needed until. + /// + /// Held here rather than on `P2PServer` because it is *advertised*: + /// `build_metadata` derives `syncnets` from its keys. Never in the ENR, + /// which cannot be replaced at runtime. + pub sync_committee_subnets: BTreeMap, } impl BeaconWire { @@ -110,6 +119,14 @@ impl BeaconWire { .any(|topics| topics.fork_digest == digest) } + /// The slot the wall clock is in, from this chain's genesis and slot + /// duration. Zero before genesis. + pub fn wall_slot(&self) -> Slot { + let genesis_ms = self.genesis_time.saturating_mul(1000); + ethlambda_types::time::unix_now_ms().saturating_sub(genesis_ms) + / self.config.slot_duration_ms.max(1) + } + /// The two values the codec needs to put a block chunk on or off the wire. pub fn codec_context(&self) -> BeaconContext { BeaconContext { diff --git a/crates/net/p2p/src/beacon/sync_committee.rs b/crates/net/p2p/src/beacon/sync_committee.rs index a4e11853..a668d24a 100644 --- a/crates/net/p2p/src/beacon/sync_committee.rs +++ b/crates/net/p2p/src/beacon/sync_committee.rs @@ -2,44 +2,450 @@ //! //! Sync subnets are joined on demand only, from a validator client's //! `sync_committee_subscriptions` request, and advertised in MetaData's -//! `syncnets` (never the ENR). Nothing here subscribes without a request. +//! `syncnets` (never the ENR, which cannot be replaced at runtime). Nothing +//! here subscribes without a request: validating every subnet permanently +//! would cost every follower up to a committee's worth of BLS verifications +//! per slot. //! -//! These are the entry points the `RpcToP2P` handlers call. The bodies are -//! filled in by the p2p stage of the sync committee work; until then they do -//! nothing, so the workspace builds with the handlers wired. +//! These are the entry points the `RpcToP2P` handlers call, plus the two the +//! 12 s sweep calls. A joined subnet is held in +//! [`BeaconWire::sync_committee_subnets`](super::BeaconWire) with the epoch it +//! is needed until (exclusive), and is subscribed under every digest the wire +//! holds, so a fork boundary's window is covered; +//! [`super::transition::apply`] carries the set across digests. +use ethlambda_state_transition::beacon::sync_committee_pool::RETAINED_SLOTS; +use ethlambda_types::beacon::constants::SYNC_COMMITTEE_SUBNET_COUNT; use ethlambda_types::beacon::containers::altair::{ SignedContributionAndProof, SyncCommitteeMessage, }; +use ethlambda_types::beacon::preset::SLOTS_PER_EPOCH; use ethlambda_types::beacon::primitives::Epoch; +use libp2p::gossipsub::IdentTopic; +use libssz::SszEncode as _; +use tracing::{debug, error, info, warn}; -use crate::P2PServer; +use super::topics; +use crate::gossipsub::compress_message; +use crate::{P2PServer, Wire}; /// Gossip `message` on `sync_committee_{id}` for each of `subnet_ids`, and /// mark each `(slot, validator, subnet)` seen so a peer's echo is ignored. +/// +/// The caller has already checked the message and pooled it; gossip never +/// echoes a node's own messages back, so this only publishes. pub(crate) fn publish_sync_committee_message( - _server: &mut P2PServer, - _subnet_ids: Vec, - _message: SyncCommitteeMessage, + server: &mut P2PServer, + subnet_ids: Vec, + message: SyncCommitteeMessage, ) { + let slot = message.slot; + let validator = message.validator_index; + let Some(beacon) = server.wire.beacon() else { + error!( + slot, + validator, "A sync committee message reached a lean node; dropping it" + ); + return; + }; + let Some(digest) = beacon.publish_digest(slot) else { + warn!( + slot, + validator, + "No held fork digest covers this sync committee message's slot; not publishing" + ); + return; + }; + let compressed = compress_message(&message.to_ssz()); + for subnet_id in subnet_ids { + if subnet_id >= SYNC_COMMITTEE_SUBNET_COUNT as u64 { + error!( + slot, + validator, subnet_id, "Sync committee subnet out of range; dropping it" + ); + continue; + } + let topic = IdentTopic::new(topics::sync_committee_topic_name(digest, subnet_id)); + server.swarm_handle.publish(topic, compressed.clone()); + server.seen_sync_messages.record(slot, validator, subnet_id); + debug!( + slot, + validator, subnet_id, "Published sync committee message to gossipsub" + ); + } } /// Gossip `signed` on `sync_committee_contribution_and_proof`, and mark it -/// seen. +/// seen. This node is subscribed to that topic, so it reaches the mesh. pub(crate) fn publish_sync_committee_contribution( - _server: &mut P2PServer, - _signed: SignedContributionAndProof, + server: &mut P2PServer, + signed: SignedContributionAndProof, ) { + let contribution = &signed.message.contribution; + let slot = contribution.slot; + let subcommittee_index = contribution.subcommittee_index; + let aggregator = signed.message.aggregator_index; + let Some(beacon) = server.wire.beacon() else { + error!( + slot, + "A sync committee contribution reached a lean node; dropping it" + ); + return; + }; + let Some(digest) = beacon.publish_digest(slot) else { + warn!( + slot, + aggregator, "No held fork digest covers this contribution's slot; not publishing" + ); + return; + }; + let topic = IdentTopic::new(topics::topic_name( + digest, + topics::SYNC_COMMITTEE_CONTRIBUTION_AND_PROOF, + )); + server + .swarm_handle + .publish(topic, compress_message(&signed.to_ssz())); + server.seen_sync_contributions.record(&signed); + debug!( + slot, + subcommittee_index, aggregator, "Published sync committee contribution to gossipsub" + ); } /// Join each `(subnet_id, until_epoch)` (exclusive), extending an existing -/// join. -pub(crate) fn join_sync_committee_subnets(_server: &mut P2PServer, _subnets: Vec<(u64, Epoch)>) {} +/// join. Ids past the subnet count are dropped. +pub(crate) fn join_sync_committee_subnets(server: &mut P2PServer, subnets: Vec<(u64, Epoch)>) { + let subscribed = join(server, subnets); + if !subscribed.is_empty() { + info!(topics = subscribed.len(), "Joined sync committee subnets"); + } +} + +/// [`join_sync_committee_subnets`], returning the topic names it subscribed. +fn join(server: &mut P2PServer, subnets: Vec<(u64, Epoch)>) -> Vec { + let Wire::Beacon(wire) = &mut server.wire else { + return Vec::new(); + }; + let mut joined = Vec::new(); + for (subnet_id, until) in subnets { + if subnet_id >= SYNC_COMMITTEE_SUBNET_COUNT as u64 { + continue; + } + let held_until = wire + .sync_committee_subnets + .entry(subnet_id) + .or_insert_with(|| { + joined.push(subnet_id); + until + }); + *held_until = (*held_until).max(until); + } + // Under every digest the node holds, like the aggregator subnets: while a + // boundary's window is open, messages are published on either side of it. + let mut subscribed = Vec::new(); + for &subnet_id in &joined { + for held in wire.held_topics() { + let name = topics::sync_committee_topic_name(held.fork_digest, subnet_id); + server.swarm_handle.subscribe(IdentTopic::new(name.clone())); + subscribed.push(name); + } + } + if !joined.is_empty() { + // The advertised `syncnets` changed. + wire.metadata_seq_number += 1; + } + subscribed +} /// Leave every subnet whose `until_epoch` has been reached. -#[allow(dead_code)] -pub(crate) fn leave_expired_sync_committee_subnets(_server: &mut P2PServer) {} +pub(crate) fn leave_expired_sync_committee_subnets(server: &mut P2PServer) { + let Some(wire) = server.wire.beacon() else { + return; + }; + let epoch = wire.wall_slot() / SLOTS_PER_EPOCH; + let unsubscribed = leave_expired(server, epoch); + if !unsubscribed.is_empty() { + debug!( + topics = unsubscribed.len(), + "Left sync committee subnets whose period of need has passed" + ); + } +} + +/// [`leave_expired_sync_committee_subnets`] at a given wall epoch, returning +/// the topic names it unsubscribed. +fn leave_expired(server: &mut P2PServer, epoch: Epoch) -> Vec { + let Wire::Beacon(wire) = &mut server.wire else { + return Vec::new(); + }; + let expired: Vec = wire + .sync_committee_subnets + .iter() + .filter(|&(_, &until)| epoch >= until) + .map(|(&subnet_id, _)| subnet_id) + .collect(); + let mut unsubscribed = Vec::new(); + for subnet_id in &expired { + wire.sync_committee_subnets.remove(subnet_id); + for held in wire.held_topics() { + let name = topics::sync_committee_topic_name(held.fork_digest, *subnet_id); + server + .swarm_handle + .unsubscribe(IdentTopic::new(name.clone())); + unsubscribed.push(name); + } + } + if !expired.is_empty() { + wire.metadata_seq_number += 1; + } + unsubscribed +} + +/// Drop pooled messages older than the retained window. Inserts prune as they +/// go; this also runs on the sweep, so a pool nothing is inserted into does not +/// keep stale entries. +pub(crate) fn prune_sync_committee_pool(server: &P2PServer) { + let Some(wire) = server.wire.beacon() else { + return; + }; + let now = wire.wall_slot(); + server + .sync_committee_pool + .lock() + .expect("sync committee pool lock poisoned") + .prune_before(now.saturating_sub(RETAINED_SLOTS)); +} + +#[cfg(test)] +mod tests { + use ethlambda_types::beacon::config::Config; + use ethlambda_types::beacon::containers::altair::{ + ContributionAndProof, SyncCommitteeContribution, SyncSubcommitteeBits, + }; + use ethlambda_types::beacon::fork::ForkName; + use ethlambda_types::beacon::primitives::Root; + + use super::*; + use crate::beacon::handler::build_metadata; + use crate::beacon::messages::BeaconMetaData; + use crate::beacon::protocols; + use crate::beacon::transition::apply; + use crate::test_support::unconnected_beacon_server; + + /// A config whose gloas boundary is 10 epochs after fulu's, so a second + /// digest can be held. + fn config_with_boundary() -> (Config, u64) { + let config = Config::mainnet(); + let gloas = config.fulu_fork_epoch + 10; + (config.with_fork_epoch(ForkName::Gloas, gloas), gloas) + } + + fn syncnets_of(server: &P2PServer) -> Vec { + let wire = server.wire.beacon().expect("beacon wire"); + let Some(BeaconMetaData::V3(v3)) = build_metadata(wire, protocols::METADATA_V3) else { + panic!("v3 requested"); + }; + (0..4) + .map(|id| v3.syncnets.get(id).unwrap_or(false)) + .collect() + } + + fn seq(server: &P2PServer) -> u64 { + server + .wire + .beacon() + .expect("beacon wire") + .metadata_seq_number + } + + fn one_bit() -> SyncSubcommitteeBits { + let mut bits = SyncSubcommitteeBits::default(); + bits.set(0, true).expect("position 0 exists"); + bits + } + + #[tokio::test] + async fn joining_subscribes_once_per_held_digest_and_extends_until() { + let (config, gloas) = config_with_boundary(); + let mut server = unconnected_beacon_server(config, 0).await; + // Open the window so two digests are held. + apply(&mut server, gloas - 1); + let wire = server.wire.beacon().expect("beacon wire"); + let digests: Vec<_> = wire.held_topics().map(|held| held.fork_digest).collect(); + assert_eq!(digests.len(), 2); + + let mut got = join(&mut server, vec![(1, 100)]); + let mut expected: Vec = digests + .iter() + .map(|&digest| topics::sync_committee_topic_name(digest, 1)) + .collect(); + expected.sort(); + got.sort(); + assert_eq!(got, expected); -/// Drop pooled messages older than the retained window. -#[allow(dead_code)] -pub(crate) fn prune_sync_committee_pool(_server: &P2PServer) {} + // A second request for the same subnet subscribes nothing and keeps + // the later `until`, whichever order they arrive in. + assert!(join(&mut server, vec![(1, 150)]).is_empty()); + assert!(join(&mut server, vec![(1, 120)]).is_empty()); + let wire = server.wire.beacon().expect("beacon wire"); + assert_eq!(wire.sync_committee_subnets.get(&1), Some(&150)); + } + + #[tokio::test] + async fn subnet_ids_past_the_count_are_dropped() { + let mut server = unconnected_beacon_server(Config::mainnet(), 0).await; + let seq_before = seq(&server); + assert!(join(&mut server, vec![(4, 10), (99, 10)]).is_empty()); + let wire = server.wire.beacon().expect("beacon wire"); + assert!(wire.sync_committee_subnets.is_empty()); + assert_eq!(seq(&server), seq_before); + } + + #[tokio::test] + async fn a_subnet_is_left_at_its_until_epoch() { + let mut server = unconnected_beacon_server(Config::mainnet(), 0).await; + join(&mut server, vec![(0, 50), (3, 60)]); + + assert!(leave_expired(&mut server, 49).is_empty()); + let left = leave_expired(&mut server, 50); + assert_eq!(left.len(), 1); + assert!(left[0].contains("/sync_committee_0/")); + let wire = server.wire.beacon().expect("beacon wire"); + assert_eq!( + wire.sync_committee_subnets + .keys() + .copied() + .collect::>(), + vec![3] + ); + + let left = leave_expired(&mut server, 1_000); + assert!(left[0].contains("/sync_committee_3/")); + let wire = server.wire.beacon().expect("beacon wire"); + assert!(wire.sync_committee_subnets.is_empty()); + } + + #[tokio::test] + async fn metadata_advertises_the_joined_subnets_and_the_sequence_moves_only_on_change() { + let mut server = unconnected_beacon_server(Config::mainnet(), 0).await; + let start = seq(&server); + assert_eq!(syncnets_of(&server), [false; 4]); + + join(&mut server, vec![(1, 100), (2, 100)]); + assert_eq!(syncnets_of(&server), [false, true, true, false]); + assert_eq!(seq(&server), start + 1); + + // Neither a repeat nor an extension changes what is advertised. + join(&mut server, vec![(1, 100)]); + join(&mut server, vec![(2, 200)]); + assert_eq!(seq(&server), start + 1); + + join(&mut server, vec![(3, 100)]); + assert_eq!(seq(&server), start + 2); + + // Nothing expired: no change. + leave_expired(&mut server, 99); + assert_eq!(seq(&server), start + 2); + leave_expired(&mut server, 100); + assert_eq!(syncnets_of(&server), [false, false, true, false]); + assert_eq!(seq(&server), start + 3); + } + + #[tokio::test] + async fn publishing_a_message_marks_each_subnet_seen() { + let mut server = unconnected_beacon_server(Config::mainnet(), 0).await; + let slot = server.wire.beacon().expect("beacon wire").wall_slot(); + // The test server's wire carries a placeholder digest; settle on the + // schedule's so the slot's digest is a held one. + apply( + &mut server, + slot / ethlambda_types::beacon::preset::SLOTS_PER_EPOCH, + ); + let message = SyncCommitteeMessage { + slot, + beacon_block_root: Root::repeat_byte(1), + validator_index: 9, + signature: Default::default(), + }; + + publish_sync_committee_message(&mut server, vec![1, 2, 4], message); + assert!(server.seen_sync_messages.contains(slot, 9, 1)); + assert!(server.seen_sync_messages.contains(slot, 9, 2)); + assert!(!server.seen_sync_messages.contains(slot, 9, 0)); + // An out-of-range subnet is dropped, not recorded. + assert!(!server.seen_sync_messages.contains(slot, 9, 4)); + } + + #[tokio::test] + async fn a_message_for_a_slot_no_held_digest_covers_is_not_marked_seen() { + let (config, gloas) = config_with_boundary(); + let mut server = unconnected_beacon_server(config, 0).await; + apply(&mut server, gloas - 20); + // A slot past the boundary names a digest nobody holds yet. + let slot = (gloas + 5) * ethlambda_types::beacon::preset::SLOTS_PER_EPOCH; + let message = SyncCommitteeMessage { + slot, + beacon_block_root: Root::repeat_byte(1), + validator_index: 9, + signature: Default::default(), + }; + publish_sync_committee_message(&mut server, vec![1], message); + assert!(!server.seen_sync_messages.contains(slot, 9, 1)); + } + + #[tokio::test] + async fn publishing_a_contribution_marks_it_seen() { + let mut server = unconnected_beacon_server(Config::mainnet(), 0).await; + let slot = server.wire.beacon().expect("beacon wire").wall_slot(); + // The test server's wire carries a placeholder digest; settle on the + // schedule's so the slot's digest is a held one. + apply( + &mut server, + slot / ethlambda_types::beacon::preset::SLOTS_PER_EPOCH, + ); + let signed = SignedContributionAndProof { + message: ContributionAndProof { + aggregator_index: 4, + contribution: SyncCommitteeContribution { + slot, + beacon_block_root: Root::repeat_byte(1), + subcommittee_index: 2, + aggregation_bits: one_bit(), + signature: Default::default(), + }, + selection_proof: Default::default(), + }, + signature: Default::default(), + }; + + publish_sync_committee_contribution(&mut server, signed.clone()); + // Recording the same contribution again says it was already there. + assert!(!server.seen_sync_contributions.record(&signed)); + } + + #[tokio::test] + async fn pruning_drops_slots_outside_the_retained_window() { + let server = unconnected_beacon_server(Config::mainnet(), 0).await; + let wall = server.wire.beacon().expect("beacon wire").wall_slot(); + let old = wall - RETAINED_SLOTS - 10; + let root = Root::repeat_byte(1); + let contribution = |slot| SyncCommitteeContribution { + slot, + beacon_block_root: root, + subcommittee_index: 0, + aggregation_bits: one_bit(), + signature: Default::default(), + }; + { + let mut pool = server.sync_committee_pool.lock().unwrap(); + // Newest first: an insert prunes what trails its own slot. + pool.insert_contribution(contribution(wall)); + pool.insert_contribution(contribution(old)); + } + prune_sync_committee_pool(&server); + let pool = server.sync_committee_pool.lock().unwrap(); + assert!(pool.contribution(old, root, 0).is_none()); + assert!(pool.contribution(wall, root, 0).is_some()); + } +} diff --git a/crates/net/p2p/src/beacon/topics.rs b/crates/net/p2p/src/beacon/topics.rs index ff4e6da6..63e8d445 100644 --- a/crates/net/p2p/src/beacon/topics.rs +++ b/crates/net/p2p/src/beacon/topics.rs @@ -14,9 +14,12 @@ //! relayed but never applied to fork choice, which is what a lighthouse node //! with no validators does too. //! -//! `sync_committee_{0..3}` and `blob_sidecar_{subnet_id}` stay absent; the -//! first arrives with the work that reads it and the second is deneb's format -//! for blobs, deprecated at fulu in favour of the column matrix. Both subnet +//! `sync_committee_{0..3}` is not part of [`BeaconTopics`] either: those four +//! subnets are joined on demand, from a validator client's subscription +//! request, like the aggregator attestation subnets (see +//! `beacon::sync_committee`). `blob_sidecar_{subnet_id}` stays absent: it is +//! deneb's format for blobs, deprecated at fulu in favour of the column +//! matrix. Both subnet //! families that *are* subscribed are subscribed narrowly: this node's sampling //! size worth of columns rather than the whole matrix, and two attestation //! subnets rather than all sixty-four, since widening either is what turns this @@ -77,9 +80,14 @@ pub const DATA_COLUMN_SIDECAR_KIND: &str = "data_column_sidecar"; /// [`DATA_COLUMN_SIDECAR_KIND`]. pub const BEACON_ATTESTATION_KIND: &str = "beacon_attestation"; +/// The metric label every sync committee subnet shares. It is also the topic +/// name the spec's gossip vectors use for the family. +pub const SYNC_COMMITTEE_KIND: &str = "sync_committee"; + /// The metric label for a topic kind this node subscribes to on the beacon /// wire: the kind itself for a global topic, [`DATA_COLUMN_SIDECAR_KIND`] for -/// a column subnet, [`BEACON_ATTESTATION_KIND`] for an attestation subnet. +/// a column subnet, [`BEACON_ATTESTATION_KIND`] for an attestation subnet, +/// [`SYNC_COMMITTEE_KIND`] for a sync committee subnet. /// `None` for anything else, lean kinds included, which is what tells the /// gossip handler a message needs no verdict. pub fn metric_kind(kind: &str) -> Option<&'static str> { @@ -93,9 +101,34 @@ pub fn metric_kind(kind: &str) -> Option<&'static str> { if data_column_subnet(kind).is_some() { return Some(DATA_COLUMN_SIDECAR_KIND); } + if sync_committee_subnet(kind).is_some() { + return Some(SYNC_COMMITTEE_KIND); + } attestation_subnet(kind).map(|_| BEACON_ATTESTATION_KIND) } +/// Topic family for sync committee messages, one topic per subnet. +pub const SYNC_COMMITTEE_PREFIX: &str = "sync_committee_"; + +/// The topic carrying one sync committee subnet. +pub fn sync_committee_topic_name(fork_digest: ForkDigest, subnet_id: u64) -> String { + topic_name(fork_digest, &format!("{SYNC_COMMITTEE_PREFIX}{subnet_id}")) +} + +/// The subnet a sync committee topic kind names, or `None` if the kind is not +/// one. +/// +/// The digit check is load-bearing: the global +/// `sync_committee_contribution_and_proof` shares the family prefix and must +/// not be read as a subnet. +pub fn sync_committee_subnet(kind: &str) -> Option { + let suffix = kind.strip_prefix(SYNC_COMMITTEE_PREFIX)?; + if suffix.is_empty() || !suffix.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + suffix.parse().ok() +} + /// Build one topic name: `/eth2/{fork_digest}/{kind}/ssz_snappy`. /// /// `fork_digest` is lowercase hex with no `0x` prefix, which is what every @@ -572,4 +605,36 @@ mod tests { assert_eq!(topics.topics.len(), SUBSCRIBED_TOPIC_KINDS.len() + 2); assert_eq!(topics.attestation_topics.len(), 2); } + + #[test] + fn a_sync_committee_topic_is_the_family_name_and_its_subnet() { + assert_eq!( + sync_committee_topic_name(MAINNET, 2), + "/eth2/8c9f62fe/sync_committee_2/ssz_snappy" + ); + } + + #[test] + fn a_sync_committee_topic_reads_its_subnet_back() { + assert_eq!(sync_committee_subnet("sync_committee_3"), Some(3)); + assert_eq!(sync_committee_subnet("sync_committee_"), None); + assert_eq!(sync_committee_subnet("sync_committee_x"), None); + // The contribution topic shares the prefix and is not a subnet. + assert_eq!( + sync_committee_subnet(SYNC_COMMITTEE_CONTRIBUTION_AND_PROOF), + None + ); + assert_eq!(sync_committee_subnet("beacon_block"), None); + } + + #[test] + fn sync_committee_subnets_share_one_label() { + assert_eq!(metric_kind("sync_committee_0"), Some(SYNC_COMMITTEE_KIND)); + assert_eq!(metric_kind("sync_committee_3"), Some(SYNC_COMMITTEE_KIND)); + assert_eq!(metric_kind("sync_committee_"), None); + assert_eq!( + metric_kind(SYNC_COMMITTEE_CONTRIBUTION_AND_PROOF), + Some(SYNC_COMMITTEE_CONTRIBUTION_AND_PROOF) + ); + } } diff --git a/crates/net/p2p/src/beacon/transition.rs b/crates/net/p2p/src/beacon/transition.rs index 59016d2e..575a0471 100644 --- a/crates/net/p2p/src/beacon/transition.rs +++ b/crates/net/p2p/src/beacon/transition.rs @@ -159,6 +159,11 @@ pub(crate) fn apply(server: &mut P2PServer, epoch: Epoch) -> Changes { changes.subscribed.push(topic.to_string()); server.swarm_handle.subscribe(topic); } + for &subnet_id in wire.sync_committee_subnets.keys() { + let topic = sync_committee_topic(entry.digest, subnet_id); + changes.subscribed.push(topic.to_string()); + server.swarm_handle.subscribe(topic); + } info!( fork_digest = %hex::encode(entry.digest), fork = entry.fork.as_str(), @@ -180,6 +185,11 @@ pub(crate) fn apply(server: &mut P2PServer, epoch: Epoch) -> Changes { changes.unsubscribed.push(topic.to_string()); server.swarm_handle.unsubscribe(topic); } + for &subnet_id in wire.sync_committee_subnets.keys() { + let topic = sync_committee_topic(left.fork_digest, subnet_id); + changes.unsubscribed.push(topic.to_string()); + server.swarm_handle.unsubscribe(topic); + } info!( fork_digest = %hex::encode(left.fork_digest), epoch, @@ -232,6 +242,10 @@ fn attestation_topic(digest: ForkDigest, subnet_id: u64) -> IdentTopic { IdentTopic::new(topics::attestation_topic_name(digest, subnet_id)) } +fn sync_committee_topic(digest: ForkDigest, subnet_id: u64) -> IdentTopic { + IdentTopic::new(topics::sync_committee_topic_name(digest, subnet_id)) +} + #[cfg(test)] mod tests { use super::*; @@ -439,6 +453,35 @@ mod tests { assert_eq!(apply(&mut server, GLOAS + 2), Changes::default()); } + #[tokio::test] + async fn sync_committee_subnets_follow_every_held_digest() { + let mut server = unconnected_beacon_server(config(), 0).await; + let schedule = server.wire.beacon().expect("beacon").schedule.clone(); + let fulu = schedule.digest_at(GLOAS - 2); + let gloas = schedule.digest_at(GLOAS); + let Wire::Beacon(wire) = &mut server.wire else { + panic!("a beacon wire"); + }; + wire.sync_committee_subnets.insert(2, u64::MAX); + let name = |digest| topics::sync_committee_topic_name(digest, 2); + + // Settle on the pre-window digest first. + apply(&mut server, GLOAS - 2); + + // Joining the next digest brings the sync subnet along. + let joined = apply(&mut server, GLOAS - 1); + assert!(joined.subscribed.contains(&name(gloas))); + assert!(joined.unsubscribed.is_empty()); + + // Leaving the old digest takes it away again. + let left = apply(&mut server, GLOAS + 2); + assert!(left.unsubscribed.contains(&name(fulu))); + assert!(left.subscribed.is_empty()); + + // Nothing to do the second time. + assert_eq!(apply(&mut server, GLOAS + 2), Changes::default()); + } + #[tokio::test] async fn publishing_follows_the_messages_own_slot() { let mut server = unconnected_beacon_server(config(), 0).await; diff --git a/crates/net/p2p/src/beacon/verdict.rs b/crates/net/p2p/src/beacon/verdict.rs index 5d820eed..a477dc41 100644 --- a/crates/net/p2p/src/beacon/verdict.rs +++ b/crates/net/p2p/src/beacon/verdict.rs @@ -3,10 +3,12 @@ //! The rules live in `ethlambda_state_transition::beacon::gossip`; this module //! decides where each half runs and what happens to the result. Cheap checks //! run inline in the p2p actor. Stateful checks run on a `spawn_blocking` -//! thread, bounded by one of two pools depending on the kind: a block or a +//! thread, bounded by one of three pools depending on the kind: a block or a //! column draws from [`P2PServer::gossip_validation_permits`], an aggregate or //! a subnet attestation from [`P2PServer::attestation_validation_permits`] (see -//! that field's own documentation for why they must not share one). Either way +//! that field's own documentation for why they must not share one), a sync +//! committee message or contribution from +//! [`P2PServer::sync_validation_permits`]. Either way //! the blocking task sends a [`GossipVerdict`] back to the actor. Every beacon //! gossip message ends in exactly one [`report`]: gossipsub holds each one //! until then. @@ -18,6 +20,9 @@ use ethlambda_network_api::{AggregateArrival, BlockArrival, BlockSource}; use ethlambda_state_transition::beacon::gossip::{self, IgnoreReason, Outcome}; use ethlambda_state_transition::beacon::helpers::accessors::CommitteeCacheExt as _; use ethlambda_storage::{CacheKey, Store}; +use ethlambda_types::beacon::containers::altair::{ + SignedContributionAndProof, SyncCommitteeMessage, +}; use ethlambda_types::beacon::containers::electra::{self, SingleAttestation}; use ethlambda_types::beacon::containers::gloas::{ PayloadAttestationMessage, SignedExecutionPayloadEnvelope, @@ -80,6 +85,17 @@ pub(crate) enum Validated { Envelope(Box), /// A gloas `payload_attestation_message`. PayloadAttestation(PayloadAttestationMessage), + /// A `sync_committee_{subnet_id}` message. + SyncCommitteeMessage { + message: SyncCommitteeMessage, + subnet_id: u64, + /// The `(subcommittee, position)` seats its signature verified for. + /// Empty until [`Validated::stateful_checks`] fills it in on `Accept`, + /// like [`Self::Aggregate`]'s `attesting_indices`. + seats: Vec<(u64, usize)>, + }, + /// A `sync_committee_contribution_and_proof`. + SyncContribution(Box), } impl Validated { @@ -118,6 +134,22 @@ impl Validated { Self::PayloadAttestation(message) => { gossip::payload_attestation::stateful_checks(store, message) } + Self::SyncCommitteeMessage { + message, + subnet_id, + seats, + } => { + match gossip::sync_committee::message_stateful_checks(store, message, *subnet_id) { + Ok(resolved) => { + *seats = resolved; + Outcome::Accept + } + Err(outcome) => outcome, + } + } + Self::SyncContribution(signed) => { + gossip::sync_committee::contribution_stateful_checks(store, signed) + } } } @@ -150,6 +182,14 @@ impl Validated { Self::PayloadAttestation(message) => server .seen_payload_attestations .record(message.data.slot, message.validator_index), + Self::SyncCommitteeMessage { + message, subnet_id, .. + } => { + server + .seen_sync_messages + .record(message.slot, message.validator_index, *subnet_id) + } + Self::SyncContribution(signed) => server.seen_sync_contributions.record(signed), } } @@ -210,6 +250,26 @@ impl Validated { .expect("payload attestation pool lock") .insert(message.clone()); } + if let Self::SyncCommitteeMessage { message, seats, .. } = &self + && outcome == Outcome::Accept + { + // Block production packs these through the pool; nothing on the + // chain actor consumes a sync message, so it never goes further. + server + .sync_committee_pool + .lock() + .expect("sync committee pool lock poisoned") + .insert_message(message, seats); + } + if let Self::SyncContribution(signed) = &self + && outcome == Outcome::Accept + { + server + .sync_committee_pool + .lock() + .expect("sync committee pool lock poisoned") + .insert_contribution(signed.message.contribution.clone()); + } let Some(blockchain) = &server.blockchain else { return; }; @@ -267,7 +327,9 @@ impl Validated { Self::Aggregate { .. } | Self::Attestation { .. } | Self::Envelope(_) - | Self::PayloadAttestation(_) => {} + | Self::PayloadAttestation(_) + | Self::SyncCommitteeMessage { .. } + | Self::SyncContribution(_) => {} } } } @@ -433,7 +495,8 @@ pub(crate) fn report(server: &P2PServer, id: GossipId, outcome: Outcome) -> bool /// The permit pool `object`'s stateful checks draw from: blocks, columns and /// envelopes from the gossip pool, everything the attesters send from the -/// attestation pool, so neither burst starves the other. +/// attestation pool, and the sync committee's messages and contributions from +/// a pool of their own, so no burst starves another. fn permits_for<'a>( server: &'a P2PServer, object: &Validated, @@ -445,6 +508,9 @@ fn permits_for<'a>( Validated::Aggregate { .. } | Validated::Attestation { .. } | Validated::PayloadAttestation(_) => &server.attestation_validation_permits, + Validated::SyncCommitteeMessage { .. } | Validated::SyncContribution(_) => { + &server.sync_validation_permits + } } } @@ -1177,4 +1243,238 @@ mod tests { ); assert_eq!(guarded(|| Outcome::Accept), Outcome::Accept); } + + /// The compressed BLS12-381 G2 generator: a signature that decodes and + /// combines, standing in for a verified one where the test is about + /// pooling and not about verification. + fn valid_looking_signature() -> ethlambda_types::beacon::primitives::BlsSignature { + let bytes = hex::decode( + "93e02b6052719f607dacd3a088274f65596bd0d09920b61ab5da61bbdc7f5049\ + 334cf11213945d57e5ac7d055d042b7e024aa2b2f08f0a91260805272dc51051\ + c6e47ad4fa403b02b4510b647ae3d1770bac0326a805bbefd48056c8c121bdb8", + ) + .expect("valid hex"); + ethlambda_types::beacon::primitives::BlsSignature(bytes.try_into().expect("96 bytes")) + } + + fn sync_message(slot: u64, validator: u64) -> SyncCommitteeMessage { + SyncCommitteeMessage { + slot, + beacon_block_root: Root::repeat_byte(7), + validator_index: validator, + signature: valid_looking_signature(), + } + } + + fn sync_message_object( + slot: u64, + validator: u64, + subnet_id: u64, + seats: Vec<(u64, usize)>, + ) -> Validated { + Validated::SyncCommitteeMessage { + message: sync_message(slot, validator), + subnet_id, + seats, + } + } + + fn sync_contribution( + slot: u64, + aggregator: u64, + subcommittee: u64, + ) -> SignedContributionAndProof { + let mut aggregation_bits = + ethlambda_types::beacon::containers::altair::SyncSubcommitteeBits::default(); + aggregation_bits.set(0, true).expect("position 0 exists"); + SignedContributionAndProof { + message: ethlambda_types::beacon::containers::altair::ContributionAndProof { + aggregator_index: aggregator, + contribution: + ethlambda_types::beacon::containers::altair::SyncCommitteeContribution { + slot, + beacon_block_root: Root::repeat_byte(7), + subcommittee_index: subcommittee, + aggregation_bits, + signature: valid_looking_signature(), + }, + selection_proof: Default::default(), + }, + signature: Default::default(), + } + } + + #[tokio::test] + async fn the_first_accept_for_a_sync_message_key_stands_and_the_second_is_marked_seen() { + let mut server = unconnected_beacon_server(Config::mainnet(), 0).await; + let object = sync_message_object(5, 11, 1, vec![(1, 0)]); + + assert_eq!( + settle(&mut server, Outcome::Accept, &object), + Outcome::Accept + ); + assert_eq!( + settle(&mut server, Outcome::Accept, &object), + Outcome::Ignore(IgnoreReason::AlreadySeen) + ); + // The same validator on another subnet is another key. + let other_subnet = sync_message_object(5, 11, 2, vec![(2, 0)]); + assert_eq!( + settle(&mut server, Outcome::Accept, &other_subnet), + Outcome::Accept + ); + // So is the same subnet in another slot. + let other_slot = sync_message_object(6, 11, 1, vec![(1, 0)]); + assert_eq!( + settle(&mut server, Outcome::Accept, &other_slot), + Outcome::Accept + ); + } + + #[tokio::test] + async fn the_first_accept_for_a_sync_contribution_stands_and_the_second_is_marked_seen() { + let mut server = unconnected_beacon_server(Config::mainnet(), 0).await; + let object = Validated::SyncContribution(Box::new(sync_contribution(5, 3, 1))); + + assert_eq!( + settle(&mut server, Outcome::Accept, &object), + Outcome::Accept + ); + assert_eq!( + settle(&mut server, Outcome::Accept, &object), + Outcome::Ignore(IgnoreReason::AlreadySeen) + ); + // Anything but an accept records nothing. + let other = Validated::SyncContribution(Box::new(sync_contribution(5, 4, 2))); + assert_eq!( + settle( + &mut server, + Outcome::Reject(RejectReason::BadSignature), + &other + ), + Outcome::Reject(RejectReason::BadSignature) + ); + assert_eq!( + settle(&mut server, Outcome::Accept, &other), + Outcome::Accept + ); + } + + /// An accepted sync message lands in the pool at the seats its checks + /// resolved; any other outcome stays out, since one unverified signature + /// fails the aggregate it is packed into. + #[tokio::test] + async fn an_accepted_sync_message_is_pooled_and_others_are_not() { + let server = unconnected_beacon_server(Config::mainnet(), 0).await; + let root = Root::repeat_byte(7); + let pooled = || { + server + .sync_committee_pool + .lock() + .unwrap() + .contribution(5, root, 1) + }; + let forward = |outcome| { + sync_message_object(5, 11, 1, vec![(1, 3)]).forward(&server, Instant::now(), outcome) + }; + forward(Outcome::Ignore(IgnoreReason::Overloaded)); + forward(Outcome::Reject(RejectReason::BadSignature)); + forward(Outcome::Queue(QueueReason::BlockUnknown)); + assert!(pooled().is_none()); + forward(Outcome::Accept); + let contribution = pooled().expect("pooled on accept"); + assert!(contribution.aggregation_bits.get(3).unwrap()); + assert!(!contribution.aggregation_bits.get(0).unwrap()); + } + + #[tokio::test] + async fn an_accepted_sync_contribution_is_pooled_and_others_are_not() { + let server = unconnected_beacon_server(Config::mainnet(), 0).await; + let root = Root::repeat_byte(7); + let pooled = || { + server + .sync_committee_pool + .lock() + .unwrap() + .contribution(5, root, 2) + }; + let forward = |outcome| { + Validated::SyncContribution(Box::new(sync_contribution(5, 3, 2))).forward( + &server, + Instant::now(), + outcome, + ) + }; + forward(Outcome::Ignore(IgnoreReason::Overloaded)); + forward(Outcome::Reject(RejectReason::AggregateSignature)); + assert!(pooled().is_none()); + forward(Outcome::Accept); + assert_eq!( + pooled().expect("pooled on accept"), + sync_contribution(5, 3, 2).message.contribution + ); + } + + /// Sync messages and contributions have no fork-choice effect, so nothing + /// is handed to the chain actor on any outcome. + #[tokio::test] + async fn sync_committee_gossip_never_reaches_the_chain_actor() { + let mut server = unconnected_beacon_server(Config::mainnet(), 0).await; + let (sender, mut received) = tokio::sync::mpsc::unbounded_channel(); + server.blockchain = Some(Arc::new(GloasRecorder(sender))); + for outcome in [ + Outcome::Accept, + Outcome::Queue(QueueReason::BlockUnknown), + Outcome::Ignore(IgnoreReason::Overloaded), + Outcome::Reject(RejectReason::BadSignature), + ] { + sync_message_object(5, 11, 1, vec![(1, 3)]).forward(&server, Instant::now(), outcome); + Validated::SyncContribution(Box::new(sync_contribution(5, 3, 2))).forward( + &server, + Instant::now(), + outcome, + ); + } + assert!(received.try_recv().is_err()); + } + + /// The sync pool is its own: exhausting it must not starve the block and + /// column pool or the attestation pool, nor the other way round. + #[tokio::test] + async fn the_sync_permit_pool_is_independent_of_the_others() { + let server = unconnected_beacon_server(Config::mainnet(), 0).await; + let message = sync_message_object(5, 11, 1, Vec::new()); + let contribution = Validated::SyncContribution(Box::new(sync_contribution(5, 3, 1))); + let sync = &server.sync_validation_permits; + for object in [&message, &contribution] { + assert!(std::sync::Arc::ptr_eq(permits_for(&server, object), sync)); + } + assert!(!std::sync::Arc::ptr_eq( + sync, + &server.gossip_validation_permits + )); + assert!(!std::sync::Arc::ptr_eq( + sync, + &server.attestation_validation_permits + )); + + // Draining the sync pool leaves the other two untouched. + let gossip_before = server.gossip_validation_permits.available_permits(); + let attestation_before = server.attestation_validation_permits.available_permits(); + let held = sync + .clone() + .acquire_many_owned(sync.available_permits() as u32) + .await + .expect("the pool is open"); + assert!(sync.clone().try_acquire_owned().is_err()); + assert_eq!( + server.gossip_validation_permits.available_permits(), + gossip_before + ); + assert_eq!( + server.attestation_validation_permits.available_permits(), + attestation_before + ); + drop(held); + } } diff --git a/crates/net/p2p/src/gossipsub/handler.rs b/crates/net/p2p/src/gossipsub/handler.rs index 1b664a47..efa643ba 100644 --- a/crates/net/p2p/src/gossipsub/handler.rs +++ b/crates/net/p2p/src/gossipsub/handler.rs @@ -253,6 +253,10 @@ fn handle_beacon_gossip( triage_envelope(server, payload) } else if kind == beacon_topics::PAYLOAD_ATTESTATION_MESSAGE { triage_payload_attestation(server, payload) + } else if let Some(subnet_id) = beacon_topics::sync_committee_subnet(kind) { + triage_sync_committee_message(server, payload, subnet_id) + } else if kind == beacon_topics::SYNC_COMMITTEE_CONTRIBUTION_AND_PROOF { + triage_sync_contribution(server, payload) } else { triage_other(wire, kind, payload) }; @@ -542,8 +546,80 @@ fn triage_payload_attestation(server: &P2PServer, payload: &[u8]) -> Dispatch { Dispatch::Validate(Validated::PayloadAttestation(message)) } -/// Decode one of the five beacon topics with nothing particular to report, -/// and count it. Ignored rather than validated: none of the five has a +/// Decode a `sync_committee_{subnet_id}` message and run its cheap gossip +/// checks. Same shape as [`triage_block`]. The fork does not matter: the +/// container is the same everywhere it exists, and the stateful half picks +/// the signing domain from the message's own slot. +fn triage_sync_committee_message(server: &P2PServer, payload: &[u8], subnet_id: u64) -> Dispatch { + const KIND: &str = beacon_topics::SYNC_COMMITTEE_KIND; + let message = match beacon_decode::decode_sync_committee_message(payload) { + Ok(message) => message, + Err(err) => { + metrics::inc_beacon_gossip(KIND, "decode_failed"); + debug!(kind = KIND, %err, bytes = payload.len(), "Beacon gossip decode failed"); + return Dispatch::Report(Outcome::Reject(RejectReason::Decode)); + } + }; + metrics::inc_beacon_gossip(KIND, "decoded"); + // `trace` rather than `debug`: a subcommittee votes in one burst per slot. + trace!( + slot = message.slot, + subnet_id, + validator = message.validator_index, + block_root = %ShortRoot(&message.beacon_block_root.0), + "Beacon sync committee message decoded" + ); + if let Err(outcome) = gossip::sync_committee::message_cheap_checks( + &server.seen_sync_messages, + &server.store, + &message, + subnet_id, + unix_now_ms(), + ) { + return Dispatch::Report(outcome); + } + Dispatch::Validate(Validated::SyncCommitteeMessage { + message, + subnet_id, + seats: Vec::new(), + }) +} + +/// Decode a `sync_committee_contribution_and_proof` and run its cheap gossip +/// checks. Same shape as [`triage_block`]. +fn triage_sync_contribution(server: &P2PServer, payload: &[u8]) -> Dispatch { + const KIND: &str = beacon_topics::SYNC_COMMITTEE_CONTRIBUTION_AND_PROOF; + let signed = match beacon_decode::decode_sync_committee_contribution(payload) { + Ok(signed) => signed, + Err(err) => { + metrics::inc_beacon_gossip(KIND, "decode_failed"); + debug!(kind = KIND, %err, bytes = payload.len(), "Beacon gossip decode failed"); + return Dispatch::Report(Outcome::Reject(RejectReason::Decode)); + } + }; + metrics::inc_beacon_gossip(KIND, "decoded"); + let contribution = &signed.message.contribution; + debug!( + slot = contribution.slot, + subcommittee_index = contribution.subcommittee_index, + aggregator = signed.message.aggregator_index, + block_root = %ShortRoot(&contribution.beacon_block_root.0), + bytes = payload.len(), + "Beacon sync committee contribution decoded" + ); + if let Err(outcome) = gossip::sync_committee::contribution_cheap_checks( + &server.seen_sync_contributions, + &server.store, + &signed, + unix_now_ms(), + ) { + return Dispatch::Report(outcome); + } + Dispatch::Validate(Validated::SyncContribution(Box::new(signed))) +} + +/// Decode one of the four beacon topics with nothing particular to report, +/// and count it. Ignored rather than validated: none of the four has a /// consumer, so this always answers `Dispatch::Report`. fn triage_other(wire: &BeaconWire, kind: &str, payload: &[u8]) -> Dispatch { let outcome = match beacon_decode::decode_gossip(&wire.config, kind, payload) { @@ -931,8 +1007,7 @@ pub async fn publish_payload_attestation_message( /// The beacon wall-clock slot, from the wire's genesis and slot duration. fn beacon_wall_slot(wire: &BeaconWire) -> u64 { - let genesis_ms = wire.genesis_time.saturating_mul(1000); - unix_now_ms().saturating_sub(genesis_ms) / wire.config.slot_duration_ms.max(1) + wire.wall_slot() } /// Join the attestation subnets a validator client's aggregators need, per @@ -1516,4 +1591,66 @@ mod tests { Dispatch::Report(Outcome::Ignore(IgnoreReason::NotCurrentSlot)) )); } + + fn sync_message_bytes(slot: Slot) -> Vec { + ethlambda_types::beacon::containers::altair::SyncCommitteeMessage { + slot, + beacon_block_root: Default::default(), + validator_index: 3, + signature: Default::default(), + } + .to_ssz() + } + + #[tokio::test] + async fn garbage_on_a_sync_committee_subnet_is_rejected_as_undecodable() { + let server = unconnected_beacon_server(Config::mainnet(), 0).await; + + assert!(matches!( + triage_sync_committee_message(&server, &[0xff; 3], 1), + Dispatch::Report(Outcome::Reject(RejectReason::Decode)) + )); + } + + #[tokio::test] + async fn garbage_on_the_contribution_topic_is_rejected_as_undecodable() { + let server = unconnected_beacon_server(Config::mainnet(), 0).await; + + assert!(matches!( + triage_sync_contribution(&server, &[0xff; 3]), + Dispatch::Report(Outcome::Reject(RejectReason::Decode)) + )); + } + + #[tokio::test] + async fn a_past_slot_sync_message_is_ignored_and_a_current_one_is_validated() { + let server = unconnected_beacon_server(Config::mainnet(), 0).await; + let wall = server.wire.beacon().expect("beacon wire").wall_slot(); + + assert!(matches!( + triage_sync_committee_message(&server, &sync_message_bytes(wall - 5), 1), + Dispatch::Report(Outcome::Ignore(IgnoreReason::NotCurrentSlot)) + )); + assert!(matches!( + triage_sync_committee_message(&server, &sync_message_bytes(wall), 1), + Dispatch::Validate(Validated::SyncCommitteeMessage { subnet_id: 1, .. }) + )); + } + + #[tokio::test] + async fn a_sync_message_already_seen_on_its_subnet_is_ignored() { + let mut server = unconnected_beacon_server(Config::mainnet(), 0).await; + let wall = server.wire.beacon().expect("beacon wire").wall_slot(); + server.seen_sync_messages.record(wall, 3, 1); + + assert!(matches!( + triage_sync_committee_message(&server, &sync_message_bytes(wall), 1), + Dispatch::Report(Outcome::Ignore(IgnoreReason::AlreadySeen)) + )); + // Another subnet is another key. + assert!(matches!( + triage_sync_committee_message(&server, &sync_message_bytes(wall), 2), + Dispatch::Validate(_) + )); + } } diff --git a/crates/net/p2p/src/gossipsub/mod.rs b/crates/net/p2p/src/gossipsub/mod.rs index 1701c5f3..cde2c7d7 100644 --- a/crates/net/p2p/src/gossipsub/mod.rs +++ b/crates/net/p2p/src/gossipsub/mod.rs @@ -2,7 +2,7 @@ mod encoding; mod handler; mod messages; -pub use encoding::decompress_message; +pub use encoding::{compress_message, decompress_message}; pub use handler::{ handle_gossip_message, join_aggregator_subnets, leave_expired_aggregator_subnets, prune_attestation_pool, publish_aggregated_attestation, publish_attestation, diff --git a/crates/net/p2p/src/lib.rs b/crates/net/p2p/src/lib.rs index bd328ac0..396586d1 100644 --- a/crates/net/p2p/src/lib.rs +++ b/crates/net/p2p/src/lib.rs @@ -52,9 +52,12 @@ use ethlambda_network_api::{ }; use ethlambda_state_transition::beacon::aggregate::MAX_AGGREGATES_PER_SLOT; use ethlambda_state_transition::beacon::gossip::{ - SeenBlockColumns, SeenBlocks, SeenColumns, aggregate::SeenAggregates, - attestation::SeenAttestations, envelope::SeenEnvelopes, + SeenBlockColumns, SeenBlocks, SeenColumns, + aggregate::SeenAggregates, + attestation::SeenAttestations, + envelope::SeenEnvelopes, payload_attestation::SeenPayloadAttestations, + sync_committee::{SeenSyncCommitteeMessages, SeenSyncContributions}, }; use ethlambda_state_transition::beacon::{ attestation_pool::SharedAttestationPool, @@ -247,6 +250,28 @@ const COLUMN_CHECK_PERMITS: usize = 16; /// has data from a follower. const ATTESTATION_VALIDATION_PERMITS: usize = 128; +/// How many `sync_committee_{subnet_id}` and +/// `sync_committee_contribution_and_proof` stateful checks may run at once. +/// +/// A pool of its own: the sync committee's burst comes a third of the way into +/// the slot, where the attestations' does, so sharing +/// [`ATTESTATION_VALIDATION_PERMITS`] would let each starve the other. A +/// message arriving with none free is ignored, like every other kind. +const SYNC_VALIDATION_PERMITS: usize = 64; + +/// Capacity of the first-valid-sync-message cache, keyed by `(slot, validator +/// index, subnet)`. A subnet carries one message per member per slot, at most +/// a subcommittee's worth, so this holds a few slots of every subnet. +const SEEN_SYNC_MESSAGES_CAPACITY: NonZeroUsize = NonZeroUsize::new(4096).expect("non-zero"); + +/// Capacity of the accepted-contribution cache by `(slot, aggregator index, +/// subcommittee)`. The rule only asks about the current slot. +const SEEN_SYNC_AGGREGATORS_CAPACITY: NonZeroUsize = NonZeroUsize::new(4096).expect("non-zero"); + +/// Capacity of the accepted-contribution cache by `(slot, root, subcommittee)`, +/// which holds the participation bits seen for that key. +const SEEN_SYNC_DATA_CAPACITY: NonZeroUsize = NonZeroUsize::new(256).expect("non-zero"); + /// Capacity of the first-valid-block cache, keyed by `(slot, proposer)`. /// How often to leave aggregator subnets whose slot has passed. One slot's /// worth: a subnet outlives its need by at most this, which costs a little @@ -1055,6 +1080,7 @@ pub fn build_swarm(config: SwarmConfig) -> Result { metadata_seq_number: 0, custody_columns: beacon.custody_columns, attestation_subnets: beacon.attestation_subnets, + sync_committee_subnets: std::collections::BTreeMap::new(), })) } }; @@ -1151,9 +1177,15 @@ impl P2P { seen_attestations: SeenAttestations::new(seen_attestations_capacity( backbone_attestation_subnets, )), + seen_sync_messages: SeenSyncCommitteeMessages::new(SEEN_SYNC_MESSAGES_CAPACITY), + seen_sync_contributions: SeenSyncContributions::new( + SEEN_SYNC_AGGREGATORS_CAPACITY, + SEEN_SYNC_DATA_CAPACITY, + ), gossip_validation_permits: Arc::new(tokio::sync::Semaphore::new( GOSSIP_VALIDATION_PERMITS, )), + sync_validation_permits: Arc::new(tokio::sync::Semaphore::new(SYNC_VALIDATION_PERMITS)), column_check_permits: Arc::new(tokio::sync::Semaphore::new(COLUMN_CHECK_PERMITS)), attestation_validation_permits: Arc::new(tokio::sync::Semaphore::new( ATTESTATION_VALIDATION_PERMITS, @@ -1279,6 +1311,12 @@ pub struct P2PServer { /// Accepted `beacon_attestation_{subnet_id}`s, by `(target_epoch, /// attester_index)`. pub(crate) seen_attestations: SeenAttestations, + /// Accepted `sync_committee_{subnet_id}` messages, by `(slot, validator + /// index, subnet)`. + pub(crate) seen_sync_messages: SeenSyncCommitteeMessages, + /// Accepted `sync_committee_contribution_and_proof`s, by aggregator and by + /// the bits seen for `(slot, root, subcommittee)`. + pub(crate) seen_sync_contributions: SeenSyncContributions, /// Permits for block and column stateful gossip checks in flight on /// blocking threads. pub(crate) gossip_validation_permits: Arc, @@ -1289,6 +1327,10 @@ pub struct P2PServer { /// [`Self::gossip_validation_permits`]; see /// [`ATTESTATION_VALIDATION_PERMITS`]. pub(crate) attestation_validation_permits: Arc, + /// Permits for sync committee message and contribution stateful gossip + /// checks in flight on blocking threads. Separate from the other two + /// pools; see [`SYNC_VALIDATION_PERMITS`]. + pub(crate) sync_validation_permits: Arc, /// Unaggregated attestations for this node's validator clients' /// aggregators, shared with the Beacon API that aggregates from it. Filled @@ -1304,9 +1346,6 @@ pub struct P2PServer { /// Accepted sync committee messages and contributions, shared with the /// Beacon API that serves and fills the same pool (block production reads /// it). Filled by `verdict::forward`; lean never touches it. - // Read once the sync committee gossip verdicts land; see - // `beacon::sync_committee`. - #[allow(dead_code)] pub(crate) sync_committee_pool: SharedSyncCommitteePool, /// The attestation subnets joined for a validator client's aggregators, @@ -1518,6 +1557,8 @@ impl P2PServer { ); gossipsub::leave_expired_aggregator_subnets(self); gossipsub::prune_attestation_pool(self); + beacon::sync_committee::leave_expired_sync_committee_subnets(self); + beacon::sync_committee::prune_sync_committee_pool(self); } #[send_handler] @@ -2812,6 +2853,18 @@ pub(crate) mod test_support { attestation_validation_permits: std::sync::Arc::new(tokio::sync::Semaphore::new( crate::ATTESTATION_VALIDATION_PERMITS, )), + seen_sync_messages: + ethlambda_state_transition::beacon::gossip::sync_committee::SeenSyncCommitteeMessages::new( + crate::SEEN_SYNC_MESSAGES_CAPACITY, + ), + seen_sync_contributions: + ethlambda_state_transition::beacon::gossip::sync_committee::SeenSyncContributions::new( + crate::SEEN_SYNC_AGGREGATORS_CAPACITY, + crate::SEEN_SYNC_DATA_CAPACITY, + ), + sync_validation_permits: std::sync::Arc::new(tokio::sync::Semaphore::new( + crate::SYNC_VALIDATION_PERMITS, + )), attestation_pool: Default::default(), payload_attestation_pool: Default::default(), sync_committee_pool: Default::default(), diff --git a/crates/net/p2p/src/req_resp/handlers.rs b/crates/net/p2p/src/req_resp/handlers.rs index 7ee43882..65972d8a 100644 --- a/crates/net/p2p/src/req_resp/handlers.rs +++ b/crates/net/p2p/src/req_resp/handlers.rs @@ -2788,6 +2788,18 @@ pub(crate) mod tests { attestation_validation_permits: std::sync::Arc::new(tokio::sync::Semaphore::new( crate::ATTESTATION_VALIDATION_PERMITS, )), + seen_sync_messages: + ethlambda_state_transition::beacon::gossip::sync_committee::SeenSyncCommitteeMessages::new( + crate::SEEN_SYNC_MESSAGES_CAPACITY, + ), + seen_sync_contributions: + ethlambda_state_transition::beacon::gossip::sync_committee::SeenSyncContributions::new( + crate::SEEN_SYNC_AGGREGATORS_CAPACITY, + crate::SEEN_SYNC_DATA_CAPACITY, + ), + sync_validation_permits: std::sync::Arc::new(tokio::sync::Semaphore::new( + crate::SYNC_VALIDATION_PERMITS, + )), attestation_pool: Default::default(), payload_attestation_pool: Default::default(), sync_committee_pool: Default::default(), diff --git a/docs/beacon_wire.md b/docs/beacon_wire.md index c7cb6e8f..50370ed0 100644 --- a/docs/beacon_wire.md +++ b/docs/beacon_wire.md @@ -103,6 +103,7 @@ since it is valid only within its own slot. | `proposer_slashing` | `ProposerSlashing` | | `bls_to_execution_change` | `SignedBLSToExecutionChange` | | `sync_committee_contribution_and_proof` | `SignedContributionAndProof` | +| `sync_committee_{subnet_id}` | `SyncCommitteeMessage` (joined on request only) | | `beacon_attestation_{subnet_id}` | `Attestation`, phase0 or electra's `SingleAttestation` (gloas keeps the latter) | | `execution_payload` | `SignedExecutionPayloadEnvelope`, gloas digests only | | `payload_attestation_message` | `PayloadAttestationMessage`, gloas digests only | @@ -126,8 +127,9 @@ One deliberate shortfall: the set is computed once at startup and kept for the process's lifetime rather than rotating every `EPOCHS_PER_SUBNET_SUBSCRIPTION` epochs. Lighthouse does the same, and reads that constant nowhere. -`sync_committee_{0..3}` stays unsubscribed and arrives with the work that reads -it. `blob_sidecar_{subnet_id}` stays absent permanently: it is deneb's format +`sync_committee_{0..3}` is not in the startup set either: those subnets are +joined on demand, see [Sync committee subnets](#sync-committee-subnets). +`blob_sidecar_{subnet_id}` stays absent permanently: it is deneb's format for blobs, deprecated at fulu in favor of the column matrix below. `data_column_sidecar_{0..127}` is no longer in that absent list. This node @@ -290,6 +292,26 @@ lighthouse follower with no validators, which verifies and relays its own backbone subnets while `should_process_attestation` keeps them out of its fork choice. +## Sync committee subnets + +Sync committee gossip is joined on demand, never at startup. A validator +client's `POST /eth/v1/validator/sync_committee_subscriptions` reaches +`P2PServer` as `subscribe_sync_committee_subnets`, which subscribes each +subnet (`sync_committee_{0..3}`) immediately under every digest the node holds, +until the request's `until_epoch` (exclusive). Validating all four subnets +permanently would cost every follower up to a committee's worth of BLS +verifications per slot, so nothing is joined without a request. + +| Piece | Behaviour | +| --- | --- | +| State | `BeaconWire::sync_committee_subnets`: subnet to `until_epoch`, extended with the later of two requests; ids of 4 or more are dropped | +| Advertised | MetaData v2 and v3 `syncnets` is built from that set, and the sequence number moves whenever the set does. The ENR carries no `syncnets` entry: ethrex's `DiscoveryServer` cannot replace the served record at runtime, the same known gap as the `eth2` entry | +| Left | the 12 s sweep leaves a subnet once the wall epoch reaches its `until_epoch`, and prunes the sync committee pool | +| Fork boundaries | `beacon::transition::apply` subscribes the set under each digest it joins and unsubscribes it under each it leaves, like the aggregator attestation subnets | +| Validation | the cheap half runs inline, the stateful half on `spawn_blocking` under `SYNC_VALIDATION_PERMITS`, a pool of its own since the burst coincides with the attestations'. An accepted message is pooled with the seats its signature verified; neither it nor a contribution reaches the chain actor | +| Contributions | `sync_committee_contribution_and_proof` is always subscribed, validated, relayed and pooled for block production | +| Publishing | the Beacon API pools its own submissions first, then publishes on each subnet its validator holds a seat in, under the digest the message's slot names, and marks them seen so a peer's echo is ignored | + ## Request/response | Protocol | Direction | @@ -617,6 +639,9 @@ Two of these advertise less, or more, than they look like: for silence there, and claiming none while serving two loses the peers looking for precisely that. It used to be all-unset, which was honest while this node held no subscription at all. +- `syncnets` names the sync committee subnets joined on a validator client's + request, so it is all-unset until one asks. It is advertised in MetaData + only; the ENR has no `syncnets` entry. - `cgc` advertises `CUSTODY_REQUIREMENT`, the floor below which peers may reject a record outright, not `sampling_size(CUSTODY_REQUIREMENT)`, the larger number of columns this node actually custodies, stores and serves @@ -692,18 +717,17 @@ Derived the mainnet wire parameters genesis_time=1606824023 genesis_validators_ No fork or blob-schedule boundary is scheduled Custodying data columns columns=[…] Backboning attestation subnets subnets_per_node=2 subnets=[…] -Advertising cgc=4 while subscribing to no sync committee subnet, and publishing nothing +Advertising cgc=4; sync committee subnets are joined only on a validator client's request Beacon P2P node started socket=0.0.0.0:9001 fork_digest=8c9f62fe topics=17 columns=8 attestation_subnets=[…] HTTP server listening addr=127.0.0.1:5054 Starting discv5 discovery discovery_addr=0.0.0.0:9002 seeds=17 total_bootnodes=17 Local ENR enr=enr:-… ``` -The `Advertising cgc=…` line names what is still true: this node subscribes to -no sync-committee subnet and publishes nothing of its own. Storing and serving -the columns it custodies (see [Data column -sidecars](#data-column-sidecars)) is no longer part of that gap, and neither is -the attestation subnet backbone. +The `Advertising cgc=…` line names what is true of sync committees: this node +subscribes to no sync-committee subnet at startup, and joins one only when a +validator client asks (see [Sync committee +subnets](#sync-committee-subnets)). `seeds=17` proves the built-in list parsed; a lower number means a bootnode ENR was skipped with a warning. `topics=17` proves the subscription set: the 7 From 8d621a7c0ba313c3fd29bf5bea87041f69094237 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:28:42 -0300 Subject: [PATCH 10/11] test(rpc): drive the sync committee validator client against the real router The unit tests check each sync committee endpoint and the client's service against a mock; neither shows the two ends agree on the wire. These tests run the real HttpBeaconNode and SyncCommitteeService against the real router over a socket: duties, subscriptions, messages (gossiped on exactly their seats' subnets), contributions that verify, and then the next slot's block, whose sync aggregate must be full and pass process_sync_aggregate. One test per fork family (fulu and gloas). The fulu harness pins the clock and roots the store's head at the state's header root, since a committee signs the head and the block builder checks the aggregate against the root the state's block_roots hold. Also documents the sync committee wire and validator duties in CLAUDE.md. --- CLAUDE.md | 18 +- .../rpc/src/beacon/validator_client_tests.rs | 359 ++++++++++++++++-- crates/net/rpc/src/lib.rs | 2 +- 3 files changed, 351 insertions(+), 28 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0a8186d0..b66301f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -334,7 +334,13 @@ actual_slot = finalized_slot + 1 + relative_index - Beacon wire: `validate_messages()` is on, so every beacon message waits for a verdict (~4.2s before gossipsub's cache evicts it). Rules in `state_transition::beacon::gossip` (cheap half inline, stateful half on a bounded `spawn_blocking` task); plumbing in `p2p/src/beacon/verdict.rs`. Lean gossip still auto-forwards - Data columns: every check runs in p2p. A column gossip did not accept (`Queue`/`Overloaded`), every fetched column, and parked columns replayed after their parent imports go through `column::chain_checks` in `p2p/src/beacon/column_checks.rs`. The chain actor stores what it gets unchecked; only debug builds re-run `chain_checks` there - Beacon subscribes seven global topics plus two node-id-derived subnet families: custody - columns and backbone attestation subnets. Gloas digests add two more topics, + columns and backbone attestation subnets. One of the seven is + `sync_committee_contribution_and_proof`; the four `sync_committee_{0..3}` subnets are + joined on demand only (a validator client's `sync_committee_subscriptions`, held + until its `until_epoch`, under every held digest) and advertised in MetaData + `syncnets`, never the ENR. Messages and contributions validate in p2p on their own + permit pool (`gossip::sync_committee`, `p2p/src/beacon/sync_committee.rs`), and + accepted ones go into the shared `SyncCommitteePool`. Gloas digests add two more topics, `execution_payload` and `payload_attestation_message` (`BeaconTopics::for_fork`; earlier digests never carry them). Gloas has its own rules for `beacon_block`, `data_column_sidecar` (fork enum `DataColumnSidecar`, fork from the topic's digest), @@ -803,7 +809,15 @@ transitions are in `ethlambda-types`, per the section above. Nothing above `Eth-Blob-Data-Included: true`, both inside the proposal's attester-offset budget; an envelope failure is logged and counted, not a failed proposal), attest at `ATTESTATION_DUE_BPS_GLOAS`, aggregate at `AGGREGATE_DUE_BPS_GLOAS`, - then the PTC vote at `PAYLOAD_ATTESTATION_DUE_BPS`. `SlotClock` picks the + then the PTC vote at `PAYLOAD_ATTESTATION_DUE_BPS`. Sync committee duties + (`SyncCommitteeService`, `crates/validator/src/sync_committee.rs`) run at every fork: + one message per validator over the head root at `SYNC_MESSAGE_DUE_BPS(_GLOAS)` + (refused while the head is optimistic), then contributions for each selected + (validator, subnet) pair at `CONTRIBUTION_DUE_BPS(_GLOAS)`, with subscriptions + re-sent every epoch for the current and next period. The node pools the messages + and a block at slot N packs only `(N-1, parent_root)`, replaced by the empty + aggregate when `verified_sync_aggregate` fails (see `docs/spec_deviations.md`). + `SlotClock` picks the offsets by each slot's own fork, and `/eth/v1/config/spec` supplies them. It still keeps no slashing-protection record; PTC votes have an in-memory `(validator, slot)` dedup only, and envelopes are unguarded. diff --git a/crates/net/rpc/src/beacon/validator_client_tests.rs b/crates/net/rpc/src/beacon/validator_client_tests.rs index e30034a3..b0a6da14 100644 --- a/crates/net/rpc/src/beacon/validator_client_tests.rs +++ b/crates/net/rpc/src/beacon/validator_client_tests.rs @@ -21,6 +21,7 @@ use ethlambda_state_transition::beacon::payload_attestation_pool::SharedPayloadA use ethlambda_state_transition::beacon::{ attestation_pool::SharedAttestationPool, sync_committee_pool::SharedSyncCommitteePool, }; +use ethlambda_state_transition::beacon::{bls, helpers::test_state::secret_key_for}; use ethlambda_types::{ beacon::{ constants::DOMAIN_BEACON_ATTESTER, @@ -38,13 +39,17 @@ use ethlambda_validator::{ AggregateKind, BeaconNodeApi, BlockRequest, SignedAggregates, dto::{ AttestationDataOutDto, CommitteeSubscriptionDto, ProposerPreparationDto, - SingleAttestationDto, encode_hex, + SingleAttestationDto, SyncCommitteeSubscriptionDto, encode_hex, }, http::HttpBeaconNode, }, + keys::ValidatorStore, + signing::SigningContext, + sync_committee::{SyncCommitteeService, subnets_of}, }; use ethlambda_storage::Store; +use tokio::sync::RwLock; use crate::test_utils::{RecordingNetwork, beacon_store_at}; @@ -124,6 +129,22 @@ async fn spawn_server( (client, network, payload_pool) } +/// A key store holding the test registry's secret for each of `indices`. +fn keys_for(state: &BeaconState, indices: &[u64]) -> RwLock { + let mut keys = ValidatorStore::new(); + for &index in indices { + let pubkey = keys + .insert_secret("test", &secret_key_for(index as usize).to_bytes()) + .expect("the test secret is a valid key"); + assert_eq!( + pubkey, + state.validator(index).unwrap().pubkey, + "the test registry's key for validator {index}" + ); + } + RwLock::new(keys) +} + /// One slot of an attester's work, in the order `ethlambda validator` does it. #[tokio::test] async fn the_validator_client_can_attest_through_this_node() { @@ -438,6 +459,273 @@ async fn the_validator_client_can_propose_through_this_node() { assert_eq!(network.blocks.lock().unwrap().len(), 1); } +// --------------------------------------------------------------------------- +// Sync committee +// --------------------------------------------------------------------------- + +/// A fulu head state at the first slot of epoch 2, its header's root the one +/// the store's head is under, served on a clock pinned to that slot. +/// +/// Unlike [`serve`] the head root is the root a block built on this state +/// names as its parent, which is the root `process_sync_aggregate` reads back +/// out of the state's `block_roots`: a committee signs over the head, so the two +/// must be the same root for the pooled aggregate to survive +/// `verified_sync_aggregate`. The schedule has fulu from epoch 0 so the state's +/// own `fork` and the client's schedule give one domain. +async fn serve_fulu_pinned( + engine: Option, +) -> ( + Arc, + BeaconState, + Arc, + Arc, +) { + use ethlambda_types::beacon::containers::shared::Fork; + + let config = fulu_schedule(); + let mut state = with_signing_validators_at(ForkName::Fulu, COUNT); + let slot = compute_start_slot_at_epoch(2); + { + let BeaconState::Fulu(fulu) = &mut state else { + unreachable!("built as fulu") + }; + fulu.slot = slot; + fulu.latest_block_header.slot = slot; + fulu.fork = Fork { + previous_version: config.fork_version(ForkName::Electra), + current_version: config.fork_version(ForkName::Fulu), + epoch: config.fulu_fork_epoch, + }; + } + let lookahead = initialize_proposer_lookahead(&state).unwrap(); + let sync_committee = + ethlambda_state_transition::beacon::helpers::altair::get_next_sync_committee(&state) + .unwrap(); + let BeaconState::Fulu(fulu) = &mut state else { + unreachable!("built as fulu") + }; + fulu.proposer_lookahead = lookahead.try_into().unwrap(); + fulu.current_sync_committee = sync_committee.clone(); + fulu.next_sync_committee = sync_committee; + + // The root slot processing gives the header: its state root filled in. + let mut header = state.latest_block_header().clone(); + if header.state_root == Default::default() { + header.state_root = state.hash_tree_root(); + } + let head_root = header.hash_tree_root(); + let block = crate::test_utils::phase0_beacon_block(slot, Default::default()); + let store = crate::test_utils::beacon_store_with_head_block( + state.clone(), + config, + block, + head_root, + slot, + ); + let (client, network, _) = spawn_server(store, engine).await; + let genesis = client.genesis().await.expect("genesis"); + let spec = client + .spec() + .await + .expect("the client reads this node's spec"); + let context = Arc::new(SigningContext { + config: spec, + genesis_validators_root: genesis.genesis_validators_root, + }); + (Arc::new(client), state, network, context) +} + +fn fulu_schedule() -> ethlambda_types::beacon::config::Config { + ethlambda_types::beacon::config::Config::mainnet().with_fork_epoch(ForkName::Fulu, 0) +} + +/// One slot of sync committee work for every validator of the test registry, +/// through the client's own service against the node: duties, subscriptions, +/// messages, then the contributions of whoever the slot selected. Checks what +/// the node published and returns the root the committee signed over. +async fn sync_committee_slot( + client: &Arc, + state: &BeaconState, + network: &RecordingNetwork, + context: Arc, + slot: u64, +) -> ethlambda_types::primitives::H256 { + use ethlambda_types::beacon::constants::{DOMAIN_SYNC_COMMITTEE, SYNC_COMMITTEE_SUBNET_COUNT}; + use std::collections::{BTreeMap, BTreeSet}; + + let epoch = compute_epoch_at_slot(slot); + let indices: Vec = (0..COUNT as u64).collect(); + let duties = client.sync_duties(epoch, &indices).await.unwrap(); + assert!(!duties.is_empty(), "the registry holds the committee"); + let seats: BTreeSet = duties + .iter() + .flat_map(|duty| duty.validator_sync_committee_indices.iter().copied()) + .collect(); + assert_eq!( + seats, + (0..preset::SYNC_COMMITTEE_SIZE as u64).collect(), + "every seat belongs to a member of the registry" + ); + let all_subnets: BTreeSet = (0..SYNC_COMMITTEE_SUBNET_COUNT as u64).collect(); + + let period = preset::EPOCHS_PER_SYNC_COMMITTEE_PERIOD; + let until_epoch = (epoch / period + 1) * period; + let subscriptions: Vec = duties + .iter() + .map(|duty| SyncCommitteeSubscriptionDto { + validator_index: duty.validator_index, + sync_committee_indices: duty.validator_sync_committee_indices.clone(), + until_epoch, + }) + .collect(); + client + .subscribe_sync_committees(&subscriptions) + .await + .unwrap(); + let joined: BTreeSet = network + .sync_subscriptions + .lock() + .unwrap() + .iter() + .map(|(subnet, _)| *subnet) + .collect(); + assert_eq!( + joined, all_subnets, + "the node joined every subnet asked for" + ); + + let keys = keys_for(state, &indices); + let service = SyncCommitteeService::new(client.clone(), context); + let root = service + .publish_messages(slot, &duties, &keys) + .await + .unwrap() + .expect("a synced node's head is signable"); + + // One message per validator, gossiped on exactly the subnets of its seats, + // and signed under the state's own domain over the head. + let domain = get_domain(state, DOMAIN_SYNC_COMMITTEE, Some(epoch)); + let signing_root = compute_signing_root(root, domain); + let published = network.sync_messages.lock().unwrap().clone(); + assert_eq!(published.len(), duties.len()); + let expected: BTreeMap> = duties + .iter() + .map(|duty| (duty.validator_index, subnets_of(duty))) + .collect(); + let mut gossiped = BTreeSet::new(); + for (subnets, message) in &published { + assert_eq!(message.slot, slot); + assert_eq!(message.beacon_block_root, root); + let subnets: BTreeSet = subnets.iter().copied().collect(); + assert_eq!(subnets, expected[&message.validator_index]); + gossiped.extend(subnets); + let pubkey = state.validator(message.validator_index).unwrap().pubkey; + assert!(bls::verify(&pubkey, signing_root, &message.signature)); + } + assert_eq!(gossiped, all_subnets, "every subnet carried a message"); + + // The selected aggregators' contributions: each covers its whole + // subcommittee, since every member signed, and verifies against it. + let count = service.aggregate(slot, root, &duties, &keys).await.unwrap(); + assert!( + count > 0, + "someone is selected among 64 validators on 4 subnets" + ); + let contributions = network.sync_contributions.lock().unwrap().clone(); + assert_eq!(contributions.len(), count); + let (committee, _) = state.sync_committees().unwrap(); + let per_subnet = preset::SYNC_COMMITTEE_SIZE / SYNC_COMMITTEE_SUBNET_COUNT; + for signed in &contributions { + let contribution = &signed.message.contribution; + assert_eq!(contribution.slot, slot); + assert_eq!(contribution.beacon_block_root, root); + let first = contribution.subcommittee_index as usize * per_subnet; + let participants: Vec = (0..per_subnet) + .filter(|&bit| contribution.aggregation_bits.get(bit).unwrap_or(false)) + .map(|bit| committee.pubkeys[first + bit]) + .collect(); + assert_eq!(participants.len(), per_subnet, "every seat was pooled"); + assert!(bls::eth_fast_aggregate_verify( + &participants, + signing_root, + &contribution.signature + )); + } + root +} + +/// Every position set and the signature verifying, as `process_sync_aggregate` +/// holds a block's aggregate to it: run on the state the block builds on. +fn assert_full_sync_aggregate( + advanced: &BeaconState, + aggregate: ðlambda_types::beacon::containers::altair::SyncAggregate, +) { + let set = (0..preset::SYNC_COMMITTEE_SIZE) + .filter(|&position| aggregate.sync_committee_bits.get(position).unwrap_or(false)) + .count(); + assert_eq!( + set, + preset::SYNC_COMMITTEE_SIZE, + "every member signed the parent" + ); + let mut state = advanced.clone(); + ethlambda_state_transition::beacon::stf::altair::process_sync_aggregate(&mut state, aggregate) + .expect("the packed aggregate verifies as the state transition checks it"); +} + +/// A sync committee member's slot through this node: the duties and +/// subscriptions, the messages gossiped and pooled, the contributions of the +/// selected aggregators, and then the next slot's block carrying what the +/// committee signed. +#[tokio::test] +async fn the_validator_client_can_serve_on_the_sync_committee() { + use ethlambda_state_transition::beacon::{ + block_production::advance_to_slot, helpers::accessors::get_beacon_proposer_index, + }; + use ethlambda_types::beacon::{ + constants::{DOMAIN_BEACON_PROPOSER, DOMAIN_RANDAO}, + containers::SignedBeaconBlock, + }; + + let (client, state, network, context) = + serve_fulu_pinned(Some(fake_execution_client().await)).await; + let slot = state.slot(); + let root = sync_committee_slot(&client, &state, &network, context, slot).await; + + // The next slot's block packs what was pooled over the head it builds on. + let next = slot + 1; + let advanced = advance_to_slot(&state, next, &fulu_schedule()).unwrap(); + let proposer = get_beacon_proposer_index(&advanced).unwrap(); + let epoch = compute_epoch_at_slot(next); + let randao_domain = get_domain(&advanced, DOMAIN_RANDAO, Some(epoch)); + let randao_reveal = sign_for( + proposer as usize, + compute_signing_root(epoch.hash_tree_root(), randao_domain), + ); + let request = BlockRequest { + slot: next, + fork: ForkName::Fulu, + proposer_index: proposer, + randao_reveal, + graffiti: Default::default(), + }; + let produced = client.produce_block(&request).await.unwrap(); + let block_domain = get_domain(&advanced, DOMAIN_BEACON_PROPOSER, Some(epoch)); + let signature = sign_for( + proposer as usize, + compute_signing_root(produced.block_root(), block_domain), + ); + let body = produced.into_signed_ssz(signature); + client.publish_block(ForkName::Fulu, &body).await.unwrap(); + + let blocks = network.blocks.lock().unwrap().clone(); + let SignedBeaconBlock::Fulu(signed) = &blocks[0] else { + panic!("a fulu slot publishes a fulu block"); + }; + assert_eq!(signed.message.parent_root, root); + assert_full_sync_aggregate(&advanced, &signed.message.body.sync_aggregate); +} + // --------------------------------------------------------------------------- // Gloas // --------------------------------------------------------------------------- @@ -445,16 +733,16 @@ async fn the_validator_client_can_propose_through_this_node() { mod gloas { use std::time::Duration; + use super::*; + use crate::test_utils::{beacon_store_with_head_block, gloas_beacon_block}; use ethlambda_state_transition::beacon::{ block_production::advance_to_slot, - bls, constants::{DOMAIN_BEACON_BUILDER, DOMAIN_BEACON_PROPOSER, DOMAIN_PTC_ATTESTER}, fork_choice::PayloadStatus, gloas_block_production::test_support::{config, parent_state, post_state}, helpers::{ accessors::get_beacon_proposer_index, gloas::{compute_ptc, get_ptc}, - test_state::secret_key_for, }, }; use ethlambda_types::beacon::{ @@ -464,15 +752,9 @@ mod gloas { }; use ethlambda_validator::{ beacon_node::dto::{ProposerDutyDto, PtcDutyDto}, - keys::ValidatorStore, payload_attestation::PayloadAttestationService, proposal::ProposalService, - signing::SigningContext, }; - use tokio::sync::RwLock; - - use super::*; - use crate::test_utils::{beacon_store_with_head_block, gloas_beacon_block}; /// A gloas head state at slot 32 under [`config`] (gloas from epoch 0), /// with the proposer lookahead and sync committee a real registry gives it @@ -557,22 +839,6 @@ mod gloas { } } - /// A key store holding the test registry's secret for each of `indices`. - fn keys_for(state: &BeaconState, indices: &[u64]) -> RwLock { - let mut keys = ValidatorStore::new(); - for &index in indices { - let pubkey = keys - .insert_secret("test", &secret_key_for(index as usize).to_bytes()) - .expect("the test secret is a valid key"); - assert_eq!( - pubkey, - state.validator(index).unwrap().pubkey, - "the test registry's key for validator {index}" - ); - } - RwLock::new(keys) - } - /// One slot of a gloas attester's work: the data comes back without a /// committee index and names the payload signal, the gloas header goes out /// with the votes, and the aggregate is the gloas container. @@ -957,4 +1223,47 @@ mod gloas { } propose_through_the_service(Some(blob)).await; } + + /// The gloas counterpart: the same slot of sync committee work, and the + /// gloas block of the next slot carrying the aggregate. + #[tokio::test] + async fn the_validator_client_can_serve_on_the_sync_committee_at_a_gloas_slot() { + let engine = fake_execution_client().await; + let Served { + client, + state, + network, + store, + context, + .. + } = serve_gloas(32, Some(engine)).await; + let client = Arc::new(client); + let slot = state.slot(); + let root = sync_committee_slot(&client, &state, &network, context.clone(), slot).await; + + let next = slot + 1; + let advanced = advance_to_slot(&state, next, &config()).unwrap(); + let proposer = get_beacon_proposer_index(&advanced).unwrap(); + let keys = keys_for(&state, &[proposer]); + let pubkey = state.validator(proposer).unwrap().pubkey; + import_published_block(store, network.clone(), advanced.clone()); + + let service = ProposalService::new(client, context, Bytes32::repeat_byte(0xab), None); + let duty = ProposerDutyDto { + pubkey: encode_hex(&pubkey.0), + validator_index: proposer, + slot: next, + }; + service + .propose(next, &duty, &keys) + .await + .expect("the proposal goes through this node"); + + let blocks = network.blocks.lock().unwrap().clone(); + let SignedBeaconBlock::Gloas(signed) = &blocks[0] else { + panic!("a gloas slot publishes a gloas block"); + }; + assert_eq!(signed.message.parent_root, root); + assert_full_sync_aggregate(&advanced, &signed.message.body.sync_aggregate); + } } diff --git a/crates/net/rpc/src/lib.rs b/crates/net/rpc/src/lib.rs index 3899cef7..09211cfe 100644 --- a/crates/net/rpc/src/lib.rs +++ b/crates/net/rpc/src/lib.rs @@ -412,7 +412,7 @@ pub(crate) mod test_utils { } /// A phase0 block at `slot`, with a trivial (default) body. - fn phase0_beacon_block(slot: u64, parent_root: H256) -> SignedBeaconBlock { + pub(crate) fn phase0_beacon_block(slot: u64, parent_root: H256) -> SignedBeaconBlock { SignedBeaconBlock::Phase0(phase0::SignedBeaconBlock { message: phase0::BeaconBlock { slot, From f50aa242f145362a8d5ae6995f3d304e30a7860b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:37:24 -0300 Subject: [PATCH 11/11] test(state-transition): skip the unselected-aggregator case on minimal Minimal's sync subcommittee is smaller than TARGET_AGGREGATORS_PER_SYNC_SUBCOMMITTEE, so is_sync_committee_aggregator's modulo clamps to 1 and every selection proof selects. The test searched for an unselected proof and panicked when none existed, failing the minimal preset CI job. Assert that no such proof exists there instead, keeping the NotAggregator check strict on mainnet. --- .../src/beacon/gossip/sync_committee.rs | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/crates/blockchain/state_transition/src/beacon/gossip/sync_committee.rs b/crates/blockchain/state_transition/src/beacon/gossip/sync_committee.rs index d532eede..0481c768 100644 --- a/crates/blockchain/state_transition/src/beacon/gossip/sync_committee.rs +++ b/crates/blockchain/state_transition/src/beacon/gossip/sync_committee.rs @@ -714,16 +714,22 @@ mod tests { contribution_cheap_checks(&seen, &store, &bad, now), Err(Outcome::Reject(RejectReason::NoParticipants)) ); - // Not selected. - let mut bad = good.clone(); - bad.message.selection_proof = (0u8..=255) + // Not selected. Minimal's subcommittee is smaller than + // `TARGET_AGGREGATORS_PER_SYNC_SUBCOMMITTEE`, so the modulo clamps to 1 + // and every proof selects: the rule cannot fire there. + let unselected = (0u8..=255) .map(|byte| BlsSignature([byte; 96])) - .find(|proof| !is_sync_committee_aggregator(proof)) - .expect("an unselected value exists"); - assert_eq!( - contribution_cheap_checks(&seen, &store, &bad, now), - Err(Outcome::Reject(RejectReason::NotAggregator)) - ); + .find(|proof| !is_sync_committee_aggregator(proof)); + if cfg!(feature = "preset-minimal") { + assert!(unselected.is_none()); + } else { + let mut bad = good.clone(); + bad.message.selection_proof = unselected.expect("an unselected value exists"); + assert_eq!( + contribution_cheap_checks(&seen, &store, &bad, now), + Err(Outcome::Reject(RejectReason::NotAggregator)) + ); + } // Seen: the same aggregator, then a covered subset from another. assert!(seen.record(&good));