Skip to content

feat(validator_store): implement sign_payload_attestation #1037

Description

@shane-moore

Goal

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:

  1. Look up (validator, cluster) via get_validator_and_cluster(validator_pubkey) (lib.rs:239+).
  2. Await get_voting_assignments(slot) to load the Phase-1 snapshot.
  3. 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).
  4. Construct PayloadAttestationVote { beacon_block_root, payload_present, blob_data_available } from LH's passed data (slot dropped here; pinned by the QBFT instance per feat(ssv_types): add PayloadAttestationVote with QbftData and value checker #1034).
  5. Run committee-mode QBFT: 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).
  6. After consensus, reconstruct full PayloadAttestationData { beacon_block_root, slot, payload_present, blob_data_available } (slot from LH's data.slot).
  7. Compute domain_hash = self.get_domain(slot.epoch(), Domain::PTCAttester).
  8. SSZ signing-root of decided PayloadAttestationData against Domain::PTCAttester.
  9. 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).
  10. 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:
#[tokio::test]
async fn sign_payload_attestation_does_not_touch_slashing_db() {
    let mock = SlashingDbMock::new();
    let store = ValidatorStore::with_db(mock.clone());
    let _ = store.sign_payload_attestation(pubkey, data).await;
    assert_eq!(mock.check_calls(), 0);
    assert_eq!(mock.insert_calls(), 0);
}
  • 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.

Metadata

Metadata

Assignees

Labels

epbsePBS / EIP-7732 / Gloas implementation

Type

No type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions