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
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)
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
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.
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.
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 dedicatedPartialSignatureKind::PTCAttester = 7(no longer reusingPostConsensus). 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 ownPartialSignatureKindrather than reusingPostConsensus, and the role usesDutyExecutor::Validatorwith noqbft_managerrouting.PTCAttester = 7keeps value-parity with the SIP'sRunnerRole/PartialSigMsgType/BeaconRole(all7); the wire byte stays[7,0,0,0](unchanged fromPTCCommittee), andDomain::PTCAttesteris0x0C.ProposerPreferences(§5) renumbers to8. Part of milestone #6.This is one logical change, but it breaks exhaustive matches across several crates, so it lands as one PR: the
Rolerename hits matches inmsgid.rs,message_validator, andqbft_manager; the newPartialSignatureKindhits the two matches inmessage_counts.rs.cargo check --workspaceis the gate that catches the cross-crate breaks (-p ssv_typesalone will not).Suggested approach
Lead with the symbol; line hints drift, re-grep at file time.
anchor/common/ssv_types/src/partial_sig.rs:PartialSignatureKindenum (~22-40)PTCAttester = 7,afterAggregatorCommitteePartialSig = 6,TryFrom<u64>(~42-57)7 => Ok(PartialSignatureKind::PTCAttester),before the_ =>armpartial_signature_kind_ssz_decode_invalid_variant~239 andpartial_signature_kind_try_from_u64_invalid_values~307)7u64 -> 8u64in both (7 is now valid;ProposerPreferences = 8is 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):Roleenum variant (~24)PTCCommittee->PTCAttesterFrom<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)matches!armmax_round(~80-88)PTCCommittee => Some(4)arm; add PTC to theValidatorRegistration | VoluntaryExit => Nonearmduty_executor(~158-168)Committeearm; addRole::PTCAttesterto theDutyExecutor::Validator(pubkey) armis_qbft_role(new helper)pub fn is_qbft_role(self) -> bool { self.max_round().is_some() }(positively named, mirroringis_committee_role, and negated at call sites; renamed fromis_non_qbft_roleper review); consumed by theconsensus_messageguard below to reject consensus messages for validator-scoped non-QBFT rolesanchor/message_validator/src/message_counts.rs(easy-to-miss break site: twomatch PartialSignatureKindblocks, no wildcard). PTC joins the pre-consensus arm (notPostConsensus):post_consensusis specifically the partial sig over the QBFT-decided value; PTC is a standalone, non-QBFT single-validator signature likeValidatorRegistration/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.)validate_partial_signature_message, pre-consensus arm (~64-69)| PartialSignatureKind::PTCAttesterto the pre-consensus armrecord_partial_signature, pre-consensus arm (~107-112)| PartialSignatureKind::PTCAttesterto the=> self.pre_consensus += 1armanchor/message_validator/src/partial_signature.rs:partial_signature_type_matches_role(~150)Role::Committee | Role::PTCCommittee => kind == PostConsensusintoRole::Committee => kind == PostConsensus+ new armRole::PTCAttester => kind == PartialSignatureKind::PTCAttesterRole::PTCAttesterinto the per-validator arm (withAggregator | Proposer | ValidatorRegistration | VoluntaryExit), which enforcesmessage_count > 1 -> reject. The> 1bound subsumes the old committee occurrence cap, so both now-dead PTC helpers are deleted:validate_ptc_committee_message_count(rejected onlymessage_count > validator_count, i.e. committee batching) andvalidate_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:committee_infodispatch (~453)Role::PTCAttesterto the validator arm (with ValidatorRegistration/VoluntaryExit)validate_role_for_fork(~862)< Fork::CStarrejectmessage_lateness/ TTL (~932)Role::PTCAttesterinto the short slot-bound arm withProposer | 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)Role::PTCAttesterinto theRole::Aggregator | Role::ValidatorRegistration => Ok(Some(2))arm. Not the oldmin(slots_per_epoch, V)formula: PTC is validator-scoped, socommittee_info.validator_indicesis always length 1 and the duty counter is keyed per-validator, making that formula evaluate toSome(1)in production (andSome(0)on missing metadata).Some(2)= ~1 PTC duty/epoch/validator + a one-duty boundary margin, matching the sibling validator-scoped roles.create_message_id_for_test(~1301)DutyExecutor::Validator(...)anchor/message_validator/src/consensus_message.rs: the "duty role has consensus" guard invalidate_consensus_message_semanticsswitches from a hardcodedValidatorRegistration | VoluntaryExitlist to!role.is_qbft_role(), so a consensus message addressed toPTCAttesteris now rejected asUnexpectedConsensusMessage(PTC is non-QBFT). The previously unreachableFailedToGetMaxRoundfallback below the guard is deleted: the defensive branch now also returnsUnexpectedConsensusMessageand the error variant is removed fromValidationFailure. Tests:test_consensus_message_for_non_consensus_roleis parameterized over all non-QBFT roles includingPTCAttester(as landed in #1080).anchor/qbft_manager/src/lib.rs(bothmatch msg_id.role()arms are exhaustive by explicit enumeration with no_wildcard, so a strayPTCAttesteris E0004):Role::PTCCommittee -> Role::PTCAttesterand move it into the non-QBFT group withValidatorRegistration | VoluntaryExit. Do not drop it. PTC is validator-scoped + non-QBFT, so a QBFT message for it returnsInconsistentMessageId.Some(Role::PTCCommittee) => { warn!("...routing not wired"); Err(RoleNotActive) }(~341-347)Role::PTCAttesterto 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; omittingPTCAttesteris E0004.Acceptance criteria
git grep PTCCommittee anchor/empty;Role::PTCAttesterexists.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 withRoleNotActiveBeforeFork { minimum_fork: Fork::CStar }; CStarOk(()).message_lateness:Role::PTCAttesteruses the short slot-bound TTL (1 + LATE_SLOT_ALLOWANCE= 3 slots, alongside Proposer/SyncCommittee); a PTC partial 20 slots late is rejected asLateSlotMessage.validate_consensus_message_semanticsrejects a consensus message for any non-QBFT role (UnexpectedConsensusMessage), now includingPTCAttester, via the negatedRole::is_qbft_role().duty_limit(Role::PTCAttester) == Some(2)(folded into theAggregator \| ValidatorRegistrationarm). The earlierSome(min(slots_per_epoch, V))was wrong: validator-scoped dispatch supplies a length-1validator_indicesand the duty counter is per-validator, so the cap is a flat per-validator value, not a cluster-wide one.PayloadAttestationMessagein one packet rejected;validator_indexoccurring > 1 time rejected (cap 1).qbft_managerhas no dedicated PTC routing arm (PTCAttestersits in theInconsistentMessageIderror arms of both branches); no//todo(epbs)stub.cargo check --workspacegreen.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(mirroraggregator_committee_*), plus the combined classification testptc_attester_is_validator_scoped_non_qbftpinning!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), androle_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: bump7 -> 8in 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 includePTCAttester(noEnumIter, so they don't pick it up automatically). Those array rows already cover the variant value and SSZ encoding, so no dedicatedptc_attester_*value/encoding tests are added.partial_signature.rs: retarget the PTC tests totest_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_acceptedasserts acceptance 2 slots late inside the short TTL;_beyond_short_ttl_rejectedpins that 20 slots late (accepted under the old committee TTL) is rejected.consensus_message.rs: renametest_duty_limit_ptc_committee -> test_duty_limit_ptc_attester, switch toDutyExecutor::Validator, and assertSome(2)(and that the cap does not scale withvalidator_indiceslength).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'sRole::PTCAttester+PartialSignatureKind::PTCAttester.