diff --git a/anchor/validator_store/src/lib.rs b/anchor/validator_store/src/lib.rs index 60ae1df26..0aa34f095 100644 --- a/anchor/validator_store/src/lib.rs +++ b/anchor/validator_store/src/lib.rs @@ -1927,6 +1927,8 @@ pub struct VotingAssignments { /// Sync committee validators mapped to their subnet IDs. /// A validator may participate in multiple subnets. pub sync_validators_by_subnet: HashMap>, + /// The indices of local validators with a PTC duty in this slot. + pub ptc_validators: Vec, } impl VotingAssignments { @@ -1999,6 +2001,21 @@ impl VotingAssignments { count } + + /// Counts expected PTC partial signatures for a given cluster. + /// + /// PTC runs a separate QBFT instance from attestation/sync, so its batch + /// size is tallied independently rather than being folded into + /// `voting_message_count_for_committee`. + pub fn ptc_signature_count_for_committee(&self, is_in_committee: F) -> usize + where + F: Fn(&ValidatorIndex) -> bool, + { + self.ptc_validators + .iter() + .filter(|&idx| is_in_committee(idx)) + .count() + } } /// Aggregator-specific voting assignments, cached at 2/3 slot when selection proofs are known. @@ -3201,6 +3218,7 @@ mod tests { ) }) .collect(), + ptc_validators: Vec::new(), } } @@ -3325,6 +3343,17 @@ mod tests { assert_eq!(message_count, 4); } + #[test] + fn test_ptc_signature_count_with_filter() { + let mut voting_assignments = create_test_voting_assignments(vec![], vec![]); + voting_assignments.ptc_validators = + vec![ValidatorIndex(1), ValidatorIndex(2), ValidatorIndex(3)]; + + let in_committee = |idx: &ValidatorIndex| matches!(idx.0, 1 | 3); + let count = voting_assignments.ptc_signature_count_for_committee(in_committee); + assert_eq!(count, 2); + } + #[tokio::test] async fn test_validator_voting_assignments_watch_channel_waits_for_update() { // Test the watch channel behavior directly without creating a full AnchorValidatorStore @@ -3355,6 +3384,7 @@ mod tests { attesting_validators: vec![ValidatorIndex(1)], attesting_committees: HashMap::new(), sync_validators_by_subnet: HashMap::new(), + ptc_validators: Vec::new(), }; tx.send_replace(Some(Arc::new(voting_assignments))); @@ -3377,6 +3407,7 @@ mod tests { attesting_validators: vec![], attesting_committees: HashMap::new(), sync_validators_by_subnet: HashMap::new(), + ptc_validators: Vec::new(), }; tx.send_replace(Some(Arc::new(voting_assignments))); diff --git a/anchor/validator_store/src/metadata_service.rs b/anchor/validator_store/src/metadata_service.rs index c1580ab73..26b473802 100644 --- a/anchor/validator_store/src/metadata_service.rs +++ b/anchor/validator_store/src/metadata_service.rs @@ -8,7 +8,7 @@ use beacon_node_fallback::BeaconNodeFallback; use bls::PublicKeyBytes; use eth2::{ BeaconNodeHttpClient, - types::{BlockId, SyncContributionData}, + types::{BlockId, PtcDuty, SyncContributionData}, }; use fork::{Fork, ForkSchedule}; use futures::stream::{FuturesUnordered, StreamExt}; @@ -312,14 +312,29 @@ impl MetadataService { }) .unwrap_or_default(); + // Get PTC validators + let epoch = slot.epoch(E::slots_per_epoch()); + let ptc_validators = if self.fork_schedule.active_fork(epoch) >= Fork::CStar { + let duties = self.duties_service.get_ptc_duties_for_slot(slot); + build_ptc_validators(&duties, |pubkey| { + self.validator_store + .get_validator_and_cluster(*pubkey) + .is_ok() + }) + } else { + Vec::new() + }; + let attester_count = attesting_validators.len(); let sync_count = sync_validators_by_subnet.len(); + let ptc_count = ptc_validators.len(); let voting_assignments = VotingAssignments { slot, attesting_validators, attesting_committees, sync_validators_by_subnet, + ptc_validators, }; self.validator_store @@ -334,11 +349,12 @@ impl MetadataService { &metrics::METADATA_SERVICE_SYNC_VALIDATORS, sync_count as i64, ); - if attester_count == 0 && sync_count == 0 { + metrics::set_gauge(&metrics::METADATA_SERVICE_PTC_VALIDATORS, ptc_count as i64); + if attester_count == 0 && sync_count == 0 && ptc_count == 0 { metrics::inc_counter(&metrics::METADATA_SERVICE_EMPTY_ASSIGNMENTS_TOTAL); } - trace!(%slot, attester_count, sync_count, "Published VotingAssignments at slot start"); + trace!(%slot, attester_count, sync_count, ptc_count, "Published VotingAssignments at slot start"); Ok(()) } @@ -1259,6 +1275,18 @@ pub fn filter_contributors_with_contributions( }); } +/// Build the local PTC validator-index list for one slot from LH-provided duties. +pub fn build_ptc_validators(duties: &[PtcDuty], is_local_active: F) -> Vec +where + F: Fn(&PublicKeyBytes) -> bool, +{ + duties + .iter() + .filter(|d| is_local_active(&d.pubkey)) + .map(|d| ValidatorIndex(d.validator_index as usize)) + .collect() +} + #[cfg(test)] mod tests { use bls::{AggregateSignature, FixedBytesExtended, Signature}; @@ -2241,4 +2269,59 @@ mod tests { let result_distance_2 = calculate_attestation_score(&data, Some(Slot::new(3230))); assert!((result_distance_2.score - 201.333333).abs() < 0.001); } + + // ═══════════════════════════════════════════════════════════════════════════════════ + // build_ptc_validators tests + // ═══════════════════════════════════════════════════════════════════════════════════ + + fn create_ptc_duty(pubkey_byte: u8, validator_index: u64) -> PtcDuty { + PtcDuty { + pubkey: PublicKeyBytes::deserialize(&[pubkey_byte; 48]).expect("valid length"), + validator_index, + slot: Slot::new(1000), + } + } + + #[test] + fn test_build_ptc_validators_empty_input() { + let duties: Vec = vec![]; + let out = build_ptc_validators(&duties, |_| true); + assert!(out.is_empty(), "Empty input must produce empty output"); + } + + #[test] + fn test_build_ptc_validators_all_local() { + let duties = vec![ + create_ptc_duty(0x01, 100), + create_ptc_duty(0x02, 200), + create_ptc_duty(0x03, 300), + ]; + let out = build_ptc_validators(&duties, |_| true); + assert_eq!( + out, + vec![ + ValidatorIndex(100), + ValidatorIndex(200), + ValidatorIndex(300) + ], + "All-local predicate must keep every duty in order and convert u64 -> usize" + ); + } + + #[test] + fn test_build_ptc_validators_filters_liquidated() { + // Simulate ClusterLiquidated: predicate returns false for the second pubkey. + let liquidated = PublicKeyBytes::deserialize(&[0x02; 48]).expect("valid length"); + let duties = vec![ + create_ptc_duty(0x01, 100), + create_ptc_duty(0x02, 200), + create_ptc_duty(0x03, 300), + ]; + let out = build_ptc_validators(&duties, |pubkey| *pubkey != liquidated); + assert_eq!( + out, + vec![ValidatorIndex(100), ValidatorIndex(300)], + "Liquidated-cluster duty must be excluded" + ); + } } diff --git a/anchor/validator_store/src/metrics.rs b/anchor/validator_store/src/metrics.rs index 6e18ce97f..a7b05166e 100644 --- a/anchor/validator_store/src/metrics.rs +++ b/anchor/validator_store/src/metrics.rs @@ -46,6 +46,14 @@ pub static METADATA_SERVICE_SYNC_VALIDATORS: LazyLock> = LazyLo ) }); +/// Current count of PTC validators in VotingAssignments (CStar+; 0 pre-fork) +pub static METADATA_SERVICE_PTC_VALIDATORS: LazyLock> = LazyLock::new(|| { + try_create_int_gauge( + "anchor_metadata_service_ptc_validators", + "Count of validators with PTC duties this slot", + ) +}); + /// Count of slots where VotingAssignments was empty pub static METADATA_SERVICE_EMPTY_ASSIGNMENTS_TOTAL: LazyLock> = LazyLock::new(|| { diff --git a/anchor/validator_store/src/testing/common.rs b/anchor/validator_store/src/testing/common.rs index 70a1ca5dd..970cf4e49 100644 --- a/anchor/validator_store/src/testing/common.rs +++ b/anchor/validator_store/src/testing/common.rs @@ -312,6 +312,7 @@ impl ValidatorStoreTestHarness { attesting_validators, attesting_committees, sync_validators_by_subnet: HashMap::new(), + ptc_validators: Vec::new(), }), beacon_vote: ssv_types::consensus::BeaconVote { block_root: Hash256::zero(),