diff --git a/anchor/common/ssv_types/src/msgid.rs b/anchor/common/ssv_types/src/msgid.rs index 33c861c58..d506d989d 100644 --- a/anchor/common/ssv_types/src/msgid.rs +++ b/anchor/common/ssv_types/src/msgid.rs @@ -21,6 +21,7 @@ pub enum Role { ValidatorRegistration, VoluntaryExit, AggregatorCommittee, + PTCCommittee, } impl From for [u8; 4] { @@ -33,6 +34,7 @@ impl From 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], } } } @@ -49,13 +51,15 @@ 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: @@ -63,7 +67,10 @@ impl Role { /// - 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 { @@ -71,6 +78,12 @@ impl Role { 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, } @@ -143,7 +156,7 @@ impl MessageId { pub fn duty_executor(&self) -> Option { // 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 @@ -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()); + } } diff --git a/anchor/message_validator/src/consensus_message.rs b/anchor/message_validator/src/consensus_message.rs index 6a5addf78..64a8f8b7b 100644 --- a/anchor/message_validator/src/consensus_message.rs +++ b/anchor/message_validator/src/consensus_message.rs @@ -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}, @@ -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 diff --git a/anchor/message_validator/src/lib.rs b/anchor/message_validator/src/lib.rs index 10f0395e3..d3cc9a335 100644 --- a/anchor/message_validator/src/lib.rs +++ b/anchor/message_validator/src/lib.rs @@ -450,7 +450,7 @@ impl Validator { // 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) @@ -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(()) } @@ -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()) @@ -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, + ))), } } @@ -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 diff --git a/anchor/message_validator/src/partial_signature.rs b/anchor/message_validator/src/partial_signature.rs index b32d7147a..6f36e4380 100644 --- a/anchor/message_validator/src/partial_signature.rs +++ b/anchor/message_validator/src/partial_signature.rs @@ -5,7 +5,10 @@ use slot_clock::SlotClock; use ssv_types::{ OperatorId, msgid::Role, - partial_sig::{PartialSignatureKind, PartialSignatureMessages, PartialSignatureMessagesError}, + partial_sig::{ + PartialSignatureKind, PartialSignatureMessage, PartialSignatureMessages, + PartialSignatureMessagesError, + }, }; use ssz::Decode; use types::consts::altair::SYNC_COMMITTEE_SUBNET_COUNT; @@ -144,7 +147,7 @@ fn validate_partial_signature_message_semantics( fn partial_signature_type_matches_role(kind: PartialSignatureKind, role: Role) -> bool { match role { - Role::Committee => kind == PartialSignatureKind::PostConsensus, + Role::Committee | Role::PTCCommittee => kind == PartialSignatureKind::PostConsensus, Role::Aggregator => { kind == PartialSignatureKind::PostConsensus || kind == PartialSignatureKind::SelectionProofPartialSig @@ -314,6 +317,12 @@ fn validate_partial_sig_messages_by_duty_logic( } } } + Role::PTCCommittee => { + // PTC produces exactly one partial signature per locally-assigned + // validator per slot. + validate_ptc_committee_message_count(message_count, validator_count)?; + validate_validator_index_occurrence_limit(&partial_signature_messages.messages, 1)?; + } // Per-validator roles only allow one signature Role::Aggregator | Role::Proposer | Role::ValidatorRegistration | Role::VoluntaryExit => { if message_count > 1 { @@ -328,6 +337,40 @@ fn validate_partial_sig_messages_by_duty_logic( Ok(()) } +fn validate_validator_index_occurrence_limit( + messages: &[PartialSignatureMessage], + limit: usize, +) -> Result<(), ValidationFailure> { + let mut validator_index_count = HashMap::new(); + for message in messages { + let count = validator_index_count + .entry(message.validator_index) + .or_insert(0usize); + *count += 1; + if *count > limit { + return Err(ValidationFailure::TooManyValidatorIndexOccurrences { + validator_index: message.validator_index, + got: *count, + limit, + }); + } + } + Ok(()) +} + +fn validate_ptc_committee_message_count( + message_count: usize, + validator_count: usize, +) -> Result<(), ValidationFailure> { + if message_count > validator_count { + return Err(ValidationFailure::TooManyPartialSignatureMessages { + got: message_count, + limit: validator_count, + }); + } + Ok(()) +} + #[cfg(test)] mod tests { use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -1203,6 +1246,10 @@ mod tests { const LATE_SLOT_ALLOWANCE_TEST: u64 = 2; const TTL_SLOTS: u64 = SLOTS_PER_EPOCH_TEST + LATE_SLOT_ALLOWANCE_TEST; // 34 slots const BEYOND_TTL_SLOTS: u64 = 40; + // Past the proposer/sync TTL (1 + LATE_SLOT_ALLOWANCE_TEST = 3 slots), well + // within the committee TTL (TTL_SLOTS = 34). Lets a test prove a role is in + // the committee TTL bucket rather than just inside the long-TTL boundary. + const COMMITTEE_TTL_BUCKET_SLOTS: u64 = 20; // Helper to create validation context for TTL tests fn create_ttl_validation_context<'a>( @@ -1632,6 +1679,145 @@ mod tests { ); } + fn create_partial_sig_message(validator_index: ValidatorIndex) -> PartialSignatureMessage { + PartialSignatureMessage { + partial_signature: Signature::empty(), + signing_root: Hash256::from([0u8; 32]), + signer: OperatorId(1), + validator_index, + } + } + + #[test] + fn test_ptc_committee_validator_index_occurrence_limit() { + // PTC allows exactly 1 occurrence per validator index per partial-sig packet. + let one = vec![create_partial_sig_message(ValidatorIndex(100))]; + assert!(validate_validator_index_occurrence_limit(&one, 1).is_ok()); + + let two = vec![ + create_partial_sig_message(ValidatorIndex(100)), + create_partial_sig_message(ValidatorIndex(100)), + ]; + assert_validation_error( + validate_validator_index_occurrence_limit(&two, 1), + |f| { + matches!( + f, + ValidationFailure::TooManyValidatorIndexOccurrences { limit: 1, .. } + ) + }, + "TooManyValidatorIndexOccurrences (PTC limit)", + ); + } + + #[test] + fn test_ptc_committee_message_count_exceeds_validator_count() { + // V+1 messages trip the structural message-count cap (V). + let v = FOUR_NODE_COMMITTEE; + assert!(validate_ptc_committee_message_count(v, v).is_ok()); + assert_validation_error( + validate_ptc_committee_message_count(v + 1, v), + |f| { + matches!( + f, + ValidationFailure::TooManyPartialSignatureMessages { limit, .. } if *limit == v + ) + }, + "TooManyPartialSignatureMessages (PTC count > V)", + ); + } + + #[test] + fn test_ptc_committee_rejected_before_cstar() { + 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::PTCCommittee, + PartialSignatureKind::PostConsensus, + OperatorId(1), + &private_key, + ); + + let fork_schedule = generate_fork_schedule(Fork::Boole); + let validation_context = create_test_validation_context_with_fork( + &signed_msg, + &committee_info, + Role::PTCCommittee, + &map, + Some(fork_schedule), + ); + + let result = validate_role_for_fork(Slot::new(0), &validation_context); + assert_validation_error( + result, + |failure| { + matches!( + failure, + ValidationFailure::RoleNotActiveBeforeFork { + minimum_fork: Fork::CStar, + .. + } + ) + }, + "RoleNotActiveBeforeFork (PTCCommittee pre-CStar)", + ); + } + + #[test] + fn test_ptc_committee_within_ttl_accepted() { + 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::PTCCommittee, + PartialSignatureKind::PostConsensus, + OperatorId(1), + &private_key, + ); + + // COMMITTEE_TTL_BUCKET_SLOTS = 20 is past the proposer/sync TTL (3) but + // well inside the committee TTL (34). Accepting here proves PTC is + // bucketed as committee, not just inside a long TTL. + let validation_context = create_ttl_validation_context( + &signed_msg, + &committee_info, + Role::PTCCommittee, + &map, + COMMITTEE_TTL_BUCKET_SLOTS, + generate_fork_schedule(Fork::CStar), + ); + + let result = validate_partial_signature_message( + validation_context, + &mut DutyState::new(64), + Arc::new(MockDutiesProvider { + voluntary_exit_duty_count: 0, + }), + ); + + assert!(result.is_ok(), "Expected ok but got: {result:?}"); + } + + #[test] + fn test_ptc_committee_binds_post_consensus_only() { + // PTC binds to PartialSignatureKind::PostConsensus. + // Mapping to ValidationFailure::PartialSignatureTypeRoleMismatch is covered + // end-to-end by test_partial_signature_message_with_invalid_type_for_role. + assert!(partial_signature_type_matches_role( + PartialSignatureKind::PostConsensus, + Role::PTCCommittee, + )); + assert!(!partial_signature_type_matches_role( + PartialSignatureKind::RandaoPartialSig, + Role::PTCCommittee, + )); + } + #[test] fn test_voluntary_exit_beyond_ttl_rejected() { // Setup diff --git a/anchor/qbft_manager/src/lib.rs b/anchor/qbft_manager/src/lib.rs index 60995c1d4..c2553bf25 100644 --- a/anchor/qbft_manager/src/lib.rs +++ b/anchor/qbft_manager/src/lib.rs @@ -270,7 +270,7 @@ impl QbftManager { Some(Role::Aggregator) => ValidatorDutyKind::Aggregator, Some(Role::SyncCommittee) => ValidatorDutyKind::SyncCommitteeAggregator, // Committee roles use DutyExecutor::Committee, not Validator - Some(Role::Committee | Role::AggregatorCommittee) + Some(Role::Committee | Role::AggregatorCommittee | Role::PTCCommittee) // These roles don't use QBFT consensus | Some(Role::ValidatorRegistration | Role::VoluntaryExit) | None => { @@ -330,6 +330,13 @@ impl QbftManager { }, ) } + Some(Role::PTCCommittee) => { + // TODO(cstar): wire PTC instance routing and add pre-CStar + // fork gate (mirror `AggregatorCommittee` arm above). + let slot = types::Slot::new(qbft_message.height); + warn!(%slot, "Ignoring PTCCommittee message; routing not wired"); + Err(QbftError::RoleNotActive) + } // Validator roles should use DutyExecutor::Validator, not Committee Some(Role::Aggregator | Role::Proposer | Role::SyncCommittee) // These roles don't use QBFT consensus