Goal
Add a new metadata-service phase loop in anchor/validator_store/src/metadata_service.rs that pre-fetches PayloadAttestationData from the BN shortly before LH's PayloadAttestationService starts QBFT at 75% slot. The loop publishes the fetched context on a watch channel that sign_payload_attestation (follow-up issue) reads as its QBFT initial value. Modeled on the existing Phase-2 voting context loop in metadata_service.rs.
Context
Per SIP-94 §3 (ssvlabs/SIPs#94), PTC requires one QBFT instance per cluster per slot. Each operator needs an initial value for that instance; LH's PayloadAttestationService fetches per-operator inside its own loop, which gives Anchor no place to stage that initial value at the metadata layer.
This phase produces the operator-local initial value by pre-fetching PayloadAttestationData through that operator's BeaconNodeFallback. Honest operators can still disagree on payload_present / blob_data_available near the 75% deadline because their BNs observe envelope and blob data at slightly different times; cluster coherence comes from QBFT deciding one value, not from the metadata fetch itself. Mirrors the §2 attestation pattern (per-operator pre-fetch into metadata_service).
When the BN has not yet seen a block for the slot, the honest behavior is to skip the duty (consensus-specs Gloas validator.md: "If the validator has not seen any beacon block for the assigned slot, do not submit"). The phase surfaces this to sign_payload_attestation via a sentinel on the watch channel.
Suggested approach
anchor/validator_store/src/metadata_service.rs:
- Add a new phase loop (Phase-4-style) that fires shortly before
chain_spec.get_payload_attestation_due() (default close to 75% slot, with margin for a BN round-trip; implementer tunes).
- Fetch via
BeaconNodeFallback::first_success(|bn| bn.get_validator_payload_attestation_data(slot)). Falls back to the next BN on failure.
- Publish to a new
PayloadAttestationContext watch channel carrying beacon_block_root, payload_present, blob_data_available.
- When the BN returns no block for the slot, publish a "skip duty" sentinel that the follow-up
sign_payload_attestation recognizes (sentinel shape is an open question, see below).
- Phase-1/2/3 loops should be untouched.
Acceptance criteria
- New phase loop fires at the configured slot fraction (close to LH's 75% PTC service start, with margin for BN round-trip).
- BN fetch via
BeaconNodeFallback::first_success; falls back to the next BN on failure.
- Watch channel publishes a
PayloadAttestationContext per slot.
- "No block seen" BN response surfaces as a "skip duty" sentinel on the watch channel.
- Existing Phase-1/2/3 loops unaffected.
- Integration tests using a mocked BN:
ptc_phase_publishes_on_bn_success: BN returns PayloadAttestationData; slot clock advances; assert the watch channel publishes within slot.
ptc_phase_publishes_skip_on_bn_none: BN returns nothing for the slot; assert the watch channel publishes the "skip duty" sentinel.
Open questions
- "Skip duty" sentinel shape. Options include
Option<PayloadAttestationContext> (None = skip), an enum like PtcSlotState { Vote(ctx), Skip }, or a flag on the context struct. Decide before publishing the type so the follow-up sign_payload_attestation PR can pattern-match cleanly.
- Phase ordering against existing loops. Phases 1/2/3 fire at different slot fractions; Phase 4 needs to fire before 75%. Confirm against the existing loop structure in
metadata_service.rs whether the new phase runs as an independent task or sequences after Phase 3.
- BN-None vs BN-error distinction. SIP-94 references the validator-spec "skip if no block seen" rule. Check the LH BN client (
eth2/src/lib.rs::get_validator_payload_attestation_data) for whether "no block" surfaces as None, an explicit error variant, or both — the sentinel handling depends on it.
Risks
- Timing tuning. Fetch too early and envelope arrival is uncertain; too late and the cluster's QBFT round budget shrinks below the 75% deadline. Default ~70% slot is a starting point; measure on ssv-mini before locking.
- BN fallback failure. If all configured BNs fail for the slot, the cluster cannot vote PTC. Missed reward only, not slashing. Surface via a metric counter so operators can spot a degraded BN fleet.
Goal
Add a new metadata-service phase loop in
anchor/validator_store/src/metadata_service.rsthat pre-fetchesPayloadAttestationDatafrom the BN shortly before LH'sPayloadAttestationServicestarts QBFT at 75% slot. The loop publishes the fetched context on a watch channel thatsign_payload_attestation(follow-up issue) reads as its QBFT initial value. Modeled on the existing Phase-2 voting context loop inmetadata_service.rs.Context
Per SIP-94 §3 (ssvlabs/SIPs#94), PTC requires one QBFT instance per cluster per slot. Each operator needs an initial value for that instance; LH's
PayloadAttestationServicefetches per-operator inside its own loop, which gives Anchor no place to stage that initial value at the metadata layer.This phase produces the operator-local initial value by pre-fetching
PayloadAttestationDatathrough that operator'sBeaconNodeFallback. Honest operators can still disagree onpayload_present/blob_data_availablenear the 75% deadline because their BNs observe envelope and blob data at slightly different times; cluster coherence comes from QBFT deciding one value, not from the metadata fetch itself. Mirrors the §2 attestation pattern (per-operator pre-fetch intometadata_service).When the BN has not yet seen a block for the slot, the honest behavior is to skip the duty (consensus-specs Gloas
validator.md: "If the validator has not seen any beacon block for the assigned slot, do not submit"). The phase surfaces this tosign_payload_attestationvia a sentinel on the watch channel.Suggested approach
anchor/validator_store/src/metadata_service.rs:chain_spec.get_payload_attestation_due()(default close to 75% slot, with margin for a BN round-trip; implementer tunes).BeaconNodeFallback::first_success(|bn| bn.get_validator_payload_attestation_data(slot)). Falls back to the next BN on failure.PayloadAttestationContextwatch channel carryingbeacon_block_root,payload_present,blob_data_available.sign_payload_attestationrecognizes (sentinel shape is an open question, see below).Acceptance criteria
BeaconNodeFallback::first_success; falls back to the next BN on failure.PayloadAttestationContextper slot.ptc_phase_publishes_on_bn_success: BN returnsPayloadAttestationData; slot clock advances; assert the watch channel publishes within slot.ptc_phase_publishes_skip_on_bn_none: BN returns nothing for the slot; assert the watch channel publishes the "skip duty" sentinel.Open questions
Option<PayloadAttestationContext>(None = skip), an enum likePtcSlotState { Vote(ctx), Skip }, or a flag on the context struct. Decide before publishing the type so the follow-upsign_payload_attestationPR can pattern-match cleanly.metadata_service.rswhether the new phase runs as an independent task or sequences after Phase 3.eth2/src/lib.rs::get_validator_payload_attestation_data) for whether "no block" surfaces asNone, an explicit error variant, or both — the sentinel handling depends on it.Risks