Skip to content

Commit 7bad23f

Browse files
authored
feat(qbft_manager): wire EnvelopeProposer per-proposer QBFT instances (#1188)
Co-Authored-By: jnhsigmap <jason.harris@sigmaprime.io>
1 parent 59005f5 commit 7bad23f

4 files changed

Lines changed: 321 additions & 123 deletions

File tree

anchor/qbft_manager/src/lib.rs

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ use slot_clock::SlotClock;
1414
use ssv_types::{
1515
CommitteeId, IndexSet, OperatorId,
1616
consensus::{
17-
AggregatorCommitteeConsensusData, BeaconVote, GloasBeaconVote, ProposerConsensusData,
18-
QbftData, QbftDataValidator,
17+
AggregatorCommitteeConsensusData, BeaconVote, EnvelopeConsensusData, GloasBeaconVote,
18+
ProposerConsensusData, QbftData, QbftDataValidator,
1919
},
2020
domain_type::DomainType,
2121
message::SignedSSVMessage,
@@ -30,7 +30,7 @@ use tokio::{
3030
},
3131
time::{Instant, sleep},
3232
};
33-
use tracing::{Instrument, debug, debug_span, error, warn};
33+
use tracing::{Instrument, debug_span, error, warn};
3434
use types::{ChainSpec, Epoch, EthSpec, Hash256, Slot};
3535

3636
use crate::instance::qbft_instance;
@@ -96,6 +96,14 @@ pub enum ValidatorDutyKind {
9696
SyncCommitteeAggregator,
9797
}
9898

99+
/// Unique identifier for an envelope-proposer QBFT instance (SIP-94 §6). Envelope
100+
/// signing is a single per-slot duty, so no `ValidatorDutyKind` discriminator.
101+
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
102+
pub struct EnvelopeProposerInstanceId {
103+
pub validator: PublicKeyBytes,
104+
pub instance_height: InstanceHeight,
105+
}
106+
99107
// Message that is passed around the QbftManager
100108
pub struct QbftMessage<D: QbftData> {
101109
pub kind: QbftMessageKind<D>,
@@ -148,6 +156,8 @@ pub struct QbftManager<E: EthSpec, S: SlotClock> {
148156
// QBFT instances for AggregatorCommitteeConsensusData
149157
aggregator_committee_instances:
150158
Map<AggregatorCommitteeInstanceId, AggregatorCommitteeConsensusData<E>>,
159+
// QBFT instances voting on Gloas self-build envelope consensus data (SIP-94 §6)
160+
envelope_consensus_data_instances: Map<EnvelopeProposerInstanceId, EnvelopeConsensusData>,
151161
// Utility to sign and serialize network messages
152162
message_sender: Arc<dyn MessageSender>,
153163
// Number of slots per epoch
@@ -178,6 +188,7 @@ impl<E: EthSpec, S: SlotClock + Clone + 'static> QbftManager<E, S> {
178188
beacon_vote_instances: DashMap::new(),
179189
gloas_beacon_vote_instances: DashMap::new(),
180190
aggregator_committee_instances: DashMap::new(),
191+
envelope_consensus_data_instances: DashMap::new(),
181192
message_sender,
182193
slots_per_epoch,
183194
fork_schedule,
@@ -201,6 +212,12 @@ impl<E: EthSpec, S: SlotClock + Clone + 'static> QbftManager<E, S> {
201212
self.fork_schedule.active_fork_config(epoch).domain_type
202213
}
203214

215+
/// Whether the Ethereum Gloas (ePBS) fork is active at `slot`, per the consensus
216+
/// spec. Distinct from the SSV protocol `fork_schedule`.
217+
fn gloas_enabled_at_slot(&self, slot: Slot) -> bool {
218+
self.spec.fork_name_at_slot::<E>(slot).gloas_enabled()
219+
}
220+
204221
// Decide a brand new qbft instance
205222
pub async fn decide_instance<D: QbftDecidable<E>>(
206223
&self,
@@ -283,9 +300,24 @@ impl<E: EthSpec, S: SlotClock + Clone + 'static> QbftManager<E, S> {
283300
Some(Role::Aggregator) => ValidatorDutyKind::Aggregator,
284301
Some(Role::SyncCommittee) => ValidatorDutyKind::SyncCommitteeAggregator,
285302
Some(Role::EnvelopeProposer) => {
286-
// TODO: wire EnvelopeProposer instance routing (#1122)
287-
debug!(?msg_id, "EnvelopeProposer routing not yet wired");
288-
return Err(QbftError::RoleNotActive);
303+
let slot = types::Slot::new(qbft_message.height);
304+
// Defense in depth behind `validate_role_for_fork`: envelope QBFT
305+
// exists only post-Gloas.
306+
if !self.gloas_enabled_at_slot(slot) {
307+
warn!(%slot, "Ignoring EnvelopeProposer message before Gloas fork");
308+
return Err(QbftError::RoleNotActive);
309+
}
310+
let id = EnvelopeProposerInstanceId {
311+
validator,
312+
instance_height,
313+
};
314+
return self.pass_to_instance::<EnvelopeConsensusData>(
315+
id,
316+
WrappedQbftMessage {
317+
signed_message: full_message,
318+
qbft_message,
319+
},
320+
);
289321
}
290322
// Committee roles use DutyExecutor::Committee, not Validator
291323
Some(Role::Committee | Role::AggregatorCommittee)
@@ -324,9 +356,8 @@ impl<E: EthSpec, S: SlotClock + Clone + 'static> QbftManager<E, S> {
324356
qbft_message,
325357
};
326358

327-
// Gate the Gloas beacon-vote shape on Ethereum's Gloas (ePBS) fork,
328-
// read from the consensus spec, rather than an SSV-internal fork.
329-
if self.spec.fork_name_at_slot::<E>(slot).gloas_enabled() {
359+
// Gate the Gloas beacon-vote shape on Ethereum's Gloas (ePBS) fork using Ethereum consensus spec.
360+
if self.gloas_enabled_at_slot(slot) {
330361
self.pass_to_instance::<GloasBeaconVote>(id, wrapped)
331362
} else {
332363
self.pass_to_instance::<BeaconVote>(id, wrapped)
@@ -416,6 +447,8 @@ impl<E: EthSpec, S: SlotClock + Clone + 'static> QbftManager<E, S> {
416447
.retain(|k, _| *k.instance_height >= cutoff.as_usize());
417448
self.aggregator_committee_instances
418449
.retain(|k, _| *k.instance_height >= cutoff.as_usize());
450+
self.envelope_consensus_data_instances
451+
.retain(|k, _| *k.instance_height >= cutoff.as_usize());
419452
}
420453
}
421454
}
@@ -560,6 +593,26 @@ impl<E: EthSpec> QbftDecidable<E> for AggregatorCommitteeConsensusData<E> {
560593
}
561594
}
562595

596+
impl<E: EthSpec> QbftDecidable<E> for EnvelopeConsensusData {
597+
type Id = EnvelopeProposerInstanceId;
598+
599+
fn get_map<S: SlotClock>(manager: &QbftManager<E, S>) -> &Map<Self::Id, Self> {
600+
&manager.envelope_consensus_data_instances
601+
}
602+
603+
fn instance_height(&self, id: &Self::Id) -> InstanceHeight {
604+
id.instance_height
605+
}
606+
607+
fn message_id(domain: &DomainType, id: &Self::Id) -> MessageId {
608+
MessageId::new(
609+
domain,
610+
Role::EnvelopeProposer,
611+
&DutyExecutor::Validator(id.validator),
612+
)
613+
}
614+
}
615+
563616
#[derive(Debug, Clone)]
564617
pub enum QbftError {
565618
QueueClosedError,

anchor/qbft_manager/src/tests.rs

Lines changed: 1 addition & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ use super::{
4040
use crate::instance::qbft_instance;
4141

4242
mod aggregator_tests;
43+
mod envelope_dispatch_tests;
4344
mod gloas_dispatch_tests;
4445
mod setup;
4546
mod timeout_tests;
@@ -961,115 +962,4 @@ mod manager_tests {
961962
result
962963
);
963964
}
964-
965-
#[tokio::test]
966-
async fn envelope_proposer_any_executor_decodes_as_validator_transient_role_not_active() {
967-
// `MessageId::new` starts from a zeroed 56-byte buffer and writes the executor bytes
968-
// in place: `DutyExecutor::Validator` fills bytes 8..56, `DutyExecutor::Committee` fills
969-
// bytes 24..56. Because `PublicKeyBytes::empty()` and `CommitteeId([0; 32])` are both
970-
// all-zero, each executor writes ONLY zeros into an already-zero buffer, so role 9
971-
// (`Role::EnvelopeProposer`) encodes to the identical 56-byte `MessageId` regardless of
972-
// the executor passed in.
973-
//
974-
// `MessageId::duty_executor` then selects the executor purely from the role, and role 9
975-
// is hard-wired to the `Validator` arm (bytes 8..55). So `receive_data` always enters the
976-
// `Some(DutyExecutor::Validator(_))` branch and hits the `Some(Role::EnvelopeProposer)`
977-
// arm, which returns `QbftError::RoleNotActive` unconditionally (routing not wired,
978-
// TODO #1122). A committee-executor `EnvelopeProposer` is therefore unconstructable, and
979-
// the `Role::EnvelopeProposer` listing in the `DutyExecutor::Committee` arm is unreachable
980-
// for role 9 — it exists only for match exhaustiveness, so its `InconsistentMessageId` can
981-
// never fire here.
982-
use fork::{Fork, ForkSchedule};
983-
use message_sender::testing::MockMessageSender;
984-
use ssv_types::{
985-
RSA_SIGNATURE_SIZE,
986-
consensus::{QbftMessage, QbftMessageType},
987-
message::{MsgType, SSVMessage, SignedSSVMessage},
988-
};
989-
use ssz::Encode;
990-
991-
// Arrange: build the manager once. The fork/spec values are irrelevant here — the
992-
// `Validator`/`EnvelopeProposer` arm returns before consulting either.
993-
let setup = setup_test(1);
994-
let fork_schedule = ForkSchedule::new(Fork::Boole, DomainType::default(), "test");
995-
let config = processor::Config {
996-
max_workers: 4,
997-
queue_size: Default::default(),
998-
};
999-
let senders = processor::spawn(config, setup.executor);
1000-
let (network_tx, _network_rx) = mpsc::unbounded_channel();
1001-
1002-
let manager = QbftManager::<types::MainnetEthSpec, _>::new(
1003-
senders,
1004-
OperatorId(1).into(),
1005-
setup.clock,
1006-
Arc::new(MockMessageSender::new(network_tx, OperatorId(1))),
1007-
NonZeroU64::new(32).expect("slots_per_epoch is non-zero"),
1008-
Arc::new(fork_schedule),
1009-
Arc::new(types::ChainSpec::mainnet()),
1010-
)
1011-
.expect("Manager creation should succeed");
1012-
1013-
// Constructs an `EnvelopeProposer` message for the given executor, returning the signed
1014-
// message plus its `QbftMessage`. Avoids duplicating the construction block per executor.
1015-
let build = |executor: &DutyExecutor| -> (SignedSSVMessage, QbftMessage) {
1016-
let msg_id = MessageId::new(&DomainType([0; 4]), Role::EnvelopeProposer, executor);
1017-
let qbft_message = QbftMessage {
1018-
qbft_message_type: QbftMessageType::Proposal,
1019-
height: 100,
1020-
round: 1,
1021-
identifier: (&msg_id).into(),
1022-
root: Hash256::from([0u8; 32]),
1023-
data_round: 1,
1024-
round_change_justification: ssv_types::VariableList::empty(),
1025-
prepare_justification: ssv_types::VariableList::empty(),
1026-
};
1027-
let ssv_msg = SSVMessage::new(
1028-
MsgType::SSVConsensusMsgType,
1029-
msg_id,
1030-
qbft_message.as_ssz_bytes(),
1031-
)
1032-
.expect("SSVMessage creation should succeed");
1033-
let signed_msg = SignedSSVMessage::new(
1034-
vec![[0xAA; RSA_SIGNATURE_SIZE]],
1035-
vec![OperatorId(1)],
1036-
ssv_msg,
1037-
vec![],
1038-
)
1039-
.expect("SignedSSVMessage creation should succeed");
1040-
(signed_msg, qbft_message)
1041-
};
1042-
1043-
// Make the byte-identity invariant explicit: both executors encode to the same 56 bytes.
1044-
let validator_msg_id = MessageId::new(
1045-
&DomainType([0; 4]),
1046-
Role::EnvelopeProposer,
1047-
&DutyExecutor::Validator(bls::PublicKeyBytes::empty()),
1048-
);
1049-
let committee_msg_id = MessageId::new(
1050-
&DomainType([0; 4]),
1051-
Role::EnvelopeProposer,
1052-
&DutyExecutor::Committee(CommitteeId([0; 32])),
1053-
);
1054-
assert_eq!(
1055-
validator_msg_id.as_ref(),
1056-
committee_msg_id.as_ref(),
1057-
"validator- and committee-executor `EnvelopeProposer` must encode to the same 56 bytes for role 9"
1058-
);
1059-
1060-
// Act + Assert: both executor variants route through the same
1061-
// `Validator`/`EnvelopeProposer` arm and return transient `RoleNotActive` (routing
1062-
// not wired), never `InconsistentMessageId`.
1063-
for executor in [
1064-
DutyExecutor::Validator(bls::PublicKeyBytes::empty()),
1065-
DutyExecutor::Committee(CommitteeId([0; 32])),
1066-
] {
1067-
let (signed_msg, qbft_message) = build(&executor);
1068-
let result = manager.receive_data(signed_msg, qbft_message);
1069-
assert!(
1070-
matches!(result, Err(QbftError::RoleNotActive)),
1071-
"`EnvelopeProposer` always decodes as `Validator` and must be transient `RoleNotActive`, got: {result:?}"
1072-
);
1073-
}
1074-
}
1075965
}

0 commit comments

Comments
 (0)