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
Add a fork-schedule equality check to ProposerConsensusDataValidator: reject any ProposerConsensusData whose version does not equal the fork scheduled at duty.slot. SIP-94 §4 makes this a MUST:
The decided value's Version selects how DataSSZ is decoded (Gloas.BeaconBlock when Version >= DataVersionGloas), and Version is leader-supplied. An operator MUST reject any decided value whose Version does not equal the fork scheduled at duty.Slot. Honest proposers always stamp Version == fork(duty.Slot), so this rejects no honest value.
Context / motivation
value.version is the only leader-controlled field that changes how every other operator interprets data_ssz, and Anchor currently trusts it everywhere it matters (refs at Anchor b162b606):
validate_block_proposal (ssv_types/src/consensus.rs:397-414) derives fork = ForkName::from(value.version) and branches decode on it (decode_block for Gloas-or-later, blinded-then-contents fallback pre-Gloas). do_validation (consensus.rs:325-384) checks the duty's slot, role, pubkey, and index against our own value, but never checks version.
Post-decide, decode_decided_block (validator_store/src/lib.rs:1934-1953) repeats the same version-keyed dispatch, so a wrong version that survives consensus steers how every operator decodes the block it then signs.
The check bites at all three QBFT validation points, since they all route through QbftDataValidator::validate -> do_validation: received proposals (common/qbft/src/lib.rs:433, validate_message), round-change justifications (:488, justify_round_change_quorum), and decided-message sanity checking (:1207, received_decided).
Why fork-schedule equality rather than trusting decode to fail: SSZ is not self-describing, and from_ssz_bytes_for_fork under a wrong fork arm fails with a shape-dependent DecodeError at best, or accepts crafted bytes at worst. Mixed go-ssv/Anchor clusters need a deterministic accept/reject rule that does not depend on which decode arm happens to error; Version == fork(duty.Slot) is that rule (agreed on the SIP thread, discussion_r3542550168).
The honest path is unaffected: Anchor stamps its own proposal's version from the BN-returned block (block.fork_name_unchecked().into(), validator_store/src/lib.rs:551), which equals the scheduled fork at the duty slot for any honest BN.
Suggested approach
ssv_types/src/consensus.rs:
Add a DataValidationError variant styled after the existing mismatch variants:
#[error("wrong data version: expected fork {expected:?}, got {got:?}")]VersionMismatch{expected:ForkName,got:ForkName},
At the top of validate_block_proposal, before the decode-arm selection, compare against the fork schedule (the validator already holds spec: Arc<ChainSpec> and E via PhantomData; precedent for fork_name_at_slot at qbft_manager/src/lib.rs:321):
Checking before decode fails fast and makes the subsequent fork derivation trustworthy. value.duty.slot is already pinned to our own duty slot by the SlotMismatch check in do_validation, so the comparison is anchored to the duty, not to leader input.
Acceptance criteria
A Gloas-slot value stamped with a pre-Gloas version is rejected with VersionMismatch, not a decode error.
A pre-Gloas-slot value stamped DataVersionGloas is rejected the same way (the rule is era-generic and hardens pre-Gloas slots too).
A value whose version matches fork_name_at_slot(duty.slot) validates exactly as before (decode arms, slashing check, and error surfaces unchanged).
The check runs regardless of disable_slashing_protection.
The QBFT paths reject leader proposals, round-change data, and decided messages carrying a mismatched version (via the existing validate plumbing; no qbft-crate change).
Tests
cargo test -p ssv_types. The existing Gloas proposer fixtures (consensus.rs, test_proposer_duty() + DataVersion::from(ForkName::Gloas) around :3000-3160) cover decode; add validator-level tests, which need a ProposerConsensusDataValidator instance (constructing one takes a SlashingDatabase; with disable_slashing_protection: true the DB is never touched, so a tempfile-backed one suffices):
both mismatch directions reject with VersionMismatch { expected, got } carrying the right forks
matching Gloas version at a Gloas slot passes through to decode (accept on well-formed bytes)
matching pre-Gloas version at a pre-Gloas slot preserves the blinded-then-contents fallback behavior
use a ChainSpec with gloas_fork_epoch set so fork_name_at_slot exercises both eras
Notes
Scope is the BEACON_ROLE_PROPOSER arm only, per SIP-94 §4. The legacy BEACON_ROLE_AGGREGATOR arm (consensus.rs:367-372) shares the version-trust pattern but is a pre-Boole path already slated for removal (TODO(post-boole) at :363); out of scope here.
go-ssv PR #2901 now explicitly rejects a pre-Gloas Version on a Gloas duty slot before decoding. Anchor enforces exact fork-schedule equality in both directions, matching the SIP rule rather than relying on a wrong-fork decoder to reject the reverse mismatch.
Issues are directionally correct, not prescriptive; verify symbols at PR time.
Addendum (2026-07-22): also pin the decoded block's slot to the duty slot
The version check makes the data_ssz decode arm trustworthy, but the decoded block's own slot remains unchecked against the duty. The block is signed under header.slot and slashing protection keys on header.slot (validate_block_proposal, epoch/domain derivation and preliminary_check_block_proposal), so a leader stamping a correct duty while embedding a block built for another slot S gets a valid signature for S that the duty-level checks never see: an equivocation vector, and an unbounded header.slot feeding the domain computation. go-ssv rejects exactly this in PR #2901 ("gloas block slot does not match duty slot"). Not an explicit SIP-94 MUST; adopted as slashing-safety hardening and reference-implementation parity.
Suggested change, in validate_block_proposal after the header decode and before the slashing gate (so it also runs under disable_slashing_protection):
if header.slot != value.duty.slot{returnErr(DataValidationError::BlockSlotMismatch{expected: value.duty.slot,got: header.slot,});}
with a BlockSlotMismatch { expected: Slot, got: Slot } variant styled after the existing mismatch variants. value.duty.slot is pinned to our own duty by the SlotMismatch check, same trust anchor as the version check.
Additional acceptance criteria:
A value whose decoded block slot differs from the duty slot is rejected with BlockSlotMismatch in both the Gloas and pre-Gloas decode arms.
The check runs regardless of disable_slashing_protection.
Goal
Add a fork-schedule equality check to
ProposerConsensusDataValidator: reject anyProposerConsensusDatawhoseversiondoes not equal the fork scheduled atduty.slot. SIP-94 §4 makes this a MUST:Context / motivation
value.versionis the only leader-controlled field that changes how every other operator interpretsdata_ssz, and Anchor currently trusts it everywhere it matters (refs at Anchorb162b606):validate_block_proposal(ssv_types/src/consensus.rs:397-414) derivesfork = ForkName::from(value.version)and branches decode on it (decode_blockfor Gloas-or-later, blinded-then-contents fallback pre-Gloas).do_validation(consensus.rs:325-384) checks the duty's slot, role, pubkey, and index against our own value, but never checksversion.decode_decided_block(validator_store/src/lib.rs:1934-1953) repeats the same version-keyed dispatch, so a wrongversionthat survives consensus steers how every operator decodes the block it then signs.The check bites at all three QBFT validation points, since they all route through
QbftDataValidator::validate->do_validation: received proposals (common/qbft/src/lib.rs:433,validate_message), round-change justifications (:488,justify_round_change_quorum), and decided-message sanity checking (:1207,received_decided).Why fork-schedule equality rather than trusting decode to fail: SSZ is not self-describing, and
from_ssz_bytes_for_forkunder a wrong fork arm fails with a shape-dependentDecodeErrorat best, or accepts crafted bytes at worst. Mixed go-ssv/Anchor clusters need a deterministic accept/reject rule that does not depend on which decode arm happens to error;Version == fork(duty.Slot)is that rule (agreed on the SIP thread, discussion_r3542550168).The honest path is unaffected: Anchor stamps its own proposal's version from the BN-returned block (
block.fork_name_unchecked().into(),validator_store/src/lib.rs:551), which equals the scheduled fork at the duty slot for any honest BN.Suggested approach
ssv_types/src/consensus.rs:DataValidationErrorvariant styled after the existing mismatch variants:validate_block_proposal, before the decode-arm selection, compare against the fork schedule (the validator already holdsspec: Arc<ChainSpec>andEviaPhantomData; precedent forfork_name_at_slotatqbft_manager/src/lib.rs:321):Checking before decode fails fast and makes the subsequent
forkderivation trustworthy.value.duty.slotis already pinned to our own duty slot by theSlotMismatchcheck indo_validation, so the comparison is anchored to the duty, not to leader input.Acceptance criteria
versionis rejected withVersionMismatch, not a decode error.DataVersionGloasis rejected the same way (the rule is era-generic and hardens pre-Gloas slots too).versionmatchesfork_name_at_slot(duty.slot)validates exactly as before (decode arms, slashing check, and error surfaces unchanged).disable_slashing_protection.validateplumbing; no qbft-crate change).Tests
cargo test -p ssv_types. The existing Gloas proposer fixtures (consensus.rs,test_proposer_duty()+DataVersion::from(ForkName::Gloas)around:3000-3160) cover decode; add validator-level tests, which need aProposerConsensusDataValidatorinstance (constructing one takes aSlashingDatabase; withdisable_slashing_protection: truethe DB is never touched, so a tempfile-backed one suffices):VersionMismatch { expected, got }carrying the right forksChainSpecwithgloas_fork_epochset sofork_name_at_slotexercises both erasNotes
BEACON_ROLE_PROPOSERarm only, per SIP-94 §4. The legacyBEACON_ROLE_AGGREGATORarm (consensus.rs:367-372) shares the version-trust pattern but is a pre-Boole path already slated for removal (TODO(post-boole)at:363); out of scope here.EnvelopeConsensusData(feat(ssv_types): add BlindedExecutionPayloadEnvelope and EnvelopeConsensusData with QbftData and value check #1121): SIP-94 §6's value check deliberately ignoresversionfor the envelope type.Versionon a Gloas duty slot before decoding. Anchor enforces exact fork-schedule equality in both directions, matching the SIP rule rather than relying on a wrong-fork decoder to reject the reverse mismatch.Issues are directionally correct, not prescriptive; verify symbols at PR time.
Addendum (2026-07-22): also pin the decoded block's slot to the duty slot
The version check makes the
data_sszdecode arm trustworthy, but the decoded block's ownslotremains unchecked against the duty. The block is signed underheader.slotand slashing protection keys onheader.slot(validate_block_proposal, epoch/domain derivation andpreliminary_check_block_proposal), so a leader stamping a correct duty while embedding a block built for another slot S gets a valid signature for S that the duty-level checks never see: an equivocation vector, and an unboundedheader.slotfeeding the domain computation. go-ssv rejects exactly this in PR #2901 ("gloas block slot does not match duty slot"). Not an explicit SIP-94 MUST; adopted as slashing-safety hardening and reference-implementation parity.Suggested change, in
validate_block_proposalafter the header decode and before the slashing gate (so it also runs underdisable_slashing_protection):with a
BlockSlotMismatch { expected: Slot, got: Slot }variant styled after the existing mismatch variants.value.duty.slotis pinned to our own duty by theSlotMismatchcheck, same trust anchor as the version check.Additional acceptance criteria:
BlockSlotMismatchin both the Gloas and pre-Gloas decode arms.disable_slashing_protection.