diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 29cd437c43..91dc219258 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -4845,43 +4845,22 @@ impl BeaconChain { // get_attestation_score(parent, parent_payload_status) where parent_payload_status // is determined by the head block's relationship to its parent. let head_weight = info.head_node.weight(); - let parent_weight = if let Ok(head_payload_status) = info.head_node.parent_payload_status() - { - // Post-GLOAS: use the payload-filtered weight matching how the head - // extends from its parent. - match head_payload_status { - proto_array::PayloadStatus::Full => { - info.parent_node.full_payload_weight().map_err(|()| { - Box::new(ProposerHeadError::Error( - Error::ProposerHeadForkChoiceError( - fork_choice::Error::ProtoArrayError( - proto_array::Error::InvalidNodeVariant { - block_root: info.parent_node.root(), - }, - ), - ), - )) - })? + let parent_weight = + if let (Ok(head_payload_status), Ok(parent_v29)) = ( + info.head_node.parent_payload_status(), + info.parent_node.as_v29(), + ) { + // Post-GLOAS: use the payload-filtered weight matching how the head + // extends from its parent. + match head_payload_status { + proto_array::PayloadStatus::Full => parent_v29.full_payload_weight, + proto_array::PayloadStatus::Empty => parent_v29.empty_payload_weight, + proto_array::PayloadStatus::Pending => info.parent_node.weight(), } - proto_array::PayloadStatus::Empty => { - info.parent_node.empty_payload_weight().map_err(|()| { - Box::new(ProposerHeadError::Error( - Error::ProposerHeadForkChoiceError( - fork_choice::Error::ProtoArrayError( - proto_array::Error::InvalidNodeVariant { - block_root: info.parent_node.root(), - }, - ), - ), - )) - })? - } - proto_array::PayloadStatus::Pending => info.parent_node.weight(), - } - } else { - // Pre-GLOAS (V17): use total weight. - info.parent_node.weight() - }; + } else { + // Pre-GLOAS or fork boundary: use total weight. + info.parent_node.weight() + }; let (head_weak, parent_strong) = if fork_choice_slot == re_org_block_slot { ( diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index 5d83fcdddb..8fa43ee10e 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -2104,9 +2104,7 @@ pub fn serve( let execution_status_string = node .execution_status() - .ok() - .map(|status| status.to_string()) - .unwrap_or_else(|| "n/a".to_string()); + .map_or_else(|_| "irrelevant".to_string(), |s| s.to_string()); ForkChoiceNode { slot: node.slot(), diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index 1b6c0c58bc..a42a1891cb 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -182,8 +182,11 @@ impl Default for ProposerBoost { /// ancestors can compare children using payload-aware tie breaking. #[derive(Clone, PartialEq, Debug, Copy)] pub struct NodeDelta { + /// Total weight change for the node. All votes contribute regardless of payload status. pub delta: i64, + /// Weight change from `PayloadStatus::Empty` votes. pub empty_delta: i64, + /// Weight change from `PayloadStatus::Full` votes. pub full_delta: i64, } @@ -399,6 +402,7 @@ impl ProtoArray { // Apply the delta to the node. if execution_status_is_invalid { + // Invalid nodes always have a weight of 0. *node.weight_mut() = 0; } else { *node.weight_mut() = apply_delta(node.weight(), delta, node_index)?; @@ -1127,10 +1131,9 @@ impl ProtoArray { ); let no_change = (parent.best_child(), parent.best_descendant()); - // For V29 (GLOAS) parents, the spec's virtual tree model requires choosing - // FULL or EMPTY direction at each node BEFORE considering concrete children. - // Only children whose parent_payload_status matches the preferred direction - // are eligible for best_child. This is PRIMARY, not a tiebreaker. + // For V29 (GLOAS) parents, the spec's virtual tree model determines a preferred + // FULL or EMPTY direction at each node. Weight is the primary selector among + // viable children; direction matching is the tiebreaker when weights are equal. let child_matches_dir = child_matches_parent_payload_preference( parent, child, diff --git a/consensus/state_processing/src/per_block_processing/process_operations.rs b/consensus/state_processing/src/per_block_processing/process_operations.rs index 9743812632..ac64398655 100644 --- a/consensus/state_processing/src/per_block_processing/process_operations.rs +++ b/consensus/state_processing/src/per_block_processing/process_operations.rs @@ -4,7 +4,10 @@ use crate::common::{ get_attestation_participation_flag_indices, increase_balance, initiate_validator_exit, slash_validator, }; -use crate::per_block_processing::errors::{BlockProcessingError, IntoWithIndex}; +use crate::per_block_processing::builder::{ + convert_validator_index_to_builder_index, is_builder_index, +}; +use crate::per_block_processing::errors::{BlockProcessingError, ExitInvalid, IntoWithIndex}; use crate::per_block_processing::verify_payload_attestation::verify_payload_attestation; use bls::{PublicKeyBytes, SignatureBytes}; use ssz_types::FixedVector; @@ -507,7 +510,26 @@ pub fn process_exits( // Verify and apply each exit in series. We iterate in series because higher-index exits may // become invalid due to the application of lower-index ones. for (i, exit) in voluntary_exits.iter().enumerate() { - verify_exit(state, None, exit, verify_signatures, spec) + // Exits must specify an epoch when they become valid; they are not valid before then. + let current_epoch = state.current_epoch(); + if current_epoch < exit.message.epoch { + return Err(BlockOperationError::invalid(ExitInvalid::FutureEpoch { + state: current_epoch, + exit: exit.message.epoch, + }) + .into_with_index(i)); + } + + // [New in Gloas:EIP7732] + if state.fork_name_unchecked().gloas_enabled() + && is_builder_index(exit.message.validator_index) + { + process_builder_voluntary_exit(state, exit, verify_signatures, spec) + .map_err(|e| e.into_with_index(i))?; + continue; + } + + verify_exit(state, Some(current_epoch), exit, verify_signatures, spec) .map_err(|e| e.into_with_index(i))?; initiate_validator_exit(state, exit.message.validator_index as usize, spec)?; @@ -515,6 +537,87 @@ pub fn process_exits( Ok(()) } +/// Process a builder voluntary exit. [New in Gloas:EIP7732] +fn process_builder_voluntary_exit( + state: &mut BeaconState, + signed_exit: &SignedVoluntaryExit, + verify_signatures: VerifySignatures, + spec: &ChainSpec, +) -> Result<(), BlockOperationError> { + let builder_index = + convert_validator_index_to_builder_index(signed_exit.message.validator_index); + + let builder = state + .builders()? + .get(builder_index as usize) + .cloned() + .ok_or(BlockOperationError::invalid(ExitInvalid::ValidatorUnknown( + signed_exit.message.validator_index, + )))?; + + // Verify the builder is active + let finalized_epoch = state.finalized_checkpoint().epoch; + if !builder.is_active_at_finalized_epoch(finalized_epoch, spec) { + return Err(BlockOperationError::invalid(ExitInvalid::NotActive( + signed_exit.message.validator_index, + ))); + } + + // Only exit builder if it has no pending withdrawals in the queue + let pending_balance = state.get_pending_balance_to_withdraw_for_builder(builder_index)?; + if pending_balance != 0 { + return Err(BlockOperationError::invalid( + ExitInvalid::PendingWithdrawalInQueue(signed_exit.message.validator_index), + )); + } + + // Verify signature (using EIP-7044 domain: capella_fork_version for Deneb+) + if verify_signatures.is_true() { + let pubkey = builder.pubkey; + let domain = spec.compute_domain( + Domain::VoluntaryExit, + spec.capella_fork_version, + state.genesis_validators_root(), + ); + let message = signed_exit.message.signing_root(domain); + // TODO(gloas): use builder pubkey cache once available + let bls_pubkey = pubkey + .decompress() + .map_err(|_| BlockOperationError::invalid(ExitInvalid::BadSignature))?; + if !signed_exit.signature.verify(&bls_pubkey, message) { + return Err(BlockOperationError::invalid(ExitInvalid::BadSignature)); + } + } + + // Initiate builder exit + initiate_builder_exit(state, builder_index, spec)?; + + Ok(()) +} + +/// Initiate the exit of a builder. [New in Gloas:EIP7732] +fn initiate_builder_exit( + state: &mut BeaconState, + builder_index: u64, + spec: &ChainSpec, +) -> Result<(), BeaconStateError> { + let current_epoch = state.current_epoch(); + let builder = state + .builders_mut()? + .get_mut(builder_index as usize) + .ok_or(BeaconStateError::UnknownBuilder(builder_index))?; + + // Return if builder already initiated exit + if builder.withdrawable_epoch != spec.far_future_epoch { + return Ok(()); + } + + // Set builder exit epoch + builder.withdrawable_epoch = current_epoch.safe_add(spec.min_builder_withdrawability_delay)?; + + Ok(()) +} + /// Validates each `bls_to_execution_change` and updates the state /// /// Returns `Ok(())` if the validation and state updates completed successfully. Otherwise returns @@ -814,6 +917,30 @@ pub fn process_deposit_requests_post_gloas( Ok(()) } +/// Check if there is a pending deposit for a new validator with the given pubkey. +// TODO(gloas): cache the deposit signature validation or remove this loop entirely if possible, +// it is `O(n * m)` where `n` is max 8192 and `m` is max 128M. +fn is_pending_validator( + state: &BeaconState, + pubkey: &PublicKeyBytes, + spec: &ChainSpec, +) -> Result { + for deposit in state.pending_deposits()?.iter() { + if deposit.pubkey == *pubkey { + let deposit_data = DepositData { + pubkey: deposit.pubkey, + withdrawal_credentials: deposit.withdrawal_credentials, + amount: deposit.amount, + signature: deposit.signature.clone(), + }; + if is_valid_deposit_signature(&deposit_data, spec).is_ok() { + return Ok(true); + } + } + } + Ok(false) +} + pub fn process_deposit_request_post_gloas( state: &mut BeaconState, deposit_request: &DepositRequest, @@ -835,10 +962,14 @@ pub fn process_deposit_request_post_gloas( let validator_index = state.get_validator_index(&deposit_request.pubkey)?; let is_validator = validator_index.is_some(); - let is_builder_prefix = + let has_builder_prefix = is_builder_withdrawal_credential(deposit_request.withdrawal_credentials, spec); - if is_builder || (is_builder_prefix && !is_validator) { + if is_builder + || (has_builder_prefix + && !is_validator + && !is_pending_validator(state, &deposit_request.pubkey, spec)?) + { // Apply builder deposits immediately apply_deposit_for_builder( state, diff --git a/consensus/types/src/core/consts.rs b/consensus/types/src/core/consts.rs index 0d4c0591cb..049094da76 100644 --- a/consensus/types/src/core/consts.rs +++ b/consensus/types/src/core/consts.rs @@ -31,9 +31,9 @@ pub mod gloas { // Fork choice constants pub type PayloadStatus = u8; - pub const PAYLOAD_STATUS_PENDING: PayloadStatus = 0; - pub const PAYLOAD_STATUS_EMPTY: PayloadStatus = 1; - pub const PAYLOAD_STATUS_FULL: PayloadStatus = 2; + pub const PAYLOAD_STATUS_EMPTY: PayloadStatus = 0; + pub const PAYLOAD_STATUS_FULL: PayloadStatus = 1; + pub const PAYLOAD_STATUS_PENDING: PayloadStatus = 2; pub const ATTESTATION_TIMELINESS_INDEX: usize = 0; pub const PTC_TIMELINESS_INDEX: usize = 1; diff --git a/testing/ef_tests/check_all_files_accessed.py b/testing/ef_tests/check_all_files_accessed.py index 782b554ff1..dd6be14306 100755 --- a/testing/ef_tests/check_all_files_accessed.py +++ b/testing/ef_tests/check_all_files_accessed.py @@ -47,6 +47,8 @@ excluded_paths = [ "bls12-381-tests/hash_to_G2", "tests/.*/eip7732", "tests/.*/eip7805", + # Heze fork is not implemented + "tests/.*/heze/.*", # TODO(gloas): remove these ignores as Gloas consensus is implemented "tests/.*/gloas/fork_choice/.*", # Ignore MatrixEntry SSZ tests for now. diff --git a/testing/ef_tests/download_test_vectors.sh b/testing/ef_tests/download_test_vectors.sh index ff5b61bb47..f91b2d1c38 100755 --- a/testing/ef_tests/download_test_vectors.sh +++ b/testing/ef_tests/download_test_vectors.sh @@ -10,7 +10,7 @@ if [[ "$version" == "nightly" || "$version" =~ ^nightly-[0-9]+$ ]]; then exit 1 fi - for cmd in unzip jq; do + for cmd in jq; do if ! command -v "${cmd}" >/dev/null 2>&1; then echo "Error ${cmd} is not installed" exit 1 @@ -48,13 +48,10 @@ if [[ "$version" == "nightly" || "$version" =~ ^nightly-[0-9]+$ ]]; then echo "Downloading artifact: ${name}" curl --progress-bar --location --show-error --retry 3 --retry-all-errors --fail \ -H "${auth_header}" -H "Accept: application/vnd.github+json" \ - --output "${name}.zip" "${url}" || { + --output "${name}" "${url}" || { echo "Failed to download ${name}" exit 1 } - - unzip -qo "${name}.zip" - rm -f "${name}.zip" done else for test in "${TESTS[@]}"; do diff --git a/testing/ef_tests/src/cases/operations.rs b/testing/ef_tests/src/cases/operations.rs index ca0124e1aa..798c66b666 100644 --- a/testing/ef_tests/src/cases/operations.rs +++ b/testing/ef_tests/src/cases/operations.rs @@ -716,8 +716,13 @@ impl> LoadCase for Operations { // Check BLS setting here before SSZ deserialization, as most types require signatures // to be valid. - let (operation, bls_error) = if metadata.bls_setting.unwrap_or_default().check().is_ok() { - match O::decode(&path.join(O::filename()), fork_name, spec) { + let operation_path = path.join(O::filename()); + let (operation, bls_error) = if !operation_path.is_file() { + // Some test cases (e.g. builder_voluntary_exit__success) have no operation file. + // TODO(gloas): remove this once the test vectors are fixed + (None, None) + } else if metadata.bls_setting.unwrap_or_default().check().is_ok() { + match O::decode(&operation_path, fork_name, spec) { Ok(op) => (Some(op), None), Err(Error::InvalidBLSInput(error)) => (None, Some(error)), Err(e) => return Err(e), diff --git a/testing/ef_tests/src/handler.rs b/testing/ef_tests/src/handler.rs index 895b8f2656..4373d6b7d1 100644 --- a/testing/ef_tests/src/handler.rs +++ b/testing/ef_tests/src/handler.rs @@ -537,11 +537,6 @@ impl Handler for RandomHandler { fn handler_name(&self) -> String { "random".into() } - - fn disabled_forks(&self) -> Vec { - // TODO(gloas): remove once we have Gloas random tests - vec![ForkName::Gloas] - } } #[derive(Educe)]