feat: replace envelope QBFT with disseminate-and-sign (SIP-94 §6) - #1286
Conversation
…e partial-signature kind
…n the decided context
The earlier commits on this branch were formatted with stable rustfmt, which ignores the unstable options in rustfmt.toml (imports_granularity, wrap_comments). Re-run cargo +nightly fmt --all so CI's cargo-fmt-check passes: import merging and line reflows only, no semantic change.
The envelope duty now disseminates the decided blinded envelope and threshold-signs it without a consensus round (SIP-94 §6), so the role-9 QBFT path is dead code: - EnvelopeConsensusData, its QbftData impl, EnvelopeConsensusDataValidator, EnvelopeValidationError, and BEACON_ROLE_ENVELOPE_PROPOSER are removed from ssv_types along with their tests. The BlindedExecutionPayloadEnvelope container and its root-parity test stay: the new flow disseminates and signs it. - Role::EnvelopeProposer moves to the max_round() = None arm, so the message validator's generic gate now rejects role-9 consensus messages as UnexpectedConsensusMessage (covered by the non-consensus-role test). - qbft_manager drops the envelope instance map, the role-9 dispatch arm, the EnvelopeProposerInstanceId, the QbftDecidable impl, and the envelope dispatch tests. - The role-9 duty-limit fixture now uses the dissemination message class, matching the only wire class the role still carries.
Four confirmed findings from a fresh-eyes review of the disseminate-and-sign rewrite: - An external-build decision now short-circuits the duty right after the context lookup with a dedicated no-op sentinel and metrics label. Before, the context-side builder-index check sat inside validate_blinded, which non-builders only reach after the dissemination wait, so every non-builder on an external-bid proposal blocked until the payload-due deadline and logged a spurious DisseminationTimeout failure. - The dissemination validator now SSZ-decodes the inner envelope (SIP-94 §7 decode rule, Reject-class, EthSpec-independent for the Gloas shape) before the first-valid record, so garbage bytes from a Byzantine member can no longer consume the slot's dissemination budget or draw invalid-message penalties from conformant peers that reject what we forward. - The blinded-envelope parity test now pins a fixed expected root (SIP-94 §6 requires a vector, not only same-implementation parity); the vector is self-generated at consensus-specs pin a5a1bc630 and still needs a go-ssv cross-check when one exists. - The decided context records the block's own parent_root rather than the bid's copy, matching the SIP §6 binding-source text exactly.
…ector The pinned blinded-envelope root was reproduced with the consensus-specs pyspec (remerkleable) at pin a5a1bc630, so the comment now states the vector is a second-implementation check rather than self-generated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YZaz43sphQ8tVVjFwUSzd8
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## epbs #1286 +/- ##
=======================================
Coverage ? 78.67%
=======================================
Files ? 178
Lines ? 39400
Branches ? 0
=======================================
Hits ? 30998
Misses ? 8402
Partials ? 0
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
cargo-sort rejects the workspace and message_receiver manifests at 6f26e78; move the new dependency into alphabetical position.
Sign a non-builder's SIP-94 §6 envelope share from a task spawned in sign_block once the decided block is threshold-signed, instead of from Lighthouse's envelope callback. That callback first fetches the operator's own envelope from its beacon node and never reaches the store when the node holds none (its local bid was external while the cluster decided a self-build block), so the share was lost. The callback now returns a delegated sentinel for non-builder contexts; the builder path is unchanged. Tracks sigp#1287.
Lighthouse can call sign_block twice for one slot: a second block-service notification for the same slot hit three of four operators at one devnet slot, about 12 s after the first call. The non-builder envelope task is spawned only after sign_abstract_block, so the repeat is rejected by slashing protection as SameData before it can spawn a second task. Nothing pinned that placement: the existing spawn-path tests disable slashing protection and call sign_block once. Add a slashing-enabled test that calls sign_block twice and expects one envelope collection; it fails with two collections if the spawn is moved above the slashing check. Also blind the local envelope only on the builder path. The non-builder callback now returns before using it, so hashing the full payload up front was wasted work on every non-builder duty.
feat(validator_store): detach non-builder envelope signing from the Lighthouse callback
EnvelopeProposer is a non-QBFT role: the consensus-message validator rejects every consensus message for it, and that rejection is already tested. Three partial-signature tests still built role-9 Prepare messages to seed DutyState, certifying state the binary cannot reach and implying a coupling to the deleted envelope QBFT flow. Replace them with the reachable cross-class cases. Disseminations and Envelope partial signatures share one max_slot per signer, so: a same-slot dissemination then share is accepted (the builder's normal sequence), a later dissemination makes a lower-slot share stale, and a later share makes a lower-slot dissemination stale.
shane-moore
left a comment
There was a problem hiding this comment.
Reviewed 61777f8b against epbs at 52ba589f, covering wire types, message admission and duty state, dissemination handoff, validator-store builder and detached non-builder paths, signature collection, the removed QBFT path, the Lighthouse callback boundary, and regression tests.
No blocking findings remain. The implementation matches the SIP-94 disseminate-and-sign design: dissemination is structurally validated before first-valid state is recorded, the runner binds the blinded envelope to the recorded block decision, signatures use the builder domain, and non-builder signing begins only after successful block signing and its slashing-protection check. Follow-up commits removed unreachable role-9 QBFT test state, deferred full-payload hashing to the builder path, pinned repeated sign_block behavior, and added a real fork-boundary wire test for broadcast_dissemination.
The remaining --proposer_nodes builder-side envelope fetch issue is documented as a Lighthouse limitation and is not introduced by this PR. Local verification at this head: all 34 signature_collector release tests, focused clippy with warnings denied, formatting, and diff checks pass. Core CI is green; release/debug tests, coverage, CLI reference, and local testnet are still running. I consider this ready to merge once those finish green.
Reviewed by GPT-5.6 Sol (xhigh reasoning).
Problem, Evidence, and Context (Required)
BeaconBlockRootis the decided block's root,ParentBeaconBlockRootits parent, andBuilderIndex/hash_tree_root(ExecutionRequests)come from the decided block's bid. The single unpinned field isPayloadRoot, and that one is builder-trusted by construction: publishing needs the payload bytes behind the root, so finding a payload for a fabricated root is a hash preimage. A forgery can cost a reveal, never put a wrong payload on chain.a5a1bc630the reveal is due atPAYLOAD_DUE_BPS = 5000, i.e. 50% of the slot, so the duty owns roughly 25% to 50% (~3s on a 12s slot), not the ~6s assumed when the QBFT variant was proposed. A round change costs a fixed 2s (qbft_manager/src/timeout.rs,QUICK_TIMEOUT), which a 3s budget cannot absorb on top of two message phases.EnvelopeConsensusData, post-consensus signing, content-matched publication) is superseded; the goal it states, replacing theUnsupportedstub with a working envelope duty, is what this delivers. Spec: SIP-94 §6/§7. Milestone: ePBS: Envelope Signing Duty.Change Overview (Required)
After §4 decides and publishes a self-build Gloas block, the operator whose own block was the decided one broadcasts its
BlindedExecutionPayloadEnvelopein a new SSV message class. Every operator validates that envelope against its own record of what §4 decided, signs the blinded signing root underDOMAIN_BEACON_BUILDERthrough the existing partial-signature collector, and only the builder operator returns the reconstructed envelope for Lighthouse to publish. The builder signs from Lighthouse's envelope callback; every other operator signs from a task thatsign_blockspawns once the decided block is threshold-signed, so a non-builder share never depends on that callback, which Lighthouse gates on its own node's block (#1287). No consensus round, one dissemination plus one signing round, both inside the payload-due window.Reading order:
common/ssv_types/src/dissemination.rs,message.rs,partial_sig.rs: the wire.SSVEnvelopeDisseminationMsgType = 3carryingEnvelopeDissemination { slot, envelope }, andPartialSignatureKind::Envelope = 10.message_validator/src/dissemination.rs: the §7 rules for the class. Role-9 only, exactly one signer, Gloas fork gate, inner SSZ decode, first-valid dedup per (MessageId, slot), lateness, duty count, signature. Structural only by design: the decision bindings are a runner concern.validator_store/src/lib.rs:DecidedBlockContext, recorded at block-decide time where the decided value and the operator's own proposal are both in scope;sign_disseminated_envelope, the non-builder tasksign_blockspawns from it; and the rewrittensign_execution_payload_envelopefor the builder path.common/dissemination_store: the handoff from the message receiver to the waiting runner. First-write-wins, all same-key waiters woken, slot-bounded eviction.qbft_manager/src/lib.rsandcommon/ssv_types/src/consensus.rs: the deletions, best read last.Intentionally unchanged:
BlindedExecutionPayloadEnvelopeand its progressive merkleization (only theEnvelopeConsensusDatawrapper is gone); the Lighthousesign_execution_payload_envelopetrait signature and the entire block-proposal path; the signature collector, duty scheduling, PTC, and proposer preferences; role 9's message-ID constants andDOMAIN_BEACON_BUILDER. Every §7 rule that is role-keyed rather than class-keyed (earliness, lateness, proposer assignment, duty limit, monotonic slot) is inherited by the new class with no edits.Role::EnvelopeProposermoving to themax_round() = Nonearm is what makes the existing generic gate reject role-9 consensus messages, so no new rejection code was needed.Risks, Trade-offs, and Mitigations (Required)
MsgType 3and rejects, penalizing the forwarding peer. Emission is Gloas-fork-gated, so a fleet that upgrades before the fork has no exposure; an operator that misses the upgrade will reject and penalize, the same exposure any fork-gated wire variant carries. SIP-94's decode-first note applies to release ordering, not to this merge.MessageId, slot) and IGNOREs the rest, so a Byzantine committee member that wins the race with a well-formed but decision-unbound carrier costs the cluster one self-build reveal. Never a wrong payload, per the preimage argument above. Mitigated in part by enforcing the inner-envelope decode at pubsub, so malformed bytes cannot consume the budget. The named hardening, per-originator admission with first-binding-valid at the runner, is intended before mainnet and is a §7 text change, not a wire change.Ok, so a non-builder, which never holds the payload bytes, must returnErr. Its callback returns a dedicated sentinel immediately (the share is signed by the task, not here), which Lighthouse logs as one recoverable "Error whilst producing block" per non-builder per duty even though block and envelope both succeeded. There is no non-cosmetic Anchor-side fix: fabricating anOkwould publish an invalid envelope. The clean fix is upstream (skip the envelope step when the decided bid is not this node's, treat the sentinel as non-fatal, as Lighthouse already does forUnknownPubkey) and is tracked separately.PayloadRootstays builder-trusted, matching the blinded-block trust model: honest non-builders never see the payload, and "self-build" is a label, not provenance.Validation (Required)
anchor_validator_store167,message_validator136,ssv_types132,dissemination_store7,qbft_manager34. Envelope-specific: 24 tests over the duty (builder disseminates/signs/publishes, the non-builder task signs the disseminated root, the non-builder callback returns the sentinel without collecting,sign_blockspawns the task only for a self-build decision on another operator's block and not as builder or on an external-build decision, a repeatedsign_blockfor the same slot is rejected by slashing protection before it can spawn a second task, signing root under the builder domain, slashing DB untouched, binding mismatch, undecodable dissemination, dissemination timeout on a paused clock, deadline passed, external-build short-circuit on a paused clock so a regression into the wait hangs rather than passes, broadcast failure, metrics labels, sign_block to envelope end to end), 11 over the new validation class, 7 over the store. Role-9 partial-signature coverage seeds state only through disseminations and shares, the two message classes that exist for the role, never through QBFT messages the validator rejects.a5a1bc630, which merkleizes the same fixture as a progressive container with all five active fields.sign_blockfor the same slot from Lighthouse about 12 s after the first; slashing protection rejected it asSameDatabefore the spawn, which the new repeat-call test pins.make cargo-fmt-checkandmake lintclean.cargo test --workspacegreen exceptspec_tests, which fails on a missingspec_tests/ssv-specfixture directory; that reproduces with this branch's changes stashed, i.e. it is an uninitialized submodule in the working copy, not a regression.Rollback (Required for behavior or runtime changes; optional otherwise)
Blockers / Dependencies (Optional)
maindefinesMsgType0/1/2 and partial-signature kinds 0/1/4/5/6, soMsgType 3and kind 10 are free but not yet registered upstream; if ssv-spec lands different values this needs a follow-up before any cross-client deployment.epbs-gloasbranch still implements the QBFT variant, so cross-client interop cannot be exercised until their no-QBFT implementation ships. Cross-implementation root fixtures against their SSZ library are wanted once it exists.--proposer_nodesis incompatible with the builder side of the self-build envelope duty at the current Lighthouse pin. Block production goes through the proposer fallback (proposer nodes first), but the envelope fetch and publish use only--beacon_nodes, and the beacon node's envelope cache is per node. A builder with proposer nodes set gets a 404 on the fetch and never reaches the callback: a missed reveal. Non-builders are unaffected, their share comes from the task. Operators on Gloas devnets should leave--proposer_nodesunset until the upstream change lands (Lighthouse's own open TODO inblock_service.rs).Additional Info / Next Steps (Optional)
🤖 Generated with Claude Code
https://claude.ai/code/session_01YZaz43sphQ8tVVjFwUSzd8