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
31 changes: 31 additions & 0 deletions anchor/validator_store/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ValidatorIndex, HashSet<SyncSubnetId>>,
/// The indices of local validators with a PTC duty in this slot.
pub ptc_validators: Vec<ValidatorIndex>,
}

impl VotingAssignments {
Expand Down Expand Up @@ -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<F>(&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.
Expand Down Expand Up @@ -3201,6 +3218,7 @@ mod tests {
)
})
.collect(),
ptc_validators: Vec::new(),
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)));

Expand All @@ -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)));

Expand Down
89 changes: 86 additions & 3 deletions anchor/validator_store/src/metadata_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -312,14 +312,29 @@ impl<E: EthSpec, T: SlotClock + 'static> MetadataService<E, T> {
})
.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
Expand All @@ -334,11 +349,12 @@ impl<E: EthSpec, T: SlotClock + 'static> MetadataService<E, T> {
&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(())
}

Expand Down Expand Up @@ -1259,6 +1275,18 @@ pub fn filter_contributors_with_contributions<E: EthSpec>(
});
}

/// Build the local PTC validator-index list for one slot from LH-provided duties.
pub fn build_ptc_validators<F>(duties: &[PtcDuty], is_local_active: F) -> Vec<ValidatorIndex>
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};
Expand Down Expand Up @@ -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<PtcDuty> = 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"
);
}
}
8 changes: 8 additions & 0 deletions anchor/validator_store/src/metrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ pub static METADATA_SERVICE_SYNC_VALIDATORS: LazyLock<Result<IntGauge>> = LazyLo
)
});

/// Current count of PTC validators in VotingAssignments (CStar+; 0 pre-fork)
pub static METADATA_SERVICE_PTC_VALIDATORS: LazyLock<Result<IntGauge>> = 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<Result<IntCounter>> =
LazyLock::new(|| {
Expand Down
1 change: 1 addition & 0 deletions anchor/validator_store/src/testing/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading