Skip to content

feat(ssv_types): add GloasBeaconVote with QbftData for Gloas slots - #1059

Merged
petarjuki7 merged 2 commits into
sigp:epbsfrom
petarjuki7:feat/gloas-beacon-vote
Jun 2, 2026
Merged

petarjuki7 merged 2 commits into
sigp:epbsfrom
petarjuki7:feat/gloas-beacon-vote

Conversation

@petarjuki7

Copy link
Copy Markdown
Member

Problem, Evidence, and Context

Under Gloas (Fork::CStar), AttestationData.index is BN-supplied and part of the signed attestation root. SSV's current BeaconVote drops index and lets each operator reconstruct it locally at signing time, which was safe pre-Gloas (deterministically 0 post-Electra, duty-derived earlier) but is unsafe post-Gloas: operators can agree on block_root/source/target and still produce different signing roots, and an operator can be led to sign both index=0 and index=1 for the same (source, target, slot) without local slashing protection tripping.

Resolves #1025. Spec source: SIP-94 §2.

Change Overview

This PR adds only the type and dispatch surface; value-checking (#1026) and end-to-end plumbing (#1027) ride on top.

Risks, Trade-offs, and Mitigations

  • Runtime panic on Gloas slots until feat(ssv_types): fork-aware GloasBeaconVoteValidator with slashability #1026 lands. create_gloas_beacon_vote_validator() is todo!(); any committee-attestation slot at active_fork(epoch) >= Fork::CStar will panic. The panic is intentional and obvious, and feat(ssv_types): fork-aware GloasBeaconVoteValidator with slashability #1026 is the immediate follow-up. Pre-Gloas operation is unaffected.
  • Wire mutual rejection across the fork epoch. Pre-Gloas operators decoding Gloas wire bytes hard-reject by design (covered by the rejection test). Rollout is coordinated via the existing FORK_PREPARATION_EPOCHS dual-subscription window.
  • Two-type duplication. Both types coexist during the Gloas activation window. A future SIP retires BeaconVote and renames the Gloas type back once Gloas has activated on all networks.

Validation

  • cargo test -p ssv_types --lib — 92 passed (3 new: SSZ round-trip, hash determinism with explicit Sha256(SSZ-bytes) assertion plus cross-index guard, length-mismatch rejection).
  • cargo test -p qbft_manager --lib — 18 passed (2 new: dispatch routes committee messages to the Gloas map at Fork::CStar, to the legacy map pre-CStar).
  • cargo test -p anchor_validator_store --lib — 33 passed, unchanged.
  • make cargo-fmt-check and make lint clean.
  • All five feat(ssv_types): add GloasBeaconVote with QbftData for Gloas slots #1025 acceptance criteria are covered by the new tests; the "BeaconVote tests unaffected" criterion is verified by the existing tests continuing to pass.

Rollback

Pure-additive at the type layer; the fork-gate in receive_data and the fork-branch in sign_committee_attestations collapse cleanly to the pre-PR shape if reverted. No config, data, or operational impact pre-Gloas.

Blockers / Dependencies

Closes sigp#1025.

Adds the QBFT-decided value type for committee attestation duties under
Gloas (Fork::CStar) and the fork-gated dispatch surface. Under Gloas,
AttestationData.index is BN-supplied and part of the signed attestation
root, so it must travel through QBFT rather than being reconstructed
locally at signing time.

- New `GloasBeaconVote { block_root, source, target, attestation_data_index }`
  alongside `BeaconVote`. Separate type so SSZ wire bytes mutually reject on
  length mismatch across the fork boundary (112 vs 120 fixed bytes). `QbftData`
  impl mirrors `BeaconVote::hash()` (SHA-256 over SSZ bytes).
- `QbftManager`: new instance map, `QbftDecidable<E>` impl, cleaner retention,
  and fork-gated routing in the `Role::Committee` arm of `receive_data`.
- `validator_store::sign_committee_attestations`: fork-branched
  `decide_instance` call; the Gloas branch references a
  `create_gloas_beacon_vote_validator()` stub whose body lands in sigp#1026.
  A `TODO(sigp#1027)` marker flags where the decided index is dropped today;
  sigp#1027 plumbs it into each validator's `attestation.data.index`.

Pre-Gloas paths are unchanged.
@codecov-commenter

codecov-commenter commented May 27, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.00000% with 44 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (epbs@b9aa7ea). Learn more about missing BASE report.

Files with missing lines Patch % Lines
anchor/validator_store/src/lib.rs 52.94% 32 Missing ⚠️
anchor/qbft_manager/src/lib.rs 54.16% 11 Missing ⚠️
anchor/common/ssv_types/src/consensus.rs 98.80% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             epbs    #1059   +/-   ##
=======================================
  Coverage        ?   62.11%           
=======================================
  Files           ?      156           
  Lines           ?    26380           
  Branches        ?        0           
=======================================
  Hits            ?    16387           
  Misses          ?     9993           
  Partials        ?        0           
Flag Coverage Δ
rust 62.11% <75.00%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@shane-moore

Copy link
Copy Markdown
Member

The new dispatch routes all Role::Committee wire messages to gloas_beacon_vote_instances at CStar, but sign_committee_sync_committee_signatures (validator_store/src/lib.rs:1362-1452) wasn't migrated and still calls decide_instance::<BeaconVote>. Pre-PR these paths shared one QBFT task per (committee, slot) via Initialized.on_completed: Vec (qbft_manager/src/instance.rs:51); post-PR they bifurcate into separate maps and sync-committee QBFT times out every Gloas slot.

Filed as #1061 (depends on #1027, since sync needs to seed the same GloasBeaconVote attestation does; a placeholder like index = 0 is unsafe because the second decide_instance caller's initial value is discarded). Not a merge blocker. Codebase is unusable on Gloas anyway until #1026/#1027 land; #1061 just needs to close before Hoodi CStar activation.

Comment on lines +922 to +925
pub block_root: Hash256,
pub source: Checkpoint,
pub target: Checkpoint,
pub attestation_data_index: u64,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: per-field /// docs would mirror Diego's request on #1047 (comment, satisfied in 15769b1). The struct-level doc here is solid; field-level docs mostly buy attestation_data_index showing up in LSP hover with its SIP-94 semantics, but matching the precedent across the sibling QBFT-value types in the same file keeps the contract visible where it's defined.

Suggested change
pub block_root: Hash256,
pub source: Checkpoint,
pub target: Checkpoint,
pub attestation_data_index: u64,
/// LMD-GHOST vote: root of the beacon block being attested to.
pub block_root: Hash256,
/// FFG source checkpoint, copied from the BN-supplied `AttestationData`.
pub source: Checkpoint,
/// FFG target checkpoint, copied from the BN-supplied `AttestationData`.
pub target: Checkpoint,
/// BN-supplied `AttestationData.index`. Under Gloas this encodes the
/// attester's fork-choice view of payload status (`0` = `EMPTY`,
/// `1` = `FULL` for non-same-slot attestations) and participates in the
/// signed attestation root, so it must travel through QBFT rather than
/// being reconstructed locally.
pub attestation_data_index: u64,

BeaconVote itself is undocumented at the field level today; happy to file a one-line follow-up to backfill it for symmetry (or roll it into #1026). Not a merge blocker either way.

0,
"Committee message pre-CStar must NOT spawn a GloasBeaconVote instance"
);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: like @jnhsigmap suggested on sibling PR (#1057 discussion), worth pinning the slot-to-epoch + active_fork(epoch) + >= Fork::CStar composition at the activation boundary with a non-zero CStar epoch. Today both tests use ForkSchedule::new(fork, ..., "test"), which puts the chosen fork at epoch 0, so slot.epoch(spe) and active_fork(epoch) aren't exercised on the boundary, and an off-by-one in any of the three would still pass.

Suggested change
}
}
/// Pin the slot-to-epoch + `active_fork(epoch)` + `>= Fork::CStar` composition
/// at the activation boundary. Off-by-one in any of the three would flip
/// exactly one of the assertions below.
#[tokio::test]
async fn test_committee_message_routes_at_cstar_activation_boundary() {
use std::collections::BTreeMap;
use types::Epoch;
const CSTAR_ACTIVATION_EPOCH: u64 = 5;
const SLOTS_PER_EPOCH: u64 = 32;
let setup = setup_test(1);
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 a valid schedule");
let manager = build_manager(&setup, schedule);
let last_pre_cstar = CSTAR_ACTIVATION_EPOCH * SLOTS_PER_EPOCH - 1;
let (signed, qbft) = build_committee_message(last_pre_cstar);
manager.receive_data(signed, qbft).expect("pre-CStar dispatch");
assert_eq!(manager.beacon_vote_instances.len(), 1);
assert_eq!(manager.gloas_beacon_vote_instances.len(), 0);
let first_cstar = CSTAR_ACTIVATION_EPOCH * SLOTS_PER_EPOCH;
let (signed, qbft) = build_committee_message(first_cstar);
manager.receive_data(signed, qbft).expect("CStar dispatch");
assert_eq!(manager.gloas_beacon_vote_instances.len(), 1);
// BeaconVote map still holds the pre-CStar instance.
assert_eq!(manager.beacon_vote_instances.len(), 1);
}

Not a merge blocker. If you'd rather defer, happy to file one follow-up issue covering all three fork-boundary gaps (this CStar gate, the AggregatorCommittee Boole gate in aggregator_tests.rs, and the PTCCommittee CStar gate on #1057).

@shane-moore

Copy link
Copy Markdown
Member

@claude, can you take a look? keep my comments on the pr in mind as well

@claude-code-actions-sigp

claude-code-actions-sigp Bot commented May 27, 2026 •

Copy link
Copy Markdown

Claude finished @shane-moore's task in 3m 41s —— View job


PR Review


Took a pass over the PR with Shane's comments in mind. Type/dispatch surface is well-scoped and the test set covers the stated acceptance criteria. Three things stand out, none of which I'd treat as a merge blocker (they line up with what Shane already flagged):

1. sign_committee_sync_committee_signatures is a real correctness gap (Shane's #1061)

I confirmed this. At anchor/validator_store/src/lib.rs:1386-1399, the sync-committee path still calls decide_instance::<BeaconVote> with no fork branch, while attestations at 1502-1565 fork-branch on >= Fork::CStar.

The pre-PR coupling that made this safe is in anchor/qbft_manager/src/instance.rs:48-86: a second decide_instance for the same (committee_id, instance_height) either reuses the running QBFT task by appending to Initialized.on_completed: Vec, or — once decided — replays the stored Completed<D> to the new caller. Both paths require the second caller's D to be the same type as the first, because Completed<D> carries the decided value and the channels are typed.

Post-PR, on a CStar slot the two callers want different D:

  • Attestations call with D = GloasBeaconVote and land in gloas_beacon_vote_instances (qbft_manager/src/lib.rs:312-316)
  • Sync committee calls with D = BeaconVote and lands in beacon_vote_instances

These are different DashMaps keyed independently, so two separate QBFT instances spin up against the same (committee_id, slot). The sync-committee instance has no peers proposing BeaconVote (everyone else is doing the Gloas dance), so it times out at every Gloas slot — that's Error::SpecificError(SpecificError::Timeout) on the sync-message return path at 1402-1405.

Shane is right that #1027 is the right place to actually fix this (the sync-committee path can't safely seed a GloasBeaconVote with index = 0 because the second decide_instance caller's initial value is discarded by the Initialized arm at instance.rs:71-77). Worth confirming that the #1061 description carries that "discarded initial value" detail; if not, Fix this → once. Otherwise no change requested in this PR.

2. Fork-boundary dispatch test — agree with Shane's nit

Both new tests in qbft_manager/src/tests/gloas_dispatch_tests.rs pin the chosen fork at epoch 0 via ForkSchedule::new(...), which exercises only one of the three composed steps in receive_data (the comparison itself), not the slot→epoch conversion or active_fork(epoch) lookup at the activation boundary. An off-by-one in Slot::epoch or a mis-keyed BTreeMap::range lookup would still pass.

Shane's suggested boundary test using from_fork_configs with Alan@0 + CStar@5 and slots 5*32-1 / 5*32 is the right shape — it pins all three legs. Worth folding in here rather than deferring, since this PR is the one introducing the CStar arm; the precedent in aggregator_tests.rs already has the same gap. If you'd rather defer and file one follow-up covering all three (CStar Committee, Boole AggregatorCommittee, CStar PTCCommittee on #1057), that's also reasonable.

3. Per-field doc comments on GloasBeaconVote (Shane's review nit)

Agree this is purely about LSP hover surface and matching the precedent on the sibling PayloadAttestationVote (lines 949-957) which has per-field /// docs. The struct-level doc at consensus.rs:909-918 already covers SIP-94 semantics, so this is a small consistency improvement. Not a blocker; happy to fold into #1026 as Shane suggests if you'd prefer to keep this PR purely additive at the type layer.

Other observations

todo!() in create_gloas_beacon_vote_validator (validator_store/src/lib.rs:891-897) — the PR description acknowledges the panic on Gloas slots until #1026 lands. The doc comment on the helper explicitly calls this out. Wiring the call site now (rather than a feature gate or unimplemented!()) does make #1026 a body-only change, which is the cleaner shape. Fine as-is.

PTCCommittee arm at qbft_manager/src/lib.rs:341-347 — unchanged in this PR, but worth noting that the TODO(cstar) there is the third unfixed CStar gate (alongside this PR's Committee gate and #1061's sync-committee gap). All three should land before Hoodi CStar activation.

Cleaner retention (qbft_manager/src/lib.rs:392-400) — gloas_beacon_vote_instances is correctly added to the cleaner with the same QBFT_RETAIN_SLOTS policy. No issue.

SSZ length-mismatch test (consensus.rs:2398-2420) — this is the load-bearing test for the "two types so wire formats mutually reject" claim in the PR description. Asserts BeaconVote::from_ssz_bytes rejects 120-byte input. Coverage for the reverse direction (GloasBeaconVote rejecting 112 bytes) would round it out symmetrically, but the asymmetry is fine: a Gloas operator decoding pre-Gloas wire bytes is the failure mode that actually matters at the fork boundary, and that's the direction the test pins. Not blocking.


Summary: type/dispatch surface looks correct and the byte-for-byte pre-Gloas claim holds. The two follow-ups Shane flagged (sync-committee bifurcation in #1061, boundary test) are the substantive items; everything else here is documentation polish.
• Branch: feat/gloas-beacon-vote

…dary

Addresses review feedback on sigp#1059. Existing tests put CStar at epoch 0,
so the slot-to-epoch + `active_fork(epoch)` lookup wasn't exercised — an
off-by-one in either would pass. New boundary test uses
`from_fork_configs(Alan@0 + CStar@5)` and asserts dispatch at slots
`5 * 32 - 1` and `5 * 32` lands in the correct map.

Also adds per-field `///` docs on `GloasBeaconVote` for symmetry with
`PayloadAttestationVote`.

@shane-moore shane-moore left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well done! great start getting into gloas work 🎉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants