Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 62 additions & 21 deletions anchor/message_validator/src/consensus_message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1405,7 +1405,7 @@ mod tests {
ValidationFailure::{EarlySlotMessage, LateSlotMessage},
tests::{
MockDutiesProvider, QbftMessageBuilder, create_message_id_for_test,
create_signed_consensus_message,
create_signed_consensus_message, spec_with_gloas,
},
};

Expand Down Expand Up @@ -1882,12 +1882,15 @@ mod tests {
assert_eq!(result, Ok(Some(SLOTS_PER_EPOCH)));
}

/// Helper function for testing role validation against fork schedules.
///
/// Tests whether a consensus message for a given role is properly accepted or rejected
/// based on the fork schedule. Used to verify that deprecated roles (Aggregator and
/// SyncCommittee) are rejected after the Boole fork but accepted before it.
fn test_role_fork_validation(role: Role, is_after_boole: bool, should_be_rejected: bool) {
/// Builds a signed consensus message for `role` and runs it through the full
/// `validate_ssv_message` path (including `validate_role_for_fork`) with the
/// given fork schedule and chain spec, returning the result for the caller
/// to assert on.
fn run_role_fork_validation(
role: Role,
fork_schedule: Arc<ForkSchedule>,
spec: Arc<types::ChainSpec>,
) -> Result<ValidatedSSVMessage, ValidationFailure> {
// Arrange: Set up test data and validation context
let committee_info = create_committee_info(FOUR_NODE_COMMITTEE);
let (private_key, public_key) = generate_test_key_pair();
Expand All @@ -1896,7 +1899,7 @@ mod tests {

let qbft_message = QbftMessageBuilder::new(role, QbftMessageType::Prepare).build();
let signed_msg = create_signed_consensus_message(
qbft_message.clone(),
qbft_message,
vec![OperatorId(1)],
vec![],
vec![private_key],
Expand All @@ -1912,16 +1915,6 @@ mod tests {
slot_clock.advance_slot();
slot_clock.advance_time(slot_duration);

let fork_schedule = if is_after_boole {
Arc::new(ForkSchedule::new(
Fork::Boole,
DomainType::default(),
"testing",
))
} else {
generate_fork_schedule()
};

let validation_context = ValidationContext {
signed_ssv_message: &signed_msg,
committee_info: &committee_info,
Expand All @@ -1933,18 +1926,38 @@ mod tests {
slot_clock,
operator_pub_keys: &map,
fork_schedule,
spec: Arc::new(types::ChainSpec::mainnet()),
spec,
};

// Act: Validate the message
let result = validate_ssv_message(
validate_ssv_message(
validation_context,
&mut DutyState::new(64),
Arc::new(MockDutiesProvider {
voluntary_exit_duty_count: 0,
..Default::default()
}),
);
)
}

/// Helper function for testing role validation against fork schedules.
///
/// Tests whether a consensus message for a given role is properly accepted or rejected
/// based on the fork schedule. Used to verify that deprecated roles (Aggregator and
/// SyncCommittee) are rejected after the Boole fork but accepted before it.
fn test_role_fork_validation(role: Role, is_after_boole: bool, should_be_rejected: bool) {
let fork_schedule = if is_after_boole {
Arc::new(ForkSchedule::new(
Fork::Boole,
DomainType::default(),
"testing",
))
} else {
generate_fork_schedule()
};

let result =
run_role_fork_validation(role, fork_schedule, Arc::new(types::ChainSpec::mainnet()));

// Assert: Verify the expected outcome
if should_be_rejected {
Expand Down Expand Up @@ -1989,4 +2002,32 @@ mod tests {
fn test_sync_committee_consensus_message_rejected_after_boole() {
test_role_fork_validation(Role::SyncCommittee, true, true);
}

#[test]
fn test_validator_registration_consensus_message_rejected_after_gloas() {
// A ValidatorRegistration consensus message with Gloas active at epoch 0
// (the builder's height of 1 is a slot in epoch 0). The Ethereum fork
// gate runs before the structural non-QBFT-role check, so the
// deprecated-role rejection surfaces instead of UnexpectedConsensusMessage.
let result = run_role_fork_validation(
Role::ValidatorRegistration,
generate_fork_schedule(),
spec_with_gloas(Some(0)),
);

assert_validation_error(
result,
|failure| {
matches!(
failure,
ValidationFailure::RoleNotActiveAfterEthFork {
role: Role::ValidatorRegistration,
deprecated_since_fork: types::ForkName::Gloas,
..
}
)
},
"RoleNotActiveAfterEthFork (ValidatorRegistration consensus message post-Gloas)",
);
}
}
35 changes: 34 additions & 1 deletion anchor/message_validator/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,12 @@ pub enum ValidationFailure {
current_fork: ForkName,
minimum_fork: ForkName,
},
/// A role deprecated at an Ethereum hard fork was seen at/after that fork activated.
RoleNotActiveAfterEthFork {
role: Role,
current_fork: ForkName,
deprecated_since_fork: ForkName,
},
}

impl From<&ValidationFailure> for MessageAcceptance {
Expand Down Expand Up @@ -887,6 +893,7 @@ pub(crate) fn validate_beacon_duty(
/// - AggregatorCommittee before Boole fork (not yet active)
/// - Aggregator and SyncCommittee after Boole fork (deprecated)
/// - PTCAttester before the Ethereum Gloas (ePBS) fork (not yet active)
/// - ValidatorRegistration at/after the Ethereum Gloas (ePBS) fork (deprecated by SIP-94)
/// - ProposerPreferences before the Ethereum Gloas (ePBS) fork (not yet active)
pub(crate) fn validate_role_for_fork(
slot: Slot,
Expand Down Expand Up @@ -926,6 +933,22 @@ pub(crate) fn validate_role_for_fork(
}
}

// Reject ValidatorRegistration at/after the Ethereum Gloas (ePBS) fork; SIP-94
// deprecates the duty (proposer preferences replace relay registrations). Gated
// on the message's duty slot, not wall clock, so registrations for pre-fork
// slots remain valid through their TTL window. Wire values are retained for
// pre-Gloas decode per SIP-94.
if role == Role::ValidatorRegistration {
let current_fork = validation_context.spec.fork_name_at_epoch(epoch);
if current_fork.gloas_enabled() {
return Err(ValidationFailure::RoleNotActiveAfterEthFork {
role,
current_fork,
deprecated_since_fork: ForkName::Gloas,
});
}
}

// Reject ProposerPreferences before the Ethereum Gloas (ePBS) fork, read from the consensus
// spec.
if role == Role::ProposerPreferences {
Expand Down Expand Up @@ -1187,7 +1210,7 @@ pub(crate) fn hash_data(full_data: &[u8]) -> [u8; 32] {

#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::{collections::HashMap, sync::Arc};

use bls::{Hash256, PublicKeyBytes};
use duties_tracker::DutiesProvider;
Expand Down Expand Up @@ -1426,6 +1449,16 @@ mod tests {
committee_members.into_iter().zip(public_keys).collect()
}

/// Build a `ChainSpec` whose Ethereum Gloas (ePBS) fork activates at
/// `gloas_fork_epoch` (`None` = "Gloas never happens"). Used by role gates
/// keyed to the Ethereum fork, e.g. PTCAttester activation and
/// ValidatorRegistration deprecation.
pub(crate) fn spec_with_gloas(gloas_fork_epoch: Option<u64>) -> Arc<types::ChainSpec> {
let mut spec = types::ChainSpec::mainnet();
spec.gloas_fork_epoch = gloas_fork_epoch.map(types::Epoch::new);
Arc::new(spec)
}

// Assert helpers for common validation patterns
pub fn assert_validation_error<T, F>(
result: Result<T, ValidationFailure>,
Expand Down
121 changes: 111 additions & 10 deletions anchor/message_validator/src/partial_signature.rs
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ mod tests {
tests::{
FOUR_NODE_COMMITTEE, MockDutiesProvider, assert_validation_error,
create_committee_info, create_message_id_for_test, create_operator_pub_keys,
generate_random_rsa_public_keys,
generate_random_rsa_public_keys, spec_with_gloas,
},
};

Expand Down Expand Up @@ -497,15 +497,6 @@ mod tests {
Arc::new(ForkSchedule::new(fork, DomainType::default(), "testing"))
}

/// Build a `ChainSpec` whose Ethereum Gloas (ePBS) fork activates at
/// `gloas_fork_epoch` (`None` = "Gloas never happens"). Used to gate
/// post-Gloas roles such as `PTCAttester`.
fn spec_with_gloas(gloas_fork_epoch: Option<u64>) -> Arc<types::ChainSpec> {
let mut spec = types::ChainSpec::mainnet();
spec.gloas_fork_epoch = gloas_fork_epoch.map(types::Epoch::new);
Arc::new(spec)
}

#[test]
fn test_aggregator_committee_message_count_small_committee() {
// Small committee (V ≤ 512): min(5*V, V + 4*512) = 5*V
Expand Down Expand Up @@ -1387,6 +1378,60 @@ mod tests {
);
}

#[test]
fn test_validator_registration_rejected_after_gloas() {
// Setup
let committee_info = create_committee_info(FOUR_NODE_COMMITTEE);
let (private_key, public_key) = generate_test_key_pair();
let map =
create_operator_pub_keys(committee_info.committee_members.clone(), vec![public_key]);
let signed_msg = create_signed_partial_sig_message(
Role::ValidatorRegistration,
PartialSignatureKind::ValidatorRegistration,
OperatorId(1),
&private_key,
);

let fork_schedule = ForkSchedule::new(Fork::Alan, DomainType::default(), "testing");
let mut validation_context = create_ttl_validation_context(
&signed_msg,
&committee_info,
Role::ValidatorRegistration,
&map,
TTL_SLOTS,
Arc::new(fork_schedule),
);
// ValidatorRegistration is deprecated at Gloas; the role gate reads the Ethereum
// fork from the spec. The message slot (1) is in epoch 0, where Gloas is active.
validation_context.spec = spec_with_gloas(Some(0));

// Execute
let result = validate_partial_signature_message(
validation_context,
&mut DutyState::new(64),
Arc::new(MockDutiesProvider {
voluntary_exit_duty_count: 0,
..Default::default()
}),
);

// Assert
assert_validation_error(
result,
|failure| {
matches!(
failure,
ValidationFailure::RoleNotActiveAfterEthFork {
role: Role::ValidatorRegistration,
deprecated_since_fork: types::ForkName::Gloas,
..
}
)
},
"RoleNotActiveAfterEthFork (ValidatorRegistration post-Gloas)",
);
}

#[test]
fn test_voluntary_exit_within_ttl_accepted() {
// Setup
Expand Down Expand Up @@ -1808,6 +1853,62 @@ mod tests {
);
}

#[test]
fn test_validator_registration_rejected_at_gloas_boundary() {
use crate::validate_role_for_fork;

let committee_info = create_committee_info(FOUR_NODE_COMMITTEE);
let (private_key, public_key) = generate_test_key_pair();
let map =
create_operator_pub_keys(committee_info.committee_members.clone(), vec![public_key]);
let signed_msg = create_signed_partial_sig_message(
Role::ValidatorRegistration,
PartialSignatureKind::ValidatorRegistration,
OperatorId(1),
&private_key,
);

let fork_schedule = generate_fork_schedule(Fork::Boole);
let mut validation_context = create_test_validation_context_with_fork(
&signed_msg,
&committee_info,
Role::ValidatorRegistration,
&map,
Some(fork_schedule),
);
// The role gate reads the Ethereum fork from the spec; Gloas activates at epoch 2.
validation_context.spec = spec_with_gloas(Some(2));

// Slot 63 is the last slot of epoch 1 (32 slots per epoch): still pre-Gloas,
// so registrations remain valid.
let result = validate_role_for_fork(Slot::new(63), &validation_context);
assert!(result.is_ok(), "Expected ok but got: {result:?}");

// Slot 64 is the first slot of epoch 2: Gloas is active, the deprecated
// duty is rejected.
let result = validate_role_for_fork(Slot::new(64), &validation_context);
assert_validation_error(
result,
|failure| {
matches!(
failure,
ValidationFailure::RoleNotActiveAfterEthFork {
role: Role::ValidatorRegistration,
deprecated_since_fork: types::ForkName::Gloas,
..
}
)
},
"RoleNotActiveAfterEthFork (ValidatorRegistration post-Gloas)",
);

// Pins the mainnet no-op: with Gloas unscheduled, the deprecation gate
// never fires regardless of slot.
validation_context.spec = spec_with_gloas(None);
let result = validate_role_for_fork(Slot::new(100_000), &validation_context);
assert!(result.is_ok(), "Expected ok but got: {result:?}");
}

#[test]
fn test_ptc_attester_within_ttl_accepted() {
let committee_info = create_committee_info(FOUR_NODE_COMMITTEE);
Expand Down
Loading