Skip to content

feat(ssv_types): retarget PTC role to validator-scoped PTCAttester + PartialSignatureKind::PTCAttester #1075

Description

@shane-moore

Goal

Retarget the PTC role from committee-scoped to validator-scoped: rename Role::PTCCommittee -> Role::PTCAttester (is_committee_role = false, duty_executor = Validator, max_round = None) and add a dedicated PartialSignatureKind::PTCAttester = 7 (no longer reusing PostConsensus). Reworks #1032 / PR #1033.

Context

SIP-94 §3 (ssvlabs/SIPs#94) was rewritten to a validator-scoped, leaderless PTC design, modeled on ProposerPreferences (§5). With no consensus round, a PTC partial signature is a standalone single-validator signature, so it gets its own PartialSignatureKind rather than reusing PostConsensus, and the role uses DutyExecutor::Validator with no qbft_manager routing. PTCAttester = 7 keeps value-parity with the SIP's RunnerRole / PartialSigMsgType / BeaconRole (all 7); the wire byte stays [7,0,0,0] (unchanged from PTCCommittee), and Domain::PTCAttester is 0x0C. ProposerPreferences (§5) renumbers to 8. Part of milestone #6.

This is one logical change, but it breaks exhaustive matches across several crates, so it lands as one PR: the Role rename hits matches in msgid.rs, message_validator, and qbft_manager; the new PartialSignatureKind hits the two matches in message_counts.rs. cargo check --workspace is the gate that catches the cross-crate breaks (-p ssv_types alone will not).

Suggested approach

Lead with the symbol; line hints drift, re-grep at file time.

anchor/common/ssv_types/src/partial_sig.rs:

Symbol (line hint) current -> target
PartialSignatureKind enum (~22-40) add PTCAttester = 7, after AggregatorCommitteePartialSig = 6,
TryFrom<u64> (~42-57) add 7 => Ok(PartialSignatureKind::PTCAttester), before the _ => arm
invalid-variant sentinel tests (two: partial_signature_kind_ssz_decode_invalid_variant ~239 and partial_signature_kind_try_from_u64_invalid_values ~307) bump 7u64 -> 8u64 in both (7 is now valid; ProposerPreferences = 8 is not yet in Anchor, so 8 is the next free invalid value)

anchor/common/ssv_types/src/msgid.rs (keep wire byte [7,0,0,0]; rename + reclassify):

Symbol (line hint) current -> target
Role enum variant (~24) PTCCommittee -> PTCAttester
From<Role> for [u8;4] (~37) Role::PTCAttester => [7, 0, 0, 0]
TryFrom<&[u8]> (~54) [7, 0, 0, 0] => Ok(Role::PTCAttester)
is_committee_role (~69-73) drop PTC from the committee matches! arm
max_round (~80-88) delete the PTCCommittee => Some(4) arm; add PTC to the ValidatorRegistration | VoluntaryExit => None arm
duty_executor (~158-168) remove PTC from the Committee arm; add Role::PTCAttester to the DutyExecutor::Validator (pubkey) arm
is_qbft_role (new helper) add pub fn is_qbft_role(self) -> bool { self.max_round().is_some() } (positively named, mirroring is_committee_role, and negated at call sites; renamed from is_non_qbft_role per review); consumed by the consensus_message guard below to reject consensus messages for validator-scoped non-QBFT roles

anchor/message_validator/src/message_counts.rs (easy-to-miss break site: two match PartialSignatureKind blocks, no wildcard). PTC joins the pre-consensus arm (not PostConsensus): post_consensus is specifically the partial sig over the QBFT-decided value; PTC is a standalone, non-QBFT single-validator signature like ValidatorRegistration / VoluntaryExit, which both sit in the pre-consensus arm. (Functionally moot for PTC in isolation, since its MessageId is distinct and both buckets cap at 1, but the correct/consistent bucket is pre-consensus.)

Symbol (line hint) current -> target
validate_partial_signature_message, pre-consensus arm (~64-69) add | PartialSignatureKind::PTCAttester to the pre-consensus arm
record_partial_signature, pre-consensus arm (~107-112) add | PartialSignatureKind::PTCAttester to the => self.pre_consensus += 1 arm

anchor/message_validator/src/partial_signature.rs:

Symbol (line hint) current -> target
partial_signature_type_matches_role (~150) split Role::Committee | Role::PTCCommittee => kind == PostConsensus into Role::Committee => kind == PostConsensus + new arm Role::PTCAttester => kind == PartialSignatureKind::PTCAttester
per-validator packet-bound arm (~327-333) move Role::PTCAttester into the per-validator arm (with Aggregator | Proposer | ValidatorRegistration | VoluntaryExit), which enforces message_count > 1 -> reject. The > 1 bound subsumes the old committee occurrence cap, so both now-dead PTC helpers are deleted: validate_ptc_committee_message_count (rejected only message_count > validator_count, i.e. committee batching) and validate_validator_index_occurrence_limit (the per-index cap, also no longer used). No signing-root dedup set (PTC has one root per (validator, slot)).

anchor/message_validator/src/lib.rs:

Symbol (line hint) current -> target
committee_info dispatch (~453) remove PTC from the committee arm; add Role::PTCAttester to the validator arm (with ValidatorRegistration/VoluntaryExit)
validate_role_for_fork (~862) rename arm; keep the < Fork::CStar reject
message_lateness / TTL (~932) move Role::PTCAttester into the short slot-bound arm with Proposer | SyncCommittee (1 + LATE_SLOT_ALLOWANCE = 3 slots). Payload attestations are gossip-valid only for their own slot and block-includable only at slot + 1, so stale partials are useless; the epoch-long bucket was inherited from the committee-scoped design. (Landed in #1080 as a follow-up commit.)
duty_limit (~1052) fold Role::PTCAttester into the Role::Aggregator | Role::ValidatorRegistration => Ok(Some(2)) arm. Not the old min(slots_per_epoch, V) formula: PTC is validator-scoped, so committee_info.validator_indices is always length 1 and the duty counter is keyed per-validator, making that formula evaluate to Some(1) in production (and Some(0) on missing metadata). Some(2) = ~1 PTC duty/epoch/validator + a one-duty boundary margin, matching the sibling validator-scoped roles.
test helper create_message_id_for_test (~1301) move PTC from committee arm to DutyExecutor::Validator(...)

anchor/message_validator/src/consensus_message.rs: the "duty role has consensus" guard in validate_consensus_message_semantics switches from a hardcoded ValidatorRegistration | VoluntaryExit list to !role.is_qbft_role(), so a consensus message addressed to PTCAttester is now rejected as UnexpectedConsensusMessage (PTC is non-QBFT). The previously unreachable FailedToGetMaxRound fallback below the guard is deleted: the defensive branch now also returns UnexpectedConsensusMessage and the error variant is removed from ValidationFailure. Tests: test_consensus_message_for_non_consensus_role is parameterized over all non-QBFT roles including PTCAttester (as landed in #1080).

anchor/qbft_manager/src/lib.rs (both match msg_id.role() arms are exhaustive by explicit enumeration with no _ wildcard, so a stray PTCAttester is E0004):

Symbol (line hint) current -> target
validator-executor error arm (~277-283) rename Role::PTCCommittee -> Role::PTCAttester and move it into the non-QBFT group with ValidatorRegistration | VoluntaryExit. Do not drop it. PTC is validator-scoped + non-QBFT, so a QBFT message for it returns InconsistentMessageId.
stub arm Some(Role::PTCCommittee) => { warn!("...routing not wired"); Err(RoleNotActive) } (~341-347) delete the stub body/warn only, and add Role::PTCAttester to the committee-executor error arm (~348-352), grouped with the non-QBFT roles (ValidatorRegistration | VoluntaryExit) as in the validator-executor branch (it is a non-QBFT role, not a wrong-executor QBFT role). No catch-all arm exists here; omitting PTCAttester is E0004.

Acceptance criteria

  • git grep PTCCommittee anchor/ empty; Role::PTCAttester exists.
  • Role::PTCAttester.is_committee_role() == false; .max_round() == None; .is_qbft_role() == false; MessageId::new(.., Role::PTCAttester, &DutyExecutor::Validator(pk)).duty_executor() == Some(DutyExecutor::Validator(_)).
  • PartialSignatureKind::PTCAttester as u64 == 7; TryFrom::<u64>::try_from(7) == Ok(PTCAttester); SSZ round-trips to [7,0,0,0,0,0,0,0].
  • partial_signature_type_matches_role(PartialSignatureKind::PTCAttester, Role::PTCAttester) == true; (PostConsensus, PTCAttester) == false.
  • validate_role_for_fork: pre-CStar rejects with RoleNotActiveBeforeFork { minimum_fork: Fork::CStar }; CStar Ok(()).
  • message_lateness: Role::PTCAttester uses the short slot-bound TTL (1 + LATE_SLOT_ALLOWANCE = 3 slots, alongside Proposer/SyncCommittee); a PTC partial 20 slots late is rejected as LateSlotMessage.
  • validate_consensus_message_semantics rejects a consensus message for any non-QBFT role (UnexpectedConsensusMessage), now including PTCAttester, via the negated Role::is_qbft_role().
  • duty_limit(Role::PTCAttester) == Some(2) (folded into the Aggregator \| ValidatorRegistration arm). The earlier Some(min(slots_per_epoch, V)) was wrong: validator-scoped dispatch supplies a length-1 validator_indices and the duty counter is per-validator, so the cap is a flat per-validator value, not a cluster-wide one.
  • per-validator packet bound: > 1 PayloadAttestationMessage in one packet rejected; validator_index occurring > 1 time rejected (cap 1).
  • qbft_manager has no dedicated PTC routing arm (PTCAttester sits in the InconsistentMessageId error arms of both branches); no //todo(epbs) stub.
  • cargo check --workspace green.

Tests (rename + retarget the existing PTC tests; as landed in #1080)

  • msgid.rs: retarget the PTC duty-executor test -> ptc_attester_uses_validator_duty_executor (mirror aggregator_committee_*), plus the combined classification test ptc_attester_is_validator_scoped_non_qbft pinning !is_committee_role(), max_round() == None, and !is_qbft_role() (added in feat(ssv_types): retarget PTC role to validator-scoped PTCAttester #1080 after review traced that no existing test fails if the classifications silently revert), and role_qbft_classification_is_pinned, which asserts every role's QBFT/non-QBFT classification on both sides of the partition (review feedback: max_round's exhaustive match forces a decision for a new role, but nothing checked the decision was right).
  • partial_sig.rs: bump 7 -> 8 in both invalid-variant tests; extend the hand-written enumerated-variant arrays (..._ssz_roundtrip_all_variants, ..._ssz_byte_layout, ..._try_from_u64_all_valid_values, TreeHash array) to include PTCAttester (no EnumIter, so they don't pick it up automatically). Those array rows already cover the variant value and SSZ encoding, so no dedicated ptc_attester_* value/encoding tests are added.
  • partial_signature.rs: retarget the PTC tests to test_ptc_attester_* (_within_ttl_accepted, _beyond_short_ttl_rejected, _rejected_before_cstar, _binds_ptc_attester_kind, _rejects_multiple_messages_per_packet); fix stale "bucketed as committee" comments. _within_ttl_accepted asserts acceptance 2 slots late inside the short TTL; _beyond_short_ttl_rejected pins that 20 slots late (accepted under the old committee TTL) is rejected.
  • consensus_message.rs: rename test_duty_limit_ptc_committee -> test_duty_limit_ptc_attester, switch to DutyExecutor::Validator, and assert Some(2) (and that the cap does not scale with validator_indices length).

Notes

Implemented by #1080. #1074 (the PayloadAttestationVote removal) not required as a dep (disjoint symbols). Builds on Fork::CStar (merged). The sign path and client spawn build on this PR's Role::PTCAttester + PartialSignatureKind::PTCAttester.

Metadata

Metadata

Assignees

No one assigned

    Labels

    epbsePBS / EIP-7732 / Gloas implementation

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions