From d512223cb21134f43eb2b2eb49369901f41b78b6 Mon Sep 17 00:00:00 2001 From: shane-moore Date: Fri, 22 May 2026 08:25:22 -0700 Subject: [PATCH 1/3] feat: add ptc duty to metadata service --- anchor/validator_store/src/lib.rs | 21 ++++ .../validator_store/src/metadata_service.rs | 95 ++++++++++++++++++- anchor/validator_store/src/metrics.rs | 8 ++ anchor/validator_store/src/testing/common.rs | 1 + 4 files changed, 122 insertions(+), 3 deletions(-) diff --git a/anchor/validator_store/src/lib.rs b/anchor/validator_store/src/lib.rs index 60ae1df26..488e399aa 100644 --- a/anchor/validator_store/src/lib.rs +++ b/anchor/validator_store/src/lib.rs @@ -1927,6 +1927,9 @@ 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. Empty when the operator has + /// no local PTC duties in this slot. + pub ptc_validators: Vec, } impl VotingAssignments { @@ -1999,6 +2002,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 +3219,7 @@ mod tests { ) }) .collect(), + ptc_validators: Vec::new(), } } @@ -3355,6 +3374,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 +3397,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..bb40d4116 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,31 @@ impl MetadataService { }) .unwrap_or_default(); + // Get PTC validators (CStar+ only). Duties are read from LH's epoch-cached + // `PtcMap`; the per-pubkey check is the same `get_validator_and_cluster` + // shape used by the Phase-2/3 consensus-data builder. + 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 +351,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 +1277,22 @@ pub fn filter_contributors_with_contributions( }); } +/// Build the local PTC validator-index list for one slot from LH-provided duties. +/// +/// `is_local_active` returns true for pubkeys whose validator resolves to a +/// non-liquidated SSV cluster on this operator (i.e. +/// `get_validator_and_cluster(pubkey).is_ok()`). +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 +2275,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(), From 05d9e396b5a00e89657c443cefac38152b9f657b Mon Sep 17 00:00:00 2001 From: shane-moore Date: Fri, 22 May 2026 09:33:18 -0700 Subject: [PATCH 2/3] chore: add ptc count test --- anchor/validator_store/src/lib.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/anchor/validator_store/src/lib.rs b/anchor/validator_store/src/lib.rs index 488e399aa..4253518ef 100644 --- a/anchor/validator_store/src/lib.rs +++ b/anchor/validator_store/src/lib.rs @@ -2014,7 +2014,7 @@ impl VotingAssignments { { self.ptc_validators .iter() - .filter(|idx| is_in_committee(idx)) + .filter(|&idx| is_in_committee(idx)) .count() } } @@ -3344,6 +3344,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 From 506480d9c5c780fe95c341d3f545d0c3b81023f3 Mon Sep 17 00:00:00 2001 From: shane-moore Date: Fri, 22 May 2026 10:29:48 -0700 Subject: [PATCH 3/3] chore: remove useless ptc fn comments --- anchor/validator_store/src/lib.rs | 3 +-- anchor/validator_store/src/metadata_service.rs | 8 +------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/anchor/validator_store/src/lib.rs b/anchor/validator_store/src/lib.rs index 4253518ef..0aa34f095 100644 --- a/anchor/validator_store/src/lib.rs +++ b/anchor/validator_store/src/lib.rs @@ -1927,8 +1927,7 @@ 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. Empty when the operator has - /// no local PTC duties in this slot. + /// The indices of local validators with a PTC duty in this slot. pub ptc_validators: Vec, } diff --git a/anchor/validator_store/src/metadata_service.rs b/anchor/validator_store/src/metadata_service.rs index bb40d4116..26b473802 100644 --- a/anchor/validator_store/src/metadata_service.rs +++ b/anchor/validator_store/src/metadata_service.rs @@ -312,9 +312,7 @@ impl MetadataService { }) .unwrap_or_default(); - // Get PTC validators (CStar+ only). Duties are read from LH's epoch-cached - // `PtcMap`; the per-pubkey check is the same `get_validator_and_cluster` - // shape used by the Phase-2/3 consensus-data builder. + // 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); @@ -1278,10 +1276,6 @@ pub fn filter_contributors_with_contributions( } /// Build the local PTC validator-index list for one slot from LH-provided duties. -/// -/// `is_local_active` returns true for pubkeys whose validator resolves to a -/// non-liquidated SSV cluster on this operator (i.e. -/// `get_validator_and_cluster(pubkey).is_ok()`). pub fn build_ptc_validators(duties: &[PtcDuty], is_local_active: F) -> Vec where F: Fn(&PublicKeyBytes) -> bool,