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
55 changes: 54 additions & 1 deletion anchor/common/ssv_types/src/msgid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub enum Role {
AggregatorCommittee,
PTCAttester,
ProposerPreferences,
EnvelopeProposer,
}

impl From<Role> for [u8; 4] {
Expand All @@ -37,6 +38,7 @@ impl From<Role> for [u8; 4] {
Role::AggregatorCommittee => [6, 0, 0, 0],
Role::PTCAttester => [7, 0, 0, 0],
Role::ProposerPreferences => [8, 0, 0, 0],
Role::EnvelopeProposer => [9, 0, 0, 0],
}
}
}
Expand All @@ -55,6 +57,7 @@ impl TryFrom<&[u8]> for Role {
[6, 0, 0, 0] => Ok(Role::AggregatorCommittee),
[7, 0, 0, 0] => Ok(Role::PTCAttester),
[8, 0, 0, 0] => Ok(Role::ProposerPreferences),
[9, 0, 0, 0] => Ok(Role::EnvelopeProposer),
_ => Err(DecodeError::NoMatchingVariant),
}
}
Expand All @@ -77,6 +80,7 @@ impl Role {
match self {
Role::Committee | Role::Aggregator | Role::AggregatorCommittee => Some(12),
Role::Proposer | Role::SyncCommittee => Some(6),
Role::EnvelopeProposer => Some(2),
// These roles don't use QBFT consensus
Role::ValidatorRegistration
| Role::VoluntaryExit
Expand Down Expand Up @@ -178,7 +182,8 @@ impl MessageId {
| Role::ValidatorRegistration
| Role::VoluntaryExit
| Role::PTCAttester
| Role::ProposerPreferences => PublicKeyBytes::deserialize(&self.0[8..])
| Role::ProposerPreferences
| Role::EnvelopeProposer => PublicKeyBytes::deserialize(&self.0[8..])
.ok()
.map(DutyExecutor::Validator),
}
Expand Down Expand Up @@ -416,6 +421,7 @@ mod tests {
Role::AggregatorCommittee,
Role::Proposer,
Role::SyncCommittee,
Role::EnvelopeProposer,
] {
assert!(role.is_qbft_role(), "{role:?} runs QBFT");
assert!(role.max_round().is_some(), "{role:?} must have a max round");
Expand Down Expand Up @@ -459,11 +465,58 @@ mod tests {
Role::ValidatorRegistration,
Role::VoluntaryExit,
Role::PTCAttester,
Role::EnvelopeProposer,
] {
assert!(
role.monotonic_slot_role(),
"{role:?} must be a monotonic-slot role"
);
}
}

/// Tests that EnvelopeProposer is a validator-scoped QBFT role with
/// round cut-off 2.
#[test]
fn envelope_proposer_is_validator_scoped_qbft_with_round_cutoff_two() {
assert!(
!Role::EnvelopeProposer.is_committee_role(),
"EnvelopeProposer is per-validator, not a committee role"
);
assert_eq!(
Role::EnvelopeProposer.max_round(),
Some(2),
"Envelope QBFT cut-off round must be 2"
);
assert!(
Role::EnvelopeProposer.is_qbft_role(),
"EnvelopeProposer runs QBFT (max_round is Some)"
);
assert!(
Role::EnvelopeProposer.monotonic_slot_role(),
"EnvelopeProposer signers advance slot-by-slot; lower slots are stale"
);

// Wire byte 9 for EnvelopeProposer check.
let bytes: [u8; 4] = Role::EnvelopeProposer.into();
assert_eq!(bytes, [9, 0, 0, 0], "EnvelopeProposer wire byte is 9");
assert_eq!(
Role::try_from(bytes.as_slice()).unwrap(),
Role::EnvelopeProposer,
"wire byte 9 decodes back to EnvelopeProposer"
);

// duty_executor resolves to Validator (per-proposer, pubkey-scoped).
let domain = DomainType([0, 0, 0, 1]);
let pk = PublicKeyBytes::empty();
let msg_id = MessageId::new(
&domain,
Role::EnvelopeProposer,
&DutyExecutor::Validator(pk),
);
assert_eq!(
msg_id.duty_executor(),
Some(DutyExecutor::Validator(pk)),
"EnvelopeProposer resolves to a validator-scoped duty executor"
);
}
}
125 changes: 124 additions & 1 deletion anchor/message_validator/src/consensus_message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,8 @@ mod tests {

use super::*;
use crate::{
LATE_MESSAGE_MARGIN, LATE_SLOT_ALLOWANCE, ValidatedSSVMessage, duty_limit,
LATE_MESSAGE_MARGIN, LATE_SLOT_ALLOWANCE, MessageAcceptance, ValidatedSSVMessage,
duty_limit,
tests::{
FOUR_NODE_COMMITTEE, SINGLE_NODE_COMMITTEE, create_committee_info,
create_operator_pub_keys, generate_random_rsa_public_keys,
Expand Down Expand Up @@ -1882,6 +1883,90 @@ mod tests {
assert_eq!(result, Ok(Some(SLOTS_PER_EPOCH)));
}

#[test]
fn test_duty_limit_envelope_proposer() {
// EnvelopeProposer is validator-scoped and per-slot. Its duty limit is
// `slots_per_epoch` (one envelope per slot across the lookahead window),
// independent of the validator-index slice length. Mirror
// test_duty_limit_proposer_preferences.
const SLOTS_PER_EPOCH: u64 = 32;

let now = SystemTime::now();
let slot_clock = ManualSlotClock::new(
Slot::new(100),
now.duration_since(UNIX_EPOCH).unwrap(),
Duration::from_secs(1),
);

let msg_id = MessageId::new(
&DomainType([0, 0, 0, 1]),
Role::EnvelopeProposer,
&DutyExecutor::Validator(PublicKeyBytes::empty()),
);
let ssv_msg = SSVMessage::new(MsgType::SSVConsensusMsgType, msg_id, vec![1, 2, 3])
.expect("SSVMessage should be created");
let signed_msg = SignedSSVMessage::new(
vec![[0xAA; RSA_SIGNATURE_SIZE]],
vec![OperatorId(1)],
ssv_msg,
vec![],
)
.expect("SignedSSVMessage should be created");

let committee_info = create_committee_info(FOUR_NODE_COMMITTEE);
let mock_duties_provider = Arc::new(MockDutiesProvider::default());
let map = HashMap::new();

// Create fork schedule with Boole at epoch 0 (active from start).
let mut fork_epochs = BTreeMap::new();
fork_epochs.insert(Fork::Alan, (Epoch::new(0), DomainType([0, 0, 0, 42])));
fork_epochs.insert(Fork::Boole, (Epoch::new(0), DomainType([0, 0, 0, 43])));
let fork_schedule = Arc::new(
fork::ForkSchedule::from_fork_configs(fork_epochs, "testing")
.expect("test fork schedule creation should succeed"),
);

let validation_context = ValidationContext {
signed_ssv_message: &signed_msg,
committee_info: &committee_info,
role: Role::EnvelopeProposer,
received_at: now,
slots_per_epoch: SLOTS_PER_EPOCH,
epochs_per_sync_committee_period: 256,
sync_committee_size: 512,
slot_clock: slot_clock.clone(),
operator_pub_keys: &map,
fork_schedule,
spec: Arc::new(types::ChainSpec::mainnet()),
};

let slot = slot_clock.now().unwrap();

// Act: Call duty_limit directly (private items visible to child-module tests).
let result = duty_limit(
&validation_context,
slot,
&[ValidatorIndex(0)], // Single validator; irrelevant for EnvelopeProposer
mock_duties_provider.clone(),
);

// Assert: Duty cap must equal slots_per_epoch and be independent of slice length.
assert_eq!(
result,
Ok(Some(SLOTS_PER_EPOCH)),
"EnvelopeProposer duty cap must be slots_per_epoch"
);

// Verify independence from slice length.
let many = vec![ValidatorIndex(0); 100];
let result = duty_limit(&validation_context, slot, &many, mock_duties_provider);
assert_eq!(
result,
Ok(Some(SLOTS_PER_EPOCH)),
"duty cap must be independent of validator-index slice length"
);
}

/// 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
Expand Down Expand Up @@ -2030,4 +2115,42 @@ mod tests {
"RoleNotActiveAfterEthFork (ValidatorRegistration consensus message post-Gloas)",
);
}

#[test]
fn test_envelope_proposer_consensus_message_rejected_before_gloas() {
// `EnvelopeProposer` is a post-Gloas role. With Gloas never activating
// (`spec_with_gloas(None)`), the shared `validate_role_for_fork` gate must reject
// its consensus message at every slot. The gate is role-agnostic across entry
// points, so exercising it once via the consensus path covers the envelope role.
let result = run_role_fork_validation(
Role::EnvelopeProposer,
generate_fork_schedule(),
spec_with_gloas(None),
);
assert_validation_error(
result,
|failure| {
matches!(
failure,
ValidationFailure::RoleNotActiveBeforeEthFork {
minimum_fork: types::ForkName::Gloas,
..
}
)
},
"RoleNotActiveBeforeEthFork (EnvelopeProposer consensus message pre-Gloas)",
);

// Ensures that pre-Gloas EnvelopeProposer messages caught at fork-gate are rejected and not
// ignored.
assert_eq!(
MessageAcceptance::from(&ValidationFailure::RoleNotActiveBeforeEthFork {
role: Role::EnvelopeProposer,
current_fork: types::ForkName::Base,
minimum_fork: types::ForkName::Gloas,
}),
MessageAcceptance::Reject,
"fork-gate failure must be Reject",
);
}
}
48 changes: 24 additions & 24 deletions anchor/message_validator/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,8 @@ impl<S: SlotClock + 'static, D: DutiesProvider> Validator<S, D> {
| Role::ValidatorRegistration
| Role::VoluntaryExit
| Role::PTCAttester
| Role::ProposerPreferences => {
| Role::ProposerPreferences
| Role::EnvelopeProposer => {
let validator_pk = match ssv_message.msg_id().duty_executor() {
Some(DutyExecutor::Validator(pk)) => pk,
_ => return Err(ValidationFailure::UnknownValidator),
Expand Down Expand Up @@ -844,11 +845,11 @@ pub(crate) fn validate_beacon_duty(
}
}

// Rule: For a proposer-preferences message, the validator must be the assigned proposer at the
// preference's `proposal_slot` (= `slot` here). Checked only once the slot-epoch's proposer
// duties are known locally, so a not-yet-fetched epoch is tolerated. No RANDAO tolerance:
// ProposerPreferences carries no RANDAO signature.
if role == Role::ProposerPreferences {
// Rule: For a proposer-preferences or envelope-proposer message, the validator must be the
// assigned proposer at the slot. Checked only once the slot-epoch's proposer duties are known
// locally, so a not-yet-fetched epoch is tolerated. No RANDAO tolerance: neither
// ProposerPreferences nor EnvelopeProposer carry a RANDAO signature.
if matches!(role, Role::ProposerPreferences | Role::EnvelopeProposer) {
// Non-committee roles always have one validator index
let validator_index = validation_context
.committee_info
Expand Down Expand Up @@ -895,6 +896,7 @@ pub(crate) fn validate_beacon_duty(
/// - 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)
/// - EnvelopeProposer before the Ethereum Gloas (ePBS) fork (not yet active)
pub(crate) fn validate_role_for_fork(
slot: Slot,
validation_context: &ValidationContext<impl SlotClock>,
Expand All @@ -921,18 +923,6 @@ pub(crate) fn validate_role_for_fork(
});
}

// Reject PTCAttester before the Ethereum Gloas (ePBS) fork, read from the consensus spec.
if role == Role::PTCAttester {
let current_fork = validation_context.spec.fork_name_at_epoch(epoch);
if !current_fork.gloas_enabled() {
return Err(ValidationFailure::RoleNotActiveBeforeEthFork {
role,
current_fork,
minimum_fork: ForkName::Gloas,
});
}
}

// 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
Expand All @@ -949,9 +939,12 @@ pub(crate) fn validate_role_for_fork(
}
}

// Reject ProposerPreferences before the Ethereum Gloas (ePBS) fork, read from the consensus
// spec.
if role == Role::ProposerPreferences {
// Reject post-Gloas roles (PTCAttester, ProposerPreferences, EnvelopeProposer) before the
// Ethereum Gloas (ePBS) fork, read from the consensus spec.
if matches!(
Comment thread
jnhsigmap marked this conversation as resolved.
role,
Role::PTCAttester | Role::ProposerPreferences | Role::EnvelopeProposer
) {
let current_fork = validation_context.spec.fork_name_at_epoch(epoch);
if !current_fork.gloas_enabled() {
return Err(ValidationFailure::RoleNotActiveBeforeEthFork {
Expand Down Expand Up @@ -1044,7 +1037,9 @@ fn message_lateness(
validation_context: &ValidationContext<impl SlotClock>,
) -> Result<Duration, ValidationFailure> {
let ttl = match validation_context.role {
Role::Proposer | Role::SyncCommittee | Role::PTCAttester => 1 + LATE_SLOT_ALLOWANCE,
Role::Proposer | Role::SyncCommittee | Role::PTCAttester | Role::EnvelopeProposer => {
1 + LATE_SLOT_ALLOWANCE
}
Role::Committee
| Role::Aggregator
| Role::ValidatorRegistration
Expand Down Expand Up @@ -1161,7 +1156,11 @@ fn duty_limit(
}
// Proposer and SyncCommittee have no duty limit
Role::Proposer | Role::SyncCommittee => Ok(None),
Role::ProposerPreferences => Ok(Some(validation_context.slots_per_epoch)),
// Per-proposal-slot roles: max duties capped at SLOTS_PER_EPOCH (one preferences packet /
// one self-build envelope per proposal slot). Overflow is IGNORE-classified.
Role::ProposerPreferences | Role::EnvelopeProposer => {
Ok(Some(validation_context.slots_per_epoch))
}
}
}

Expand Down Expand Up @@ -1436,7 +1435,8 @@ mod tests {
| Role::ValidatorRegistration
| Role::VoluntaryExit
| Role::PTCAttester
| Role::ProposerPreferences => DutyExecutor::Validator(PublicKeyBytes::empty()),
| Role::ProposerPreferences
| Role::EnvelopeProposer => DutyExecutor::Validator(PublicKeyBytes::empty()),
};
MessageId::new(&domain, role, &duty_executor)
}
Expand Down
Loading
Loading