Skip to content
Closed
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: 52 additions & 3 deletions anchor/common/ssv_types/src/msgid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub enum Role {
ValidatorRegistration,
VoluntaryExit,
AggregatorCommittee,
PTCCommittee,
}

impl From<Role> for [u8; 4] {
Expand All @@ -33,6 +34,7 @@ impl From<Role> for [u8; 4] {
Role::ValidatorRegistration => [4, 0, 0, 0],
Role::VoluntaryExit => [5, 0, 0, 0],
Role::AggregatorCommittee => [6, 0, 0, 0],
Role::PTCCommittee => [7, 0, 0, 0],
}
}
}
Expand All @@ -49,28 +51,39 @@ impl TryFrom<&[u8]> for Role {
[4, 0, 0, 0] => Ok(Role::ValidatorRegistration),
[5, 0, 0, 0] => Ok(Role::VoluntaryExit),
[6, 0, 0, 0] => Ok(Role::AggregatorCommittee),
[7, 0, 0, 0] => Ok(Role::PTCCommittee),
_ => Err(DecodeError::NoMatchingVariant),
}
}
}

impl Role {
/// Returns true if this role is a committee-based role (Committee or AggregatorCommittee).
/// Returns true if this role is a committee-based role (Committee, AggregatorCommittee, or
/// PTCCommittee).
///
/// Committee roles handle multiple validators in batched operations and have relaxed
/// validation rules compared to per-validator roles:
/// - Skip validator index validation (operators may have different validator sets)
/// - Skip slot advancement checks (allow processing "older" slots within 34-slot window)
/// - Have different message count limits and validator index occurrence limits
pub fn is_committee_role(self) -> bool {
matches!(self, Role::Committee | Role::AggregatorCommittee)
matches!(
self,
Role::Committee | Role::AggregatorCommittee | Role::PTCCommittee
)
}

pub fn max_round(self) -> Option<u64> {
// as per https://github.com/ssvlabs/ssv/blob/6382d4b52ea5e0efd9378a5a00ef481f39d6234f/message/validation/consensus_validation.go#L370
match self {
Role::Committee | Role::Aggregator | Role::AggregatorCommittee => Some(12),
Role::Proposer | Role::SyncCommittee => Some(6),
// PTC is expected near 75% of a 12s slot, leaving about 3s.
// With 2s quick round timeouts, only round 1 is likely to finish
// before slot end. Allow up to 4 rounds as a small local grace
// window for delayed starts or message loss; this is not a
// consensus-spec requirement.
Role::PTCCommittee => Some(4),
// These roles don't use QBFT consensus
Role::ValidatorRegistration | Role::VoluntaryExit => None,
}
Expand Down Expand Up @@ -143,7 +156,7 @@ impl MessageId {
pub fn duty_executor(&self) -> Option<DutyExecutor> {
// which kind of executor we need to get depends on the role
match self.role()? {
Role::Committee | Role::AggregatorCommittee => {
Role::Committee | Role::AggregatorCommittee | Role::PTCCommittee => {
self.0[24..].try_into().ok().map(DutyExecutor::Committee)
}
Role::Aggregator
Expand Down Expand Up @@ -307,4 +320,40 @@ mod tests {
None => panic!("Failed to extract duty executor"),
}
}

#[test]
fn ptc_uses_committee_duty_executor() {
// PTCCommittee must use Committee-style duty executor (CommitteeId),
// not Validator-style (PublicKeyBytes)
let domain = DomainType([0, 0, 0, 0]);
let committee_id = CommitteeId::from(vec![OperatorId(100), OperatorId(200)]);
let duty_executor = DutyExecutor::Committee(committee_id);

let msg_id = MessageId::new(&domain, Role::PTCCommittee, &duty_executor);

assert_eq!(msg_id.role(), Some(Role::PTCCommittee));

match msg_id.duty_executor() {
Some(DutyExecutor::Committee(id)) => assert_eq!(id, committee_id),
Some(DutyExecutor::Validator(_)) => {
panic!("PTCCommittee should use Committee duty executor, not Validator")
}
None => panic!("Failed to extract duty executor"),
}
}

#[test]
fn ptc_max_round_is_four() {
// PTC duty starts at 75% slot; Some(4) is the deliberate cap distinct
// from Committee's Some(12). Regressions copying the Committee value
// would compile silently — this guards against that.
assert_eq!(Role::PTCCommittee.max_round(), Some(4));
}

#[test]
fn ptc_is_committee_role() {
// is_committee_role drives the validator-index-mismatch skip and
// slot-advancement skip in message_validator. PTC must qualify.
assert!(Role::PTCCommittee.is_committee_role());
}
}
68 changes: 67 additions & 1 deletion anchor/message_validator/src/consensus_message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ mod tests {
use bls::{Hash256, PublicKeyBytes};
use openssl::hash::MessageDigest;
use ssv_types::{
OperatorId, RSA_SIGNATURE_SIZE, VariableList,
CommitteeId, OperatorId, RSA_SIGNATURE_SIZE, ValidatorIndex, VariableList,
consensus::{QbftMessage, QbftMessageType},
domain_type::DomainType,
message::{MsgType, SSVMessage, SignedSSVMessage},
Expand Down Expand Up @@ -1717,6 +1717,72 @@ mod tests {
assert_eq!(result, Ok(Some(expected_duty_count)));
}

#[test]
fn test_duty_limit_ptc_committee() {
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::PTCCommittee,
&DutyExecutor::Committee(CommitteeId([0u8; 32])),
);
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(SINGLE_NODE_COMMITTEE);
let mock_duties_provider = Arc::new(MockDutiesProvider {
voluntary_exit_duty_count: 0,
});
let map = create_operator_pub_keys(committee_info.committee_members.clone(), vec![]);

let validation_context = ValidationContext {
signed_ssv_message: &signed_msg,
committee_info: &committee_info,
role: Role::PTCCommittee,
received_at: now,
slots_per_epoch: 32,
epochs_per_sync_committee_period: 256,
sync_committee_size: 512,
slot_clock: slot_clock.clone(),
operator_pub_keys: &map,
fork_schedule: generate_fork_schedule(),
};

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

// V < slots_per_epoch: V is the binding constraint.
let small_cluster = vec![ValidatorIndex(0); 4];
let result = duty_limit(
&validation_context,
slot,
&small_cluster,
mock_duties_provider.clone(),
);
assert_eq!(result, Ok(Some(4)));

// V > slots_per_epoch: slot count is the binding constraint.
let large_cluster = vec![ValidatorIndex(0); 100];
let result = duty_limit(
&validation_context,
slot,
&large_cluster,
mock_duties_provider,
);
assert_eq!(result, Ok(Some(32)));
}

/// Helper function for testing role validation against fork schedules.
///
/// Tests whether a consensus message for a given role is properly accepted or rejected
Expand Down
26 changes: 23 additions & 3 deletions anchor/message_validator/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,7 @@ impl<S: SlotClock + 'static, D: DutiesProvider> Validator<S, D> {
// Get committee info based on role and duty executor
let network_state = self.network_state_rx.borrow();
let committee_info = match role {
Role::Committee | Role::AggregatorCommittee => {
Role::Committee | Role::AggregatorCommittee | Role::PTCCommittee => {
let committee_id = committee_id.ok_or(ValidationFailure::NonExistentCommitteeID)?;
network_state
.get_committee_info_by_committee_id(&committee_id)
Expand Down Expand Up @@ -859,6 +859,15 @@ pub(crate) fn validate_role_for_fork(
});
}

// Reject PTCCommittee before CStar fork (safety net)
if role == Role::PTCCommittee && active_fork < Fork::CStar {
return Err(ValidationFailure::RoleNotActiveBeforeFork {
role,
current_fork: active_fork,
minimum_fork: Fork::CStar,
});
}

Ok(())
}

Expand Down Expand Up @@ -925,7 +934,8 @@ fn message_lateness(
| Role::Aggregator
| Role::ValidatorRegistration
| Role::VoluntaryExit
| Role::AggregatorCommittee => validation_context.slots_per_epoch + LATE_SLOT_ALLOWANCE,
| Role::AggregatorCommittee
| Role::PTCCommittee => validation_context.slots_per_epoch + LATE_SLOT_ALLOWANCE,
};

let deadline = slot_start_time(slot + ttl, validation_context.slot_clock.clone())
Expand Down Expand Up @@ -1033,6 +1043,16 @@ fn duty_limit(
}
// Proposer and SyncCommittee have no duty limit
Role::Proposer | Role::SyncCommittee => Ok(None),
// PTC: each validator is eligible for the PTC only in the one slot of the
// epoch where it has its attestation duty (PTC pool = union of beacon
// committees for that slot; see `compute_ptc` at
// https://github.com/ethereum/consensus-specs/blob/4a4937bea332d72a55a76aaebcb97fbcdc189f69/specs/gloas/beacon-chain.md#new-compute_ptc).
// So per-epoch max duties = min(slots_per_epoch, V) where V is the
// cluster's local validator count.
Role::PTCCommittee => Ok(Some(std::cmp::min(
validation_context.slots_per_epoch,
validator_indices.len() as u64,
))),
}
}

Expand Down Expand Up @@ -1278,7 +1298,7 @@ mod tests {
pub(crate) fn create_message_id_for_test(role: Role) -> MessageId {
let domain = DomainType([0, 0, 0, 1]);
let duty_executor = match role {
Role::Committee | Role::AggregatorCommittee => {
Role::Committee | Role::AggregatorCommittee | Role::PTCCommittee => {
DutyExecutor::Committee(CommitteeId([0u8; 32]))
}
Role::Aggregator
Expand Down
Loading
Loading