Skip to content

feat(ssv_types): reject decided ProposerConsensusData whose Version mismatches the duty-slot fork #1129

Description

@shane-moore

Goal

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):
let expected = self.spec.fork_name_at_slot::<E>(value.duty.slot);
let got = ForkName::from(value.version);
if got != expected {
    return Err(DataValidationError::VersionMismatch { expected, got });
}

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.
  • Do NOT generalize this to EnvelopeConsensusData (feat(ssv_types): add BlindedExecutionPayloadEnvelope and EnvelopeConsensusData with QbftData and value check #1121): SIP-94 §6's value check deliberately ignores version for the envelope type.
  • 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 {
    return Err(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.
  • Matching block slot validates exactly as before.

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