Skip to content

feat: replace envelope QBFT with disseminate-and-sign (SIP-94 §6) - #1286

Merged
shane-moore merged 15 commits into
sigp:epbsfrom
shane-moore:envelope-no-qbft
Sep 3, 2026
Merged

feat: replace envelope QBFT with disseminate-and-sign (SIP-94 §6)#1286
shane-moore merged 15 commits into
sigp:epbsfrom
shane-moore:envelope-no-qbft

Conversation

@shane-moore

@shane-moore shane-moore commented Sep 2, 2026

Copy link
Copy Markdown
Member

Problem, Evidence, and Context (Required)

  • The envelope signing duty (role 9) was specced as a second QBFT round over a blinded envelope, and feat(validator_store): implement sign_execution_payload_envelope #1126 was written against that shape. SSV Labs has since adopted the no-consensus design and is shipping it for Sepolia; SIP-94 §6/§7 were rewritten accordingly (ePBS (EIP-7732) Support ssvlabs/SIPs#94). Anchor never shipped the QBFT variant, so this replaces it outright rather than migrating.
  • There is nothing for the cluster to agree on. Every field of the envelope is already pinned by the §4 block decision: BeaconBlockRoot is the decided block's root, ParentBeaconBlockRoot its parent, and BuilderIndex / hash_tree_root(ExecutionRequests) come from the decided block's bid. The single unpinned field is PayloadRoot, 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.
  • The timing budget no longer fits a consensus round. At consensus-specs pin a5a1bc630 the reveal is due at PAYLOAD_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.
  • Closes feat(validator_store): implement sign_execution_payload_envelope #1126. Its suggested approach (QBFT over EnvelopeConsensusData, post-consensus signing, content-matched publication) is superseded; the goal it states, replacing the Unsupported stub 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 BlindedExecutionPayloadEnvelope in a new SSV message class. Every operator validates that envelope against its own record of what §4 decided, signs the blinded signing root under DOMAIN_BEACON_BUILDER through 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 that sign_block spawns 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:

  1. common/ssv_types/src/dissemination.rs, message.rs, partial_sig.rs: the wire. SSVEnvelopeDisseminationMsgType = 3 carrying EnvelopeDissemination { slot, envelope }, and PartialSignatureKind::Envelope = 10.
  2. 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.
  3. 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 task sign_block spawns from it; and the rewritten sign_execution_payload_envelope for the builder path.
  4. common/dissemination_store: the handoff from the message receiver to the waiting runner. First-write-wins, all same-key waiters woken, slot-bounded eviction.
  5. qbft_manager/src/lib.rs and common/ssv_types/src/consensus.rs: the deletions, best read last.

Intentionally unchanged: BlindedExecutionPayloadEnvelope and its progressive merkleization (only the EnvelopeConsensusData wrapper is gone); the Lighthouse sign_execution_payload_envelope trait signature and the entire block-proposal path; the signature collector, duty scheduling, PTC, and proposer preferences; role 9's message-ID constants and DOMAIN_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::EnvelopeProposer moving to the max_round() = None arm 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)

  • Wire rollout: a node without this change fails SSZ decode on MsgType 3 and 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.
  • First-valid dedup is the SIP's accepted trade, and it is a real residual: pubsub records the first structurally valid dissemination per (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.
  • One absolute payload-due deadline (50% of the slot) bounds the pre-dissemination check, the non-builder wait, and the signature collection, so a duty that cannot land releases its task instead of leaking into the next slot.
  • Non-builder result shape: the Lighthouse trait returns the full signed envelope and Lighthouse publishes every Ok, so a non-builder, which never holds the payload bytes, must return Err. 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 an Ok would 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 for UnknownPubkey) and is tracked separately.
  • External builds short-circuit: when the decided bid names an external builder there is no self-build envelope duty, so the runner returns a no-op sentinel immediately after the context lookup rather than waiting for a dissemination that cannot arrive. Without that gate every non-builder would block to the deadline and report a failure on the common mainnet path.
  • PayloadRoot stays 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)

  • Unit coverage, all green: anchor_validator_store 167, message_validator 136, ssv_types 132, dissemination_store 7, qbft_manager 34. 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_block spawns the task only for a self-build decision on another operator's block and not as builder or on an external-build decision, a repeated sign_block for 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.
  • Root equivalence is pinned against a fixed vector, as SIP-94 §6 requires, not just same-implementation parity: the blinded envelope's root is asserted equal to a constant that was independently reproduced with the consensus-specs pyspec (remerkleable) at pin a5a1bc630, which merkleizes the same fixture as a progressive container with all five active fields.
  • Devnet, ssv-mini, 4 Anchor operators with a Gloas fork and 10 managed validators, operators split across two beacon nodes whose ELs stamp different extraData so each proposal has real builders and real non-builders (verified by non-builders' local envelope root differing from the disseminated root they signed): 24 managed proposals after the fork, 24 envelopes disseminated, signed, published, and confirmed served by the beacon node for their slot. Zero failed outcomes, zero dissemination timeouts, zero binding mismatches, and the only validation rejects in the whole run were the expected relayed-duplicate IGNOREs from the second builder on the same beacon node. Finality advanced throughout.
  • Second devnet campaign on the task-based non-builder path, same topology: 23 managed proposals after the fork, 23 envelopes revealed, zero task failures, zero dissemination timeouts, zero duplicate shares. One operator stopped for 65 slots gave 8 duties at exactly 3-of-4, all revealed, and its restart rejoined in both roles. At two slots a non-builder's task signed before its own Lighthouse had fetched the local envelope, an ordering the callback path cannot produce. Three operators received a second sign_block for the same slot from Lighthouse about 12 s after the first; slashing protection rejected it as SameData before the spawn, which the new repeat-call test pins.
  • Devnet faults: one operator stopped for 70 slots gave 10 duties at exactly 3-of-4, including a slot where the stopped operator was the round-1 QBFT leader (survivors round-changed, decided at ~2.0s, envelope published at 2.1s, revealed, with ~3.9s of margin to the deadline); restart rejoined as a full signer within six slots. A beacon node paused for 16 slots correctly dropped the cluster below threshold at block QBFT, so no envelope duty started without a decided block, and recovery was clean. Captures and scripts retained locally, available on request.
  • An independent adversarial review of the branch found four defects, all fixed here: the external-build wait, the missing inner-envelope decode at pubsub, the missing fixed-root vector, and a §6/§7 contradiction over what "first valid" means (fixed in the SIP text, with §6 now matching the implementation).
  • make cargo-fmt-check and make lint clean. cargo test --workspace green except spec_tests, which fails on a missing spec_tests/ssv-spec fixture 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)

  • Plain revert, and the QBFT machinery it restores was referenced by nothing outside the deleted code. No config, data, or operational impact. The duty exists only after the Ethereum Gloas fork, which no production network has scheduled, so nothing live is stranded; on a Gloas devnet a revert simply returns the fleet to having no envelope duty, which costs reveals and carries no slashing risk (envelope signing appears in no Gloas slashing predicate at the pin).

Blockers / Dependencies (Optional)

  • Wire numbers are currently assigned by SIP-94 alone. ssv-spec main defines MsgType 0/1/2 and partial-signature kinds 0/1/4/5/6, so MsgType 3 and 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.
  • go-ssv's epbs-gloas branch 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_nodes is 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_nodes unset until the upstream change lands (Lighthouse's own open TODO in block_service.rs).

Additional Info / Next Steps (Optional)

🤖 Generated with Claude Code

https://claude.ai/code/session_01YZaz43sphQ8tVVjFwUSzd8

shane-moore and others added 9 commits September 1, 2026 08:36
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-commenter

codecov-commenter commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.17647% with 48 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (epbs@52ba589). Learn more about missing BASE report.

Files with missing lines Patch % Lines
anchor/validator_store/src/lib.rs 93.95% 13 Missing ⚠️
anchor/message_receiver/src/manager.rs 0.00% 10 Missing ⚠️
anchor/operator_doppelganger/src/service.rs 0.00% 6 Missing ⚠️
...or/validator_store/src/testing/envelope_signing.rs 98.83% 6 Missing ⚠️
anchor/message_validator/src/dissemination.rs 98.89% 5 Missing ⚠️
anchor/client/src/lib.rs 0.00% 3 Missing ⚠️
anchor/common/dissemination_store/src/lib.rs 98.57% 2 Missing ⚠️
anchor/message_validator/src/lib.rs 95.91% 2 Missing ⚠️
anchor/common/ssv_types/src/dissemination.rs 93.33% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             epbs    #1286   +/-   ##
=======================================
  Coverage        ?   78.67%           
=======================================
  Files           ?      178           
  Lines           ?    39400           
  Branches        ?        0           
=======================================
  Hits            ?    30998           
  Misses          ?     8402           
  Partials        ?        0           
Flag Coverage Δ
rust 78.67% <97.17%> (?)

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

☔ View full report in Codecov by Harness.
📢 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.

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.
shane-moore and others added 2 commits September 2, 2026 18:02
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 shane-moore left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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).

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.

2 participants