You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Implement the sign_payload_attestation LH trait method on AnchorValidatorStore in anchor/validator_store/src/lib.rs, modeled on sign_committee_attestations (around lib.rs:1459). The method runs committee-mode QBFT over PayloadAttestationVote constructed from LH's passed PayloadAttestationData, then signs the decided data with each local PTC-assigned validator's BLS share under Domain::PTCAttester. LH's PayloadAttestationService calls this method per validator at 75% slot; Anchor batches internally per cluster.
Critical: must NOT touch slashing protection. Per CR-1 of the SIP-94 security cross-cuts, PTC and proposer-preferences signing must not call slashing_protection.check_* or slashing_protection.insert_*. LH's reference implementation bypasses doppelganger and skips the slashing DB; Anchor must do the same.
Context
Per SIP-94 §3 (ssvlabs/SIPs#94), each PTC-assigned validator signs PayloadAttestationData { beacon_block_root, slot, payload_present, blob_data_available } under DOMAIN_PTC_ATTESTER. In SSV, a cluster runs one QBFT instance per slot over a stripped PayloadAttestationVote (per #1034), then each validator on PTC for the slot contributes a partial signature on the decided data. Partial sigs reconstruct into a full PayloadAttestationMessage that LH's PayloadAttestationService submits.
This issue brings together #1032 (role wire surface + PostConsensus binding), #1034 (PayloadAttestationVote + value checker), #1035 (qbft_manager routing + QbftDecidable impl), and #1036 / PR #1053 (publishes per-slot PTC validator set on VotingAssignments for batch sizing). The follow-up service-spawn issue wires LH's PayloadAttestationService to call this method.
Suggested approach
Add a sign_payload_attestation method on AnchorValidatorStore modeled on sign_committee_attestations (lib.rs:1459). High-level flow:
Look up (validator, cluster) via get_validator_and_cluster(validator_pubkey) (lib.rs:239+).
Await get_voting_assignments(slot) to load the Phase-1 snapshot.
Defensive race check (see Race-handling below). If the LH-passed validator is not in voting_assignments.ptc_validators, return SpecificError::ValidatorNotInPtc { validator_pubkey, slot } immediately. Mirrors the existing pattern at lib.rs:2644-2657 (produce_selection_proof) and lib.rs:2756-2770 (produce_sync_selection_proof).
SSZ signing-root of decided PayloadAttestationData against Domain::PTCAttester.
Size the partial-sig batch: let batch_size = voting_assignments.ptc_signature_count_for_committee(|idx| committee_validator_indices.contains(idx)). Then call the shared committee-mode helper (collect_signature with CollectionMode::Committee { validator_partial_signature_batch_size: batch_size, base_hash }, role = Role::PTCCommittee, kind = PartialSignatureKind::PostConsensus). PTC reuses PostConsensus per feat: add Role::PTCCommittee with message-validator fork-gate #1032 (no new partial-sig kind variant).
Return PayloadAttestationMessage { validator_index, data, signature } per validator.
No slashing-DB calls at any step. The method must not invoke slashing_protection.check_* or slashing_protection.insert_*.
Race-handling (Phase-1 snapshot vs LH's 75% caller)
Phase 1 (metadata_service.rs:320) and LH's PayloadAttestationService (payload_attestation_service.rs:124,178) both read RwLock<PtcMap>, roughly 9 seconds apart. Cache state can change between slot start and 75% slot:
Process startup mid-epoch with a slow BN: LH polling iteration 1 doesn't finish before the next slot boundary. Phase 1 reads empty.
Startup before validator-indices resolve: poll_beacon_ptc_attesters_for_epoch early-returns on local_indices.is_empty() (LH duties_service.rs:1846-1852), a silent no-op poll.
Dependent-root change mid-slot.
CStar slot 0 is largely mitigated by LH's pre-fetch (current_epoch + 1 < gloas_fork_epoch gate at duties_service.rs:720; polls current and next epoch every iteration at duties_service.rs:1785-1814).
Mechanical consequence if not handled:signature_collector/src/lib.rs:254 checks batch.len() == validator_partial_signature_batch_size exactly. If the snapshot disagrees with what LH passes, the batch either never reaches size or sends prematurely with later sigs orphaned. All outcomes: cluster misses the PTC slot. Per SIP-94 §3 PTC is missed reward, not slashable, but the failure is silent and operationally invisible.
Mitigation: the step-3 contains-check above. Add a ValidatorNotInPtc { validator_pubkey, slot } variant to SpecificError (existing twins at lib.rs:2141-2150). The comment at lib.rs:2644-2647 documents the rationale verbatim ("both this call and VotingAssignments come from DutiesService...").
Acceptance criteria
Anchor's sign_payload_attestation returns a valid PayloadAttestationMessage per validator, signed under Domain::PTCAttester, when invoked per validator on PTC for the slot.
Defensive race check. Returns SpecificError::ValidatorNotInPtc { validator_pubkey, slot } when the LH-passed validator is absent from voting_assignments.ptc_validators, instead of producing a partial sig that desyncs the signature collector. New ValidatorNotInPtc variant added to SpecificError.
Single-flight per (cluster, slot): concurrent sign_payload_attestation calls from multiple local PTC validators in the same cluster start or join exactly one in-flight QBFT instance for that (cluster, slot); all of them sign the same decided PayloadAttestationData. Late duplicate calls arriving after consensus completes reuse the completed decision; if the instance timed out or failed, they receive the same failure rather than starting a new instance.
Zero slashing-DB interactions asserted by a regression test:
4-operator cluster integration test: all 4 validators on PTC for a slot; reconstruction yields valid messages for all four.
All pre-existing tests pass.
Open questions
collect_signature call shape. The Obsidian sketch uses a positional-args API (collect_signature(kind, role, ..., &validator, &cluster, signing_root, slot)) that does not match the current shared committee-mode helpers in validator_store/src/lib.rs (which use a CollectionMode::Committee builder pattern). Rebuild step 9 against the current sign_committee_attestations shape at lib.rs:1461+.
Risks
Round-budget exhaustion at 75% deadline. 75% slot start with QUICK_TIMEOUT = 2s on a 12s mainnet slot gives ~1 round before the deadline. Mitigation: max_round = Some(4) shipped in feat: add Role::PTCCommittee with message-validator fork-gate #1032. If the cluster still misses, PTC duty is missed (missed reward, not slashing).
CR-1: slashing-DB isolation. Easy to copy-paste from sign_committee_attestations and accidentally inherit a check_and_insert_block_proposal call. Regression test enforces.
Sibling race in sign_committee_attestations. Out of scope here, but worth flagging: sign_committee_attestations (lib.rs:1458+) doesn't have an equivalent defensive contains-check; voting_message_count_for_committee (lib.rs:1411-1413) silently returns the snapshot count regardless of consistency with the LH-passed attestations Vec. Same race shape applies. Separate issue if/when addressed.
Goal
Implement the
sign_payload_attestationLH trait method onAnchorValidatorStoreinanchor/validator_store/src/lib.rs, modeled onsign_committee_attestations(aroundlib.rs:1459). The method runs committee-mode QBFT overPayloadAttestationVoteconstructed from LH's passedPayloadAttestationData, then signs the decided data with each local PTC-assigned validator's BLS share underDomain::PTCAttester. LH'sPayloadAttestationServicecalls this method per validator at 75% slot; Anchor batches internally per cluster.Critical: must NOT touch slashing protection. Per CR-1 of the SIP-94 security cross-cuts, PTC and proposer-preferences signing must not call
slashing_protection.check_*orslashing_protection.insert_*. LH's reference implementation bypasses doppelganger and skips the slashing DB; Anchor must do the same.Context
Per SIP-94 §3 (ssvlabs/SIPs#94), each PTC-assigned validator signs
PayloadAttestationData { beacon_block_root, slot, payload_present, blob_data_available }underDOMAIN_PTC_ATTESTER. In SSV, a cluster runs one QBFT instance per slot over a strippedPayloadAttestationVote(per #1034), then each validator on PTC for the slot contributes a partial signature on the decided data. Partial sigs reconstruct into a fullPayloadAttestationMessagethat LH'sPayloadAttestationServicesubmits.This issue brings together #1032 (role wire surface +
PostConsensusbinding), #1034 (PayloadAttestationVote+ value checker), #1035 (qbft_manager routing +QbftDecidableimpl), and #1036 / PR #1053 (publishes per-slot PTC validator set onVotingAssignmentsfor batch sizing). The follow-up service-spawn issue wires LH'sPayloadAttestationServiceto call this method.Suggested approach
Add a
sign_payload_attestationmethod onAnchorValidatorStoremodeled onsign_committee_attestations(lib.rs:1459). High-level flow:(validator, cluster)viaget_validator_and_cluster(validator_pubkey)(lib.rs:239+).get_voting_assignments(slot)to load the Phase-1 snapshot.voting_assignments.ptc_validators, returnSpecificError::ValidatorNotInPtc { validator_pubkey, slot }immediately. Mirrors the existing pattern atlib.rs:2644-2657(produce_selection_proof) andlib.rs:2756-2770(produce_sync_selection_proof).PayloadAttestationVote { beacon_block_root, payload_present, blob_data_available }from LH's passeddata(slot dropped here; pinned by the QBFT instance per feat(ssv_types): add PayloadAttestationVote with QbftData and value checker #1034).consensus.decide_instance(PTCCommitteeInstanceId { committee, instance_height: slot.as_usize().into() }, vote, validator, TimeoutMode::SlotTime { instance_start_time: chain_spec.get_payload_attestation_due() }, &cluster.cluster_members).PayloadAttestationData { beacon_block_root, slot, payload_present, blob_data_available }(slot from LH'sdata.slot).domain_hash = self.get_domain(slot.epoch(), Domain::PTCAttester).PayloadAttestationDataagainstDomain::PTCAttester.let batch_size = voting_assignments.ptc_signature_count_for_committee(|idx| committee_validator_indices.contains(idx)). Then call the shared committee-mode helper (collect_signaturewithCollectionMode::Committee { validator_partial_signature_batch_size: batch_size, base_hash },role = Role::PTCCommittee,kind = PartialSignatureKind::PostConsensus). PTC reusesPostConsensusper feat: add Role::PTCCommittee with message-validator fork-gate #1032 (no new partial-sig kind variant).PayloadAttestationMessage { validator_index, data, signature }per validator.No slashing-DB calls at any step. The method must not invoke
slashing_protection.check_*orslashing_protection.insert_*.Race-handling (Phase-1 snapshot vs LH's 75% caller)
Phase 1 (
metadata_service.rs:320) and LH'sPayloadAttestationService(payload_attestation_service.rs:124,178) both readRwLock<PtcMap>, roughly 9 seconds apart. Cache state can change between slot start and 75% slot:poll_beacon_ptc_attesters_for_epochearly-returns onlocal_indices.is_empty()(LHduties_service.rs:1846-1852), a silent no-op poll.CStar slot 0 is largely mitigated by LH's pre-fetch (
current_epoch + 1 < gloas_fork_epochgate atduties_service.rs:720; polls current and next epoch every iteration atduties_service.rs:1785-1814).Mechanical consequence if not handled:
signature_collector/src/lib.rs:254checksbatch.len() == validator_partial_signature_batch_sizeexactly. If the snapshot disagrees with what LH passes, the batch either never reaches size or sends prematurely with later sigs orphaned. All outcomes: cluster misses the PTC slot. Per SIP-94 §3 PTC is missed reward, not slashable, but the failure is silent and operationally invisible.Mitigation: the step-3 contains-check above. Add a
ValidatorNotInPtc { validator_pubkey, slot }variant toSpecificError(existing twins atlib.rs:2141-2150). The comment atlib.rs:2644-2647documents the rationale verbatim ("both this call andVotingAssignmentscome fromDutiesService...").Acceptance criteria
sign_payload_attestationreturns a validPayloadAttestationMessageper validator, signed underDomain::PTCAttester, when invoked per validator on PTC for the slot.SpecificError::ValidatorNotInPtc { validator_pubkey, slot }when the LH-passed validator is absent fromvoting_assignments.ptc_validators, instead of producing a partial sig that desyncs the signature collector. NewValidatorNotInPtcvariant added toSpecificError.(cluster, slot): concurrentsign_payload_attestationcalls from multiple local PTC validators in the same cluster start or join exactly one in-flight QBFT instance for that(cluster, slot); all of them sign the same decidedPayloadAttestationData. Late duplicate calls arriving after consensus completes reuse the completed decision; if the instance timed out or failed, they receive the same failure rather than starting a new instance.Open questions
collect_signaturecall shape. The Obsidian sketch uses a positional-args API (collect_signature(kind, role, ..., &validator, &cluster, signing_root, slot)) that does not match the current shared committee-mode helpers invalidator_store/src/lib.rs(which use aCollectionMode::Committeebuilder pattern). Rebuild step 9 against the currentsign_committee_attestationsshape atlib.rs:1461+.Risks
QUICK_TIMEOUT = 2son a 12s mainnet slot gives ~1 round before the deadline. Mitigation:max_round = Some(4)shipped in feat: add Role::PTCCommittee with message-validator fork-gate #1032. If the cluster still misses, PTC duty is missed (missed reward, not slashing).sign_committee_attestationsand accidentally inherit acheck_and_insert_block_proposalcall. Regression test enforces.sign_committee_attestations. Out of scope here, but worth flagging:sign_committee_attestations(lib.rs:1458+) doesn't have an equivalent defensive contains-check;voting_message_count_for_committee(lib.rs:1411-1413) silently returns the snapshot count regardless of consistency with the LH-passedattestationsVec. Same race shape applies. Separate issue if/when addressed.