diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 728a09a475a..1e3a792b32c 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -91,7 +91,7 @@ use crate::{ use bls::{PublicKey, PublicKeyBytes, Signature}; use eth2::beacon_response::ForkVersionedResponse; use eth2::types::{ - EventKind, PtcDuty, SseBlobSidecar, SseBlock, SseDataColumnSidecar, + EventKind, InclusionListDuty, PtcDuty, SseBlobSidecar, SseBlock, SseDataColumnSidecar, SseExtendedPayloadAttributes, SseHead, SseHeadV2, }; use execution_layer::{ @@ -1759,6 +1759,49 @@ impl BeaconChain { Ok((duties, dependent_root)) } + /// Get inclusion list committee duties for validators at a given epoch ([New in Heze:EIP7805]). + pub fn compute_inclusion_list_duties( + &self, + state: &BeaconState, + epoch: Epoch, + validator_indices: &[u64], + dependent_block_root: Hash256, + ) -> Result<(Vec>, Hash256), Error> { + let relative_epoch = RelativeEpoch::from_epoch(state.current_epoch(), epoch) + .map_err(Error::IncorrectStateForAttestation)?; + + let dependent_root = + state.attester_shuffling_decision_root(dependent_block_root, relative_epoch)?; + + let mut assignments: HashMap = HashMap::new(); + for slot in epoch.slot_iter(T::EthSpec::slots_per_epoch()) { + let committee = state.get_inclusion_list_committee(slot)?; + for validator_index in &committee { + assignments.entry(*validator_index).or_insert(slot); + } + } + + let pubkey_cache = self.validator_pubkey_cache.read(); + + let duties = validator_indices + .iter() + .map(|&validator_index| { + let Some(&pubkey) = pubkey_cache.get_pubkey_bytes(validator_index as usize) else { + return None; + }; + assignments + .get(&validator_index) + .map(|&slot| InclusionListDuty { + pubkey, + validator_index, + slot, + }) + }) + .collect::>(); + + Ok((duties, dependent_root)) + } + pub fn get_aggregated_attestation( &self, attestation: AttestationRef, diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs index 655db71ea18..49e1778e616 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -30,13 +30,13 @@ use tree_hash::TreeHash; use types::consts::gloas::BUILDER_INDEX_SELF_BUILD; use types::{ Address, Attestation, AttestationGloas, AttesterSlashing, AttesterSlashingGloas, BeaconBlock, - BeaconBlockBodyGloas, BeaconBlockGloas, BeaconState, BeaconStateError, BlobsList, BuilderIndex, - ChainSpec, Deposit, Eth1Data, EthSpec, ExecutionBlockHash, ExecutionPayloadBid, - ExecutionPayloadEnvelope, ExecutionPayloadGloas, ExecutionRequestsGloas, FullPayload, Graffiti, - Hash256, IndexedAttestation, KzgProofs, PayloadAttestation, ProposerSlashing, RelativeEpoch, - SignedBeaconBlock, SignedBlsToExecutionChange, SignedExecutionPayloadBid, - SignedExecutionPayloadEnvelope, SignedVoluntaryExit, Slot, SyncAggregate, Uint256, Withdrawal, - Withdrawals, + BeaconBlockBodyGloas, BeaconBlockBodyHeze, BeaconBlockGloas, BeaconBlockHeze, BeaconState, + BeaconStateError, BlobsList, BuilderIndex, ChainSpec, Deposit, Eth1Data, EthSpec, + ExecutionBlockHash, ExecutionPayloadBid, ExecutionPayloadEnvelope, ExecutionPayloadGloas, + ExecutionRequestsGloas, FullPayload, Graffiti, Hash256, IndexedAttestation, KzgProofs, + PayloadAttestation, ProposerSlashing, RelativeEpoch, SignedBeaconBlock, + SignedBlsToExecutionChange, SignedExecutionPayloadBid, SignedExecutionPayloadEnvelope, + SignedVoluntaryExit, Slot, SyncAggregate, Uint256, Withdrawal, Withdrawals, }; use crate::payload_bid_verification::payload_bid_cache::BidParent; @@ -650,13 +650,31 @@ impl BeaconChain { _phantom: PhantomData::>, }, }), - // TODO(heze): construct a `BeaconBlockHeze` here once Heze block production is - // wired up end-to-end (get_payload, envelope handling, etc). - BeaconState::Heze(_) => { - return Err(BlockProductionError::InvalidBlockVariant( - "Block production disabled for Heze".to_owned(), - )); - } + BeaconState::Heze(_) => BeaconBlock::Heze(BeaconBlockHeze { + slot, + proposer_index, + parent_root, + state_root: Hash256::ZERO, + body: BeaconBlockBodyHeze { + randao_reveal, + eth1_data, + graffiti, + // The operation list lengths are bounded by the op pool packing limits above. + proposer_slashings: ProgressiveVariableList::from_iter(proposer_slashings), + attester_slashings: ProgressiveVariableList::from_iter(attester_slashings), + attestations: ProgressiveVariableList::from_iter(attestations), + deposits: ProgressiveVariableList::from_iter(deposits), + voluntary_exits: ProgressiveVariableList::from_iter(voluntary_exits), + sync_aggregate, + bls_to_execution_changes: ProgressiveVariableList::from_iter( + bls_to_execution_changes, + ), + parent_execution_requests, + signed_execution_payload_bid, + payload_attestations: ProgressiveVariableList::from_iter(payload_attestations), + _phantom: PhantomData::>, + }, + }), }; let signed_beacon_block = SignedBeaconBlock::from_block( diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index dfee0160eb8..f1285fa161d 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -1933,6 +1933,58 @@ async fn proposer_lookahead_excludes_slashed_proposer_only_after_first_two_gloas ); } +/// Ensure the harness can produce and import a chain across the Heze fork boundary +#[tokio::test] +async fn heze_block_production_across_boundary() { + let gloas_fork_epoch = Epoch::new(1); + let heze_fork_epoch = Epoch::new(2); + let mut spec = ForkName::Fulu.make_genesis_spec(E::default_spec()); + spec.gloas_fork_epoch = Some(gloas_fork_epoch); + spec.heze_fork_epoch = Some(heze_fork_epoch); + + let db_path = tempdir().unwrap(); + let store = get_store_generic(&db_path, Default::default(), spec.clone()); + let validators_keypairs = + types::test_utils::generate_deterministic_keypairs(LOW_VALIDATOR_COUNT); + let harness = TestHarness::builder(E::default()) + .spec(spec.into()) + .keypairs(validators_keypairs) + .fresh_disk_store(store) + .mock_execution_layer() + .build(); + let all_validators = harness.get_all_validators(); + + // Build through the first Heze epoch, ending at the first slot of the next epoch + let last_slot = (heze_fork_epoch + 1).start_slot(E::slots_per_epoch()); + let slots: Vec = (1..=last_slot.as_u64()).map(Into::into).collect(); + let state = harness.get_current_state(); + let (_, _, head_block_root, head_state) = harness + .add_attested_blocks_at_slots(state, &slots, &all_validators) + .await; + + assert_eq!(head_state.current_epoch(), heze_fork_epoch + 1); + + let head_block_root: Hash256 = head_block_root.into(); + let head_block = harness + .chain + .store + .get_blinded_block(&head_block_root) + .unwrap() + .expect("head block should be stored"); + assert!( + matches!(head_block, SignedBeaconBlock::Heze(_)), + "the head block should be a Heze block" + ); + assert!( + harness + .chain + .get_payload_envelope(&head_block_root) + .unwrap() + .is_some(), + "the Heze block's execution payload envelope should be stored" + ); +} + // Ensure blocks from abandoned forks are pruned from the Hot DB #[tokio::test] async fn prunes_abandoned_fork_between_two_finalized_checkpoints() { diff --git a/beacon_node/execution_layer/src/engine_api/http.rs b/beacon_node/execution_layer/src/engine_api/http.rs index 23510e1b0ee..5235a57a4f2 100644 --- a/beacon_node/execution_layer/src/engine_api/http.rs +++ b/beacon_node/execution_layer/src/engine_api/http.rs @@ -1061,7 +1061,9 @@ impl HttpJsonRpc { let params = json!([JsonPayloadIdRequest::from(payload_id)]); match fork_name { - ForkName::Gloas => { + // TODO(heze): deliberately reusing the Gloas response containers while the Heze + // payload is identical to the Gloas one. Switch to the Heze types if it diverges + ForkName::Gloas | ForkName::Heze => { let response: JsonGetPayloadResponseGloas = self .rpc_request( ENGINE_GET_PAYLOAD_V6, @@ -1073,7 +1075,6 @@ impl HttpJsonRpc { .try_into() .map_err(Error::BadResponse) } - // TODO(heze): add a Heze arm once Heze payload retrieval is implemented. _ => Err(Error::UnsupportedForkVariant(format!( "called get_payload_v6 with {}", fork_name @@ -1444,18 +1445,13 @@ impl HttpJsonRpc { Err(Error::RequiredMethodUnsupported("engine_getPayloadv5")) } } - ForkName::Gloas => { + ForkName::Gloas | ForkName::Heze => { if engine_capabilities.get_payload_v6 { self.get_payload_v6(fork_name, payload_id).await } else { Err(Error::RequiredMethodUnsupported("engine_getPayloadV6")) } } - // TODO(heze): implement the Heze getPayload path once the engine API for Heze - // is specified. - ForkName::Heze => Err(Error::UnsupportedForkVariant( - "getPayload not implemented for Heze".to_string(), - )), ForkName::Base | ForkName::Altair => Err(Error::UnsupportedForkVariant(format!( "called get_payload with {}", fork_name diff --git a/beacon_node/execution_layer/src/lib.rs b/beacon_node/execution_layer/src/lib.rs index 5c94a5fd65a..d4e8b0e0b1e 100644 --- a/beacon_node/execution_layer/src/lib.rs +++ b/beacon_node/execution_layer/src/lib.rs @@ -225,8 +225,6 @@ impl From> for BlockProposalContentsGloas } } -// TODO(heze): add a `BlockProposalContentsHeze` here once Heze block production is wired up. - pub enum BlockProposalContents> { Payload { payload: Payload, @@ -952,8 +950,6 @@ impl ExecutionLayer { Ok(payload_response.into()) } - // TODO(heze): add a `get_payload_heze` here once Heze block production is wired up. - /// Maps to the `engine_getPayload` JSON-RPC call. /// /// However, it will attempt to call `self.prepare_payload` if it cannot find an existing diff --git a/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs b/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs index 1fcff6806f2..f2ffc068870 100644 --- a/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs +++ b/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs @@ -850,6 +850,13 @@ impl ExecutionBlockGenerator { .push(ProgressiveVariableList::::new(tx.into())); } } + ExecutionPayload::Heze(payload) => { + for tx in Vec::from(transactions) { + payload + .transactions + .push(ProgressiveVariableList::::new(tx.into())); + } + } _ => { for tx in Vec::from(transactions) { execution_payload diff --git a/beacon_node/http_api/src/inclusion_list_duties.rs b/beacon_node/http_api/src/inclusion_list_duties.rs new file mode 100644 index 00000000000..73a7f75949d --- /dev/null +++ b/beacon_node/http_api/src/inclusion_list_duties.rs @@ -0,0 +1,188 @@ +//! Contains the handler for the `POST validator/duties/inclusion_list/{epoch}` endpoint. + +use crate::state_id::StateId; +use beacon_chain::{BeaconChain, BeaconChainError, BeaconChainTypes}; +use eth2::types::{self as api_types, InclusionListDuty}; +use slot_clock::SlotClock; +use state_processing::state_advance::partial_state_advance; +use types::{BeaconState, ChainSpec, Epoch, EthSpec, Hash256}; + +type ApiDuties = api_types::DutiesResponse>; + +pub fn inclusion_list_duties( + request_epoch: Epoch, + request_indices: &[u64], + chain: &BeaconChain, +) -> Result { + if request_indices.is_empty() { + return Err(warp_utils::reject::custom_bad_request( + "at least one validator index must be provided".to_string(), + )); + } + + let current_epoch = chain + .slot_clock + .now_or_genesis() + .map(|slot| slot.epoch(T::EthSpec::slots_per_epoch())) + .ok_or(BeaconChainError::UnableToReadSlot) + .map_err(warp_utils::reject::unhandled_error)?; + + let tolerant_current_epoch = if chain.slot_clock.is_prior_to_genesis().unwrap_or(true) { + current_epoch + } else { + chain + .slot_clock + .now_with_future_tolerance(chain.spec.maximum_gossip_clock_disparity()) + .ok_or_else(|| { + warp_utils::reject::custom_server_error("unable to read slot clock".into()) + })? + .epoch(T::EthSpec::slots_per_epoch()) + }; + + let is_within_clock_tolerance = request_epoch == current_epoch + || request_epoch == current_epoch + 1 + || request_epoch == tolerant_current_epoch + 1; + + if is_within_clock_tolerance { + let head_epoch = chain + .canonical_head + .cached_head() + .snapshot + .beacon_state + .current_epoch(); + + let head_can_serve_request = request_epoch == head_epoch || request_epoch == head_epoch + 1; + + if head_can_serve_request { + compute_inclusion_list_duties_from_cached_head(request_epoch, request_indices, chain) + } else { + compute_inclusion_list_duties_from_state(request_epoch, request_indices, chain) + } + } else if request_epoch > current_epoch + 1 { + Err(warp_utils::reject::custom_bad_request(format!( + "request epoch {} is more than one epoch past the current epoch {}", + request_epoch, current_epoch + ))) + } else { + compute_inclusion_list_duties_from_state(request_epoch, request_indices, chain) + } +} + +fn compute_inclusion_list_duties_from_cached_head( + request_epoch: Epoch, + request_indices: &[u64], + chain: &BeaconChain, +) -> Result { + let (cached_head, execution_status) = chain + .canonical_head + .head_and_execution_status() + .map_err(warp_utils::reject::unhandled_error)?; + let state = &cached_head.snapshot.beacon_state; + let head_block_root = cached_head.head_block_root(); + + let (duties, dependent_root) = chain + .compute_inclusion_list_duties(state, request_epoch, request_indices, head_block_root) + .map_err(warp_utils::reject::unhandled_error)?; + + convert_to_api_response( + duties, + dependent_root, + execution_status.is_optimistic_or_invalid(), + ) +} + +fn compute_inclusion_list_duties_from_state( + request_epoch: Epoch, + request_indices: &[u64], + chain: &BeaconChain, +) -> Result { + let state_opt = { + let (cached_head, execution_status) = chain + .canonical_head + .head_and_execution_status() + .map_err(warp_utils::reject::unhandled_error)?; + let head = &cached_head.snapshot; + + if head.beacon_state.current_epoch() <= request_epoch { + Some(( + head.beacon_state_root(), + head.beacon_state.clone(), + execution_status.is_optimistic_or_invalid(), + )) + } else { + None + } + }; + + let (state, execution_optimistic) = + if let Some((state_root, mut state, execution_optimistic)) = state_opt { + ensure_state_can_determine_inclusion_list_duties( + &mut state, + state_root, + request_epoch, + &chain.spec, + )?; + (state, execution_optimistic) + } else { + let (state, execution_optimistic, _finalized) = + StateId::from_slot(request_epoch.start_slot(T::EthSpec::slots_per_epoch())) + .state(chain)?; + (state, execution_optimistic) + }; + + if !(state.current_epoch() == request_epoch || state.current_epoch() + 1 == request_epoch) { + return Err(warp_utils::reject::custom_server_error(format!( + "state epoch {} not suitable for request epoch {}", + state.current_epoch(), + request_epoch + ))); + } + + let (duties, dependent_root) = chain + .compute_inclusion_list_duties( + &state, + request_epoch, + request_indices, + chain.genesis_block_root, + ) + .map_err(warp_utils::reject::unhandled_error)?; + + convert_to_api_response(duties, dependent_root, execution_optimistic) +} + +fn ensure_state_can_determine_inclusion_list_duties( + state: &mut BeaconState, + state_root: Hash256, + target_epoch: Epoch, + spec: &ChainSpec, +) -> Result<(), warp::reject::Rejection> { + if state.current_epoch() > target_epoch { + return Err(warp_utils::reject::custom_server_error(format!( + "state epoch {} is later than target epoch {}", + state.current_epoch(), + target_epoch + ))); + } else if state.current_epoch() + 1 < target_epoch { + let target_slot = target_epoch + .saturating_sub(1_u64) + .start_slot(E::slots_per_epoch()); + + partial_state_advance(state, Some(state_root), target_slot, spec) + .map_err(BeaconChainError::from) + .map_err(warp_utils::reject::unhandled_error)?; + } + + Ok(()) +} + +fn convert_to_api_response( + duties: Vec>, + dependent_root: Hash256, + execution_optimistic: bool, +) -> Result { + Ok(api_types::DutiesResponse { + dependent_root, + execution_optimistic: Some(execution_optimistic), + data: duties.into_iter().flatten().collect(), + }) +} diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index 630e9d92118..ff128d08479 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -16,6 +16,7 @@ mod builders; mod caches; mod custody; mod database; +mod inclusion_list_duties; mod light_client; mod metrics; mod peer; @@ -2625,6 +2626,14 @@ pub async fn serve( task_spawner_filter.clone(), ); + // POST validator/duties/inclusion_list/{epoch} + let post_validator_duties_inclusion_list = post_validator_duties_inclusion_list( + eth_v1.clone(), + chain_filter.clone(), + not_while_syncing_filter.clone(), + task_spawner_filter.clone(), + ); + // POST validator/duties/sync/{epoch} let post_validator_duties_sync = post_validator_duties_sync( eth_v1.clone(), @@ -3488,6 +3497,7 @@ pub async fn serve( .uor(post_beacon_rewards_sync_committee) .uor(post_validator_duties_attester) .uor(post_validator_duties_ptc) + .uor(post_validator_duties_inclusion_list) .uor(post_validator_duties_sync) .uor(post_validator_aggregate_and_proofs) .uor(post_validator_contribution_and_proofs) diff --git a/beacon_node/http_api/src/validator/mod.rs b/beacon_node/http_api/src/validator/mod.rs index 7b904260b8e..97b80f65c81 100644 --- a/beacon_node/http_api/src/validator/mod.rs +++ b/beacon_node/http_api/src/validator/mod.rs @@ -7,7 +7,9 @@ use crate::utils::{ ResponseFilter, TaskSpawnerFilter, ValidatorSubscriptionTxFilter, publish_network_message, }; use crate::version::{V1, V2, V3, V4, add_ssz_content_type_header, unsupported_version_rejection}; -use crate::{StateId, attester_duties, proposer_duties, ptc_duties, sync_committees}; +use crate::{ + StateId, attester_duties, inclusion_list_duties, proposer_duties, ptc_duties, sync_committees, +}; use beacon_chain::attestation_verification::VerifiedAttestation; use beacon_chain::proposer_preferences_verification::ProposerPreferencesError; use beacon_chain::{AttestationError, BeaconChain, BeaconChainError, BeaconChainTypes}; @@ -209,6 +211,42 @@ pub fn post_validator_duties_ptc( .boxed() } +// POST validator/duties/inclusion_list/{epoch} +pub fn post_validator_duties_inclusion_list( + eth_v1: EthV1Filter, + chain_filter: ChainFilter, + not_while_syncing_filter: NotWhileSyncingFilter, + task_spawner_filter: TaskSpawnerFilter, +) -> ResponseFilter { + eth_v1 + .and(warp::path("validator")) + .and(warp::path("duties")) + .and(warp::path("inclusion_list")) + .and(warp::path::param::().or_else(|_| async { + Err(warp_utils::reject::custom_bad_request( + "Invalid epoch".to_string(), + )) + })) + .and(warp::path::end()) + .and(not_while_syncing_filter.clone()) + .and(warp_utils::json::json()) + .and(task_spawner_filter.clone()) + .and(chain_filter.clone()) + .then( + |epoch: Epoch, + not_synced_filter: Result<(), Rejection>, + indices: ValidatorIndexData, + task_spawner: TaskSpawner, + chain: Arc>| { + task_spawner.blocking_json_task(Priority::P0, move || { + not_synced_filter?; + inclusion_list_duties::inclusion_list_duties(epoch, &indices.0, &chain) + }) + }, + ) + .boxed() +} + // GET validator/aggregate_attestation?attestation_data_root,slot pub fn get_validator_aggregate_attestation( any_version: AnyVersionFilter, diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index a020c633c82..f787f8b7075 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -9,7 +9,7 @@ use beacon_chain::{ }; use beacon_processor::{Work, WorkEvent, work_reprocessing_queue::ReprocessQueueMessage}; use eth2::types::ProduceBlockV3Response; -use eth2::types::{DepositContractData, StateId}; +use eth2::types::{DepositContractData, InclusionListDuty, StateId}; use execution_layer::{ForkchoiceState, PayloadAttributes}; use fixed_bytes::FixedBytesExtended; use http_api::test_utils::InteractiveTester; @@ -61,8 +61,7 @@ async fn state_by_root_pruned_from_fork_choice() { type E = MinimalEthSpec; let validator_count = 24; - // TODO(heze): use `ForkName::latest()` once Heze block production is wired up. - let spec = ForkName::Gloas.make_genesis_spec(E::default_spec()); + let spec = ForkName::latest().make_genesis_spec(E::default_spec()); let tester = InteractiveTester::::new_with_initializer_and_mutator( Some(spec.clone()), @@ -1387,3 +1386,108 @@ async fn lighthouse_custody_info() { info.custody_group_count as usize ); } + +/// Inclusion list duties across the Heze fork boundary: pre-Heze epochs are not rejected, +/// and duties at the fork epoch resolve their dependent root from pre-fork history. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn inclusion_list_duties_across_fork_boundary() { + type E = MinimalEthSpec; + + let validator_count = 32; + let heze_fork_epoch = Epoch::new(2); + let mut spec = ForkName::Gloas.make_genesis_spec(E::default_spec()); + spec.heze_fork_epoch = Some(heze_fork_epoch); + + let tester = InteractiveTester::::new(Some(spec), validator_count).await; + let harness = &tester.harness; + let client = &tester.client; + + // Build the chain into the Heze fork epoch. + harness.advance_slot(); + let target_slot = heze_fork_epoch.start_slot(E::slots_per_epoch()) + 1; + harness + .extend_chain( + target_slot.as_u64() as usize, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + assert_eq!(harness.chain.epoch().unwrap(), heze_fork_epoch); + + // Pre-Heze epochs are not rejected. + for epoch in [Epoch::new(0), Epoch::new(1)] { + let duties = client + .post_validator_duties_inclusion_list(epoch, &[0]) + .await + .unwrap_or_else(|e| panic!("Failed to get duties on pre-Heze epoch {epoch}: {e:?}")); + assert_eq!( + duties.data.len(), + 1, + "validator 0 should have an IL duty in pre-Heze epoch {epoch}" + ); + } + + // Duties at the fork epoch are served, with the dependent root resolved from + // pre-fork history + let indices: Vec = (0..validator_count as u64).collect(); + let duties = client + .post_validator_duties_inclusion_list(heze_fork_epoch, &indices) + .await + .expect("fork epoch duties should be served"); + + let expected_dependent_root = harness + .chain + .block_root_at_slot( + (heze_fork_epoch - 1).start_slot(E::slots_per_epoch()) - 1, + beacon_chain::WhenSlotSkipped::Prev, + ) + .unwrap() + .expect("pre-fork dependent block should exist"); + assert_eq!(duties.dependent_root, expected_dependent_root); + + // Verify the duties against committees derived from the fork epoch state. + let mut state = harness + .chain + .state_at_slot( + heze_fork_epoch.start_slot(E::slots_per_epoch()), + beacon_chain::StateSkipConfig::WithStateRoots, + ) + .unwrap(); + state + .build_committee_cache(types::RelativeEpoch::Current, &harness.chain.spec) + .unwrap(); + + let slot_committees: Vec<(Slot, Vec)> = heze_fork_epoch + .slot_iter(E::slots_per_epoch()) + .map(|slot| { + let committee = state.get_inclusion_list_committee(slot).unwrap(); + (slot, committee.to_vec()) + }) + .collect(); + + let expected_duties: Vec = indices + .iter() + .filter_map(|&validator_index| { + let validator = state.validators().get(validator_index as usize)?; + let (slot, _) = slot_committees + .iter() + .find(|(_, committee)| committee.contains(&validator_index))?; + Some(InclusionListDuty { + pubkey: validator.pubkey, + validator_index, + slot: *slot, + }) + }) + .collect(); + + assert_eq!( + duties.data, expected_duties, + "fork epoch duties should match state-derived committees" + ); + + // The epoch after the fork is also served; its dependent root still lies pre-fork + client + .post_validator_duties_inclusion_list(heze_fork_epoch + 1, &[0]) + .await + .expect("duties for the epoch after the fork should be served"); +} diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 38bcd05a77c..3b95b882ccb 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -4419,6 +4419,120 @@ impl ApiTester { self } + pub async fn test_get_validator_duties_inclusion_list(self) -> Self { + let current_epoch = self.chain.epoch().unwrap().as_u64(); + + let half = current_epoch / 2; + let first = current_epoch - half; + let last = current_epoch + half; + + for epoch in first..=last { + for indices in self.interesting_validator_indices() { + let epoch = Epoch::from(epoch); + + // The endpoint requires at least one validator index. + if indices.is_empty() { + assert_eq!( + self.client + .post_validator_duties_inclusion_list(epoch, indices.as_slice()) + .await + .unwrap_err() + .status() + .map(Into::into), + Some(400) + ); + continue; + } + + // The endpoint does not allow getting duties past the next epoch. + if epoch > current_epoch + 1 { + assert_eq!( + self.client + .post_validator_duties_inclusion_list(epoch, indices.as_slice()) + .await + .unwrap_err() + .status() + .map(Into::into), + Some(400) + ); + continue; + } + + let results = self + .client + .post_validator_duties_inclusion_list(epoch, indices.as_slice()) + .await + .unwrap(); + + let dependent_root = self + .chain + .block_root_at_slot( + (epoch - 1).start_slot(E::slots_per_epoch()) - 1, + WhenSlotSkipped::Prev, + ) + .unwrap() + .unwrap_or(self.chain.head_beacon_block_root()); + + assert_eq!(results.dependent_root, dependent_root); + + let result_duties = results.data; + + let mut state = self + .chain + .state_at_slot( + epoch.start_slot(E::slots_per_epoch()), + StateSkipConfig::WithStateRoots, + ) + .unwrap(); + state + .build_committee_cache(RelativeEpoch::Current, &self.chain.spec) + .unwrap(); + + let slot_committees: Vec<(Slot, Vec)> = epoch + .slot_iter(E::slots_per_epoch()) + .map(|slot| { + let committee = state.get_inclusion_list_committee(slot).unwrap(); + (slot, committee.to_vec()) + }) + .collect(); + + let expected_duties: Vec = indices + .iter() + .filter_map(|&validator_index| { + let validator = state.validators().get(validator_index as usize)?; + let (slot, _) = slot_committees + .iter() + .find(|(_, committee)| committee.contains(&validator_index))?; + Some(InclusionListDuty { + pubkey: validator.pubkey, + validator_index, + slot: *slot, + }) + }) + .collect(); + + assert_eq!( + result_duties, expected_duties, + "inclusion list duties should exactly match state-derived committees" + ); + + // With 32 validators there are fewer unique members per slot than + // INCLUSION_LIST_COMMITTEE_SIZE, so every committee here cycles + // + // ensure that the endpoint returns at most one duty per validator even in that case + let mut seen_validators = std::collections::HashSet::new(); + assert!( + result_duties + .iter() + .all(|duty| seen_validators.insert(duty.validator_index)), + "each validator should appear at most once in the duties response" + ); + } + } + + self + } + pub async fn test_block_production(self) -> Self { // Pre-Gloas endpoint test; post-Gloas block production is v4-only. if self.chain.spec.is_gloas_scheduled() { @@ -9869,6 +9983,29 @@ async fn get_validator_duties_ptc_with_skip_slots() { .await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_validator_duties_inclusion_list() { + if !fork_name_from_env().is_some_and(|f| f.heze_enabled()) { + return; + } + ApiTester::new_with_hard_forks() + .await + .test_get_validator_duties_inclusion_list() + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_validator_duties_inclusion_list_with_skip_slots() { + if !fork_name_from_env().is_some_and(|f| f.heze_enabled()) { + return; + } + ApiTester::new_with_hard_forks() + .await + .skip_slots(E::slots_per_epoch() * 2) + .test_get_validator_duties_inclusion_list() + .await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn block_production() { ApiTester::new().await.test_block_production().await; diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index a02b948a909..e823ffd2e8b 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -82,6 +82,7 @@ const HTTP_SYNC_DUTIES_TIMEOUT_QUOTIENT: u32 = 4; const HTTP_SYNC_AGGREGATOR_TIMEOUT_QUOTIENT: u32 = 24; // For DVT involving middleware only // TODO(EIP-7732): Determine what this quotient should be const HTTP_PTC_DUTIES_TIMEOUT_QUOTIENT: u32 = 4; +const HTTP_INCLUSION_LIST_DUTIES_TIMEOUT_QUOTIENT: u32 = 4; const HTTP_GET_BEACON_BLOCK_SSZ_TIMEOUT_QUOTIENT: u32 = 4; const HTTP_GET_DEBUG_BEACON_STATE_QUOTIENT: u32 = 4; const HTTP_GET_DEPOSIT_SNAPSHOT_QUOTIENT: u32 = 4; @@ -104,6 +105,7 @@ pub struct Timeouts { pub sync_duties: Duration, pub sync_aggregators: Duration, pub ptc_duties: Duration, + pub inclusion_list_duties: Duration, pub get_beacon_blocks_ssz: Duration, pub get_debug_beacon_states: Duration, pub get_deposit_snapshot: Duration, @@ -126,6 +128,7 @@ impl Timeouts { sync_duties: timeout, sync_aggregators: timeout, ptc_duties: timeout, + inclusion_list_duties: timeout, get_beacon_blocks_ssz: timeout, get_debug_beacon_states: timeout, get_deposit_snapshot: timeout, @@ -150,6 +153,7 @@ impl Timeouts { sync_duties: base_timeout / HTTP_SYNC_DUTIES_TIMEOUT_QUOTIENT, sync_aggregators: base_timeout / HTTP_SYNC_AGGREGATOR_TIMEOUT_QUOTIENT, ptc_duties: base_timeout / HTTP_PTC_DUTIES_TIMEOUT_QUOTIENT, + inclusion_list_duties: base_timeout / HTTP_INCLUSION_LIST_DUTIES_TIMEOUT_QUOTIENT, get_beacon_blocks_ssz: base_timeout / HTTP_GET_BEACON_BLOCK_SSZ_TIMEOUT_QUOTIENT, get_debug_beacon_states: base_timeout / HTTP_GET_DEBUG_BEACON_STATE_QUOTIENT, get_deposit_snapshot: base_timeout / HTTP_GET_DEPOSIT_SNAPSHOT_QUOTIENT, @@ -4133,6 +4137,29 @@ impl BeaconNodeHttpClient { ) .await } + + /// `POST validator/duties/inclusion_list/{epoch}` + pub async fn post_validator_duties_inclusion_list( + &self, + epoch: Epoch, + indices: &[u64], + ) -> Result>, Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("validator") + .push("duties") + .push("inclusion_list") + .push(&epoch.to_string()); + + self.post_with_timeout_and_response( + path, + &ValidatorIndexDataRef(indices), + self.timeouts.inclusion_list_duties, + ) + .await + } } #[cfg(test)] diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index 08dc2e00c5f..eedcfc8ff30 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -863,6 +863,14 @@ pub struct PtcDuty { pub slot: Slot, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct InclusionListDuty { + pub pubkey: PublicKeyBytes, + #[serde(with = "serde_utils::quoted_u64")] + pub validator_index: u64, + pub slot: Slot, +} + #[derive(Clone, Deserialize)] pub struct ValidatorBlocksQuery { pub randao_reveal: SignatureBytes, diff --git a/consensus/types/src/state/beacon_state.rs b/consensus/types/src/state/beacon_state.rs index 84e3b303a16..ce28c810e9f 100644 --- a/consensus/types/src/state/beacon_state.rs +++ b/consensus/types/src/state/beacon_state.rs @@ -128,6 +128,9 @@ pub enum BeaconStateError { slot: Slot, index: CommitteeIndex, }, + NoInclusionListCommittee { + slot: Slot, + }, ZeroSlotsPerEpoch, PubkeyCacheInconsistent, PubkeyCacheIncomplete { @@ -1213,6 +1216,20 @@ impl BeaconState { cache.get_beacon_committees_at_slot(slot) } + /// Get the inclusion list committee for the given `slot`. [New in Heze:EIP7805] + /// + /// Utilises the committee cache. + pub fn get_inclusion_list_committee( + &self, + slot: Slot, + ) -> Result, BeaconStateError> { + let cache = self.committee_cache_at_slot(slot)?; + let committee = + cache.get_inclusion_list_committee_at_slot(slot, E::inclusion_list_committee_size())?; + let committee: Vec = committee.into_iter().map(|index| index as u64).collect(); + Ok(FixedVector::new(committee)?) + } + /// Get all of the Beacon committees at a given relative epoch. /// /// Utilises the committee cache. diff --git a/consensus/types/src/state/committee_cache.rs b/consensus/types/src/state/committee_cache.rs index 2e74ab760cb..09e1af06f5e 100644 --- a/consensus/types/src/state/committee_cache.rs +++ b/consensus/types/src/state/committee_cache.rs @@ -246,6 +246,32 @@ impl CommitteeCache { .collect() } + /// Get the inclusion list committee for the given `slot`. [New in Heze:EIP7805] + /// + /// Cycles over the slot's beacon committees, so a validator may appear more than once. + pub fn get_inclusion_list_committee_at_slot( + &self, + slot: Slot, + inclusion_list_committee_size: usize, + ) -> Result, BeaconStateError> { + let indices: Vec = self + .get_beacon_committees_at_slot(slot)? + .iter() + .flat_map(|bc| bc.committee.iter().copied()) + .take(inclusion_list_committee_size) + .collect(); + + if indices.is_empty() { + return Err(BeaconStateError::NoInclusionListCommittee { slot }); + } + + Ok(indices + .into_iter() + .cycle() + .take(inclusion_list_committee_size) + .collect()) + } + /// Returns all committees for `self.initialized_epoch`. pub fn get_all_beacon_committees(&self) -> Result>, BeaconStateError> { let initialized_epoch = self diff --git a/consensus/types/tests/committee_cache.rs b/consensus/types/tests/committee_cache.rs index 5205446c713..288c5ef7fb6 100644 --- a/consensus/types/tests/committee_cache.rs +++ b/consensus/types/tests/committee_cache.rs @@ -10,11 +10,11 @@ use types::*; use crate::test_utils::generate_deterministic_keypairs; -pub const VALIDATOR_COUNT: usize = 16; +pub const MAX_VALIDATOR_COUNT: usize = 160; /// A cached set of keys. static KEYPAIRS: LazyLock> = - LazyLock::new(|| generate_deterministic_keypairs(VALIDATOR_COUNT)); + LazyLock::new(|| generate_deterministic_keypairs(MAX_VALIDATOR_COUNT)); fn get_harness(validator_count: usize) -> BeaconChainHarness> { let harness = BeaconChainHarness::builder(E::default()) @@ -169,3 +169,94 @@ async fn min_randao_epoch_correct() { state.get_randao_mix(min_randao_epoch - 1).unwrap_err(); state.get_randao_mix(min_randao_epoch + 1).unwrap(); } + +/// 16 validators gives ~2 members per slot, so the committee wraps. +#[tokio::test] +async fn inclusion_list_committee_wraps_around_small_committees() { + let mut state = new_state::(16, Slot::new(0)).await; + let spec = &MinimalEthSpec::default_spec(); + state.build_all_committee_caches(spec).unwrap(); + + let size = MinimalEthSpec::inclusion_list_committee_size(); + + for slot in state + .current_epoch() + .slot_iter(MinimalEthSpec::slots_per_epoch()) + { + let concatenated: Vec = state + .get_beacon_committees_at_slot(slot) + .unwrap() + .iter() + .flat_map(|bc| bc.committee.iter().map(|i| *i as u64)) + .collect(); + assert!(concatenated.len() < size); + + let committee = state.get_inclusion_list_committee(slot).unwrap(); + + assert_eq!(committee.len(), size); + for (i, validator) in committee.iter().enumerate() { + assert_eq!(*validator, concatenated[i % concatenated.len()]); + } + + assert_eq!(committee, state.get_inclusion_list_committee(slot).unwrap()); + } +} + +/// Inclusion list duties are looked up an epoch ahead, so the next epoch must resolve. +#[tokio::test] +async fn inclusion_list_committee_resolves_for_the_next_epoch() { + let mut state = new_state::(160, Slot::new(0)).await; + let spec = &MinimalEthSpec::default_spec(); + state.build_all_committee_caches(spec).unwrap(); + + let size = MinimalEthSpec::inclusion_list_committee_size(); + let next_epoch = state.next_epoch().unwrap(); + + for slot in next_epoch.slot_iter(MinimalEthSpec::slots_per_epoch()) { + let concatenated: Vec = state + .get_beacon_committees_at_slot(slot) + .unwrap() + .iter() + .flat_map(|bc| bc.committee.iter().map(|i| *i as u64)) + .collect(); + + let committee = state.get_inclusion_list_committee(slot).unwrap(); + + assert_eq!(committee.len(), size); + for (i, validator) in committee.iter().enumerate() { + assert_eq!(*validator, concatenated[i % concatenated.len()]); + } + } +} + +/// 160 validators gives ~20 members per slot, so the committee truncates to the first 16. +#[tokio::test] +async fn inclusion_list_committee_truncates_large_committees() { + let mut state = new_state::(160, Slot::new(0)).await; + let spec = &MinimalEthSpec::default_spec(); + state.build_all_committee_caches(spec).unwrap(); + + let size = MinimalEthSpec::inclusion_list_committee_size(); + + for slot in state + .current_epoch() + .slot_iter(MinimalEthSpec::slots_per_epoch()) + { + let concatenated: Vec = state + .get_beacon_committees_at_slot(slot) + .unwrap() + .iter() + .flat_map(|bc| bc.committee.iter().map(|i| *i as u64)) + .collect(); + assert!(concatenated.len() > size); + + let committee = state.get_inclusion_list_committee(slot).unwrap(); + + assert_eq!(committee.len(), size); + for (i, validator) in committee.iter().enumerate() { + assert_eq!(*validator, concatenated[i]); + } + + assert_eq!(committee, state.get_inclusion_list_committee(slot).unwrap()); + } +}