feat(qbft_manager): wire PTC committee-scoped QBFT instances and fork gate - #1057
feat(qbft_manager): wire PTC committee-scoped QBFT instances and fork gate#1057shane-moore wants to merge 2 commits into
Conversation
… gate Replaces the //todo(cstar) stub in receive_data with full PTC routing: new PTCCommitteeInstanceId, a fourth instance Map for PayloadAttestationVote, cleaner retention, and the QbftDecidable impl. Fork-gated at >= Fork::CStar, mirroring the AggregatorCommittee/Boole precedent. The qbft_manager gate is defense-in-depth on top of message_validator's existing pre-CStar rejection. Closes sigp#1035.
|
@claude, please take a look |
|
Claude finished @shane-moore's task in 2m 48s —— View job Review: PR #1057 —
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## epbs #1057 +/- ##
=======================================
Coverage ? 62.04%
=======================================
Files ? 156
Lines ? 26265
Branches ? 0
=======================================
Hits ? 16297
Misses ? 9968
Partials ? 0
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Replaces the negative `!matches!(Err(RoleNotActive))` assertion with positive `is_ok()` plus a check that the message actually populated `payload_attestation_vote_instances` at the expected committee+height. The previous form would pass on any non-`RoleNotActive` outcome, including a silent `Ok(())` from a fall-through path or any other error. The new form proves the message reached the PTC routing arm specifically and spawned its instance. Same weakness exists in the `AggregatorCommittee` accept test; tracking that separately so the precedent stays symmetric.
|
@claude, pushed On obs 3 (
The field is typed Obs 1 (test boilerplate) and obs 4 (comment casing) acknowledged as inherited from the AggregatorCommittee precedent; not introduced by this PR. |
|
Claude finished @shane-moore's task in 1m 8s —— View job Re-review of
|
| 1, | ||
| "Only the PTC arm should have spawned an instance", | ||
| ); | ||
| } |
There was a problem hiding this comment.
Looks good after a bit of reading 🙂, just one point that came up - we could test the fork-boundary to ensure that QbftManager rejects PTC traffic where CStar is activated at a non-zero epoch? We cover accepting at CStar but not rejecting prior.
| } | |
| } | |
| /// Test that at a non-zero CStar activation epoch the qbft_manager | |
| /// routing arm rejects PTC traffic at the last pre-fork slot (i.e. to catch off-by-one bugs). | |
| #[tokio::test] | |
| async fn test_ptc_committee_rejected_at_last_pre_cstar_slot() { | |
| use std::collections::BTreeMap; | |
| use ssv_types::{ | |
| RSA_SIGNATURE_SIZE, | |
| message::{MsgType, SSVMessage, SignedSSVMessage}, | |
| }; | |
| use ssz::Encode; | |
| use types::{Epoch, MainnetEthSpec}; | |
| const CSTAR_ACTIVATION_EPOCH: u64 = 5; | |
| let setup = setup_test(1); | |
| // CStar pinned to non-zero epoch. | |
| let mut configs = BTreeMap::new(); | |
| configs.insert(Fork::Alan, (Epoch::new(0), DomainType::default())); | |
| configs.insert( | |
| Fork::CStar, | |
| (Epoch::new(CSTAR_ACTIVATION_EPOCH), DomainType::default()), | |
| ); | |
| let schedule = | |
| ForkSchedule::from_fork_configs(configs, "test").expect("Alan@0 + CStar@5 is valid"); | |
| let senders = processor::spawn( | |
| processor::Config { | |
| max_workers: 4, | |
| queue_size: Default::default(), | |
| }, | |
| setup.executor, | |
| ); | |
| let (network_tx, _network_rx) = mpsc::unbounded_channel(); | |
| let spe = MainnetEthSpec::slots_per_epoch(); | |
| let manager = QbftManager::<MainnetEthSpec, _>::new( | |
| senders, | |
| OperatorId(1).into(), | |
| setup.clock, | |
| Arc::new(MockMessageSender::new(network_tx, OperatorId(1))), | |
| NonZeroU64::new(spe).expect("slots_per_epoch is non-zero"), | |
| Arc::new(schedule), | |
| ) | |
| .expect("manager creation"); | |
| let last_pre_cstar_slot = CSTAR_ACTIVATION_EPOCH * spe - 1; | |
| let msg_id = MessageId::new( | |
| &DomainType([0; 4]), | |
| Role::PTCCommittee, | |
| &DutyExecutor::Committee(CommitteeId([0; 32])), | |
| ); | |
| let qbft_message = QbftMessage { | |
| qbft_message_type: QbftMessageType::Proposal, | |
| height: last_pre_cstar_slot, | |
| round: 1, | |
| identifier: (&msg_id).into(), | |
| root: Hash256::from([0u8; 32]), | |
| data_round: 1, | |
| round_change_justification: ssv_types::VariableList::empty(), | |
| prepare_justification: ssv_types::VariableList::empty(), | |
| }; | |
| let signed = SignedSSVMessage::new( | |
| vec![[0xAA; RSA_SIGNATURE_SIZE]], | |
| vec![OperatorId(1)], | |
| SSVMessage::new( | |
| MsgType::SSVConsensusMsgType, | |
| msg_id, | |
| qbft_message.as_ssz_bytes(), | |
| ) | |
| .expect("SSVMessage creation"), | |
| vec![], | |
| ) | |
| .expect("SignedSSVMessage creation"); | |
| let result = manager.receive_data(signed, qbft_message); | |
| assert!( | |
| matches!(result, Err(QbftError::RoleNotActive)), | |
| "last pre-CStar slot {last_pre_cstar_slot} (epoch {}) must reject PTC message.", | |
| last_pre_cstar_slot / spe, | |
| ); | |
| } |
|
closing for now since we're going to move to a non-qbft ptc approach |
Problem, Evidence, and Context
Closes #1035. PR #1033 (issue #1032) added
Role::PTCCommitteetoqbft_manager::receive_dataas a placeholder arm with a//todo(cstar)marker that rejected all PTC traffic. PR #1047 (issue #1034) addedPayloadAttestationVotewithQbftData+ value checker inssv_types. This PR completes the qbft_manager wire-up so the downstream issue #1037 (sign_payload_attestation) can compile againstconsensus.decide_instance::<PayloadAttestationVote>(...).Per SIP-94 §3, PTC runs one QBFT instance per cluster per slot over
PayloadAttestationVote. The manager's existing four-role pattern (Committee, AggregatorCommittee, Proposer, plus a placeholder for PTCCommittee) gets the fourth role wired in here.Change Overview
Direct structural mirror of the
Role::AggregatorCommitteeprecedent added in PR #1033, substitutingFork::CStarforFork::BooleandPayloadAttestationVoteforAggregatorCommitteeConsensusData<E>. Single-crate change (qbft_manager/), +215/-6 across 3 files.Reading order: start at the replaced routing arm in
lib.rsand compare against theAggregatorCommitteetemplate it mirrors immediately above it. Then look at the four supporting edits (newPTCCommitteeInstanceIdtype, new map field onQbftManager, constructor init, cleaner.retainentry,QbftDecidable for PayloadAttestationVoteimpl alongside the other three impls). Finallytests/ptc_tests.rsfor two fork-gating tests mirroringtests/aggregator_tests.rs::test_aggregator_committee_rejected_before_booleandtests.rs::manager_tests::test_aggregator_committee_accepted_after_boole.Intentionally unchanged:
message_validator/src/lib.rsalready rejects PTC before CStar; this PR's gate is defense-in-depth.PayloadAttestationVoteor its validator (both shipped in feat(ssv_types): add PayloadAttestationVote with QbftData and value checker #1047).Risks, Trade-offs, and Mitigations
Defense-in-depth fork gate duplication. The
< Fork::CStarcheck duplicates themessage_validatorgate. Same intentional duplication exists for< Fork::BooleonAggregatorCommittee; not a new pattern.Test scope is tight. Two new fork-gating tests cover the security boundary. No end-to-end consensus test for PTC, no cleaner eviction test. These are symmetric gaps across all four routing arms; scope-bounded here to avoid cross-cutting test refactoring. Test boilerplate is duplicated across the AggregatorCommittee precedent and the new PTC tests but is small and not separately tracked.
Stub-removal completeness. Two markers from #1033 are gone after this PR: the
//todo(cstar)line comment and the placeholderwarn!(%slot, "Ignoring PTCCommittee message; routing not wired"). Verified by grep.Validation
cargo build -p qbft_manager --tests— clean.make cargo-fmt && make cargo-fmt-check— clean.make lint(clippy) — clean.cargo test -p qbft_manager— 18/18 pass (16 prior + 2 new PTC tests:test_ptc_committee_rejected_before_cstarandtest_ptc_committee_accepted_after_cstar).anchor/qbft_manager/.Rollback
Pure additive change behind
Fork::CStar. Pre-CStar behavior is bit-for-bit unchanged (the new arm returnsErr(QbftError::RoleNotActive)before any state mutation; the new map stays empty until the fork activates). Downstream consumer #1037 has not landed, so revert pre-CStar is a no-op.Blockers / Dependencies
None for merge. Downstream:
feat(validator_store): implement sign_payload_attestation— first consumer ofPTCCommitteeInstanceId+ theQbftDecidable for PayloadAttestationVoteimpl shipped here.feat(client): spawn LH PayloadAttestationService— downstream of feat(validator_store): implement sign_payload_attestation #1037, not blocked by this PR.Additional Info / Next Steps
N/A