Repository navigation
feat(validator_store): implement sign_execution_payload_envelope #1126
Description
Goal
Replace the Unsupported stub at validator_store/src/lib.rs:3315 with the SIP-94 envelope-signing duty against the current Lighthouse full-envelope trait: blind the full ExecutionPayloadEnvelope that Lighthouse hands in for the internal QBFT value, collect post-consensus signatures under Domain::BeaconBuilder, and return the full SignedExecutionPayloadEnvelope only when the local full envelope blinds to the decided value.
Context / motivation
Lighthouse already drives this duty inline from BlockService: after a Gloas block is signed and published, it fetches the envelope, calls sign_execution_payload_envelope(pubkey, envelope), and POSTs every successful result (block_service.rs:627-689, :698-715 at LH pin 81d576943). The trait passes the full envelope and no separate duty or slot (validator_client/validator_store/src/lib.rs:202-207), so the slot derives from envelope.slot() (payload.slot_number, execution_payload_envelope.rs:65-67). The current pin's envelope fields are payload, ExecutionRequestsGloas<E>, builder_index, beacon_block_root, and parent_beacon_block_root (execution_payload_envelope.rs:20-27).
The intended shape is go-ssv parity at the SSV layer: no pre-consensus phase, QBFT over EnvelopeConsensusData.DataSSZ = SSZ(BlindedExecutionPayloadEnvelope), value-check against the block-QBFT-decided root, post-consensus signing under DOMAIN_BEACON_BUILDER, and content-matched publication only by operators whose full local envelope blinds to the decided value. Content matching does not elect a unique publisher: multiple operators may hold byte-identical matching envelopes. One current Lighthouse seam differs from go-ssv: go-ssv can let non-builders skip publication and still mark the duty successful, but this LH trait POSTs every Ok, so Anchor must return a distinct non-publish error for non-matching operators. This is the only safe result with the current trait, but Lighthouse wraps every signer error as BlockError::Recoverable and logs it as a block-proposal failure even though the block was already published. It does not retry this path.
ethereum/beacon-APIs#624 currently proposes removing the public blinded-envelope transport from the API. That is fine for this issue: Anchor's internal QBFT value remains blinded, while the Lighthouse-facing method and publication path are full-envelope at the current pin.
Suggested approach
Lead with symbols; line hints drift.
async fn sign_execution_payload_envelope(
&self,
validator_pubkey: PublicKeyBytes,
envelope: ExecutionPayloadEnvelope<E>,
) -> Result<SignedExecutionPayloadEnvelope<E>, Error> {
let (validator, cluster) = self.get_validator_and_cluster(validator_pubkey)?;
let validator_index = validator.index.ok_or(SpecificError::MissingIndex)?;
let slot = envelope.slot();
// Defense in depth: LH currently calls this only for Gloas local building, but that caller
// assumption is temporary. Refuse before initializing QBFT or emitting a partial signature.
if !self.spec.fork_name_at_slot::<E>(slot).gloas_enabled() {
return Err(/* dedicated pre-Gloas / role-not-active error */);
}
if envelope.builder_index != BUILDER_INDEX_SELF_BUILD {
return Err(/* dedicated envelope-duty-not-applicable error */);
}
// Blind the local full envelope for the internal QBFT value. Current LH uses ExecutionRequestsGloas<E>.
let local_blinded = BlindedExecutionPayloadEnvelope::from_full(&envelope)?;
let data = EnvelopeConsensusData::new(/* duty, version = Gloas, ssz(local_blinded) */);
// Value check needs the block-QBFT-decided root for (validator_pubkey, slot).
let decided_block_root = self.get_decided_block_root(validator.public_key, slot)?;
let validator_fn = self.create_envelope_consensus_data_validator(
validator.public_key,
validator_index,
slot,
decided_block_root,
);
let decided = self.consensus.decide_instance(
EnvelopeProposerInstanceId {
validator: validator.public_key,
instance_height: slot.as_usize().into(),
},
data,
validator_fn,
TimeoutMode::Relative { current_round_start_time: self.get_instant_in_slot(slot, Duration::ZERO)? },
&cluster.cluster_members,
).await?;
let decided_blinded = decided.decode_blinded_envelope::<E>()?;
// Non-slashable. No slashing-protection call.
let domain = self.get_domain(slot.epoch(E::slots_per_epoch()), Domain::BeaconBuilder);
let signing_root = decided_blinded.signing_root(domain);
let signature = self.collect_signature(
PartialSignatureKind::PostConsensus,
Role::EnvelopeProposer,
CollectionMode::SingleValidator,
&validator,
&cluster,
signing_root,
slot,
).await?;
// LH POSTs every Ok, so non-builders must return a distinguishable non-publish error.
if local_blinded != decided_blinded {
return Err(Error::SpecificError(SpecificError::EnvelopeNotBuiltLocally));
}
Ok(SignedExecutionPayloadEnvelope { message: envelope, signature })
}The non-publish error name is illustrative, but it must let Anchor-owned logs and metrics distinguish this successful non-publish outcome from QBFT, decode, collection, or reconstruction failures. The current Lighthouse caller cannot make that distinction and will still emit its generic error-level proposer-failure log.
Metrics fold in: add an "envelope" label for CONSENSUS_TIMES. If adding Anchor-owned envelope signature/reconstruction failure metrics, do not count the intentional non-builder path as an infra failure; use a separate outcome label such as published / not_built_locally if we want visibility.
Acceptance criteria
- Lighthouse
BlockServicecan call the method after block publish, and an operator returns aSignedExecutionPayloadEnvelope<E>only when its local full envelope blinds to the decided value; the returnedmessageis byte-identical to the full envelope Lighthouse passed in. - Anchor starts one per-proposer envelope QBFT instance and signs the decided blinded root under
Domain::BeaconBuilder; the reconstructed signature verifies for the full envelope because the blinded and full envelope roots are equal. - No slashing-protection or doppelganger-protection path is touched.
- Before QBFT initialization, the local sign path rejects a slot whose scheduled Ethereum fork is pre-Gloas and rejects an input envelope whose
builder_index != BUILDER_INDEX_SELF_BUILD; neither path callsdecide_instance, emits a consensus message, or enters partial-signature collection. - The consensus value check independently rejects wrong duty slot, wrong validator index, wrong validator pubkey, non-self-build
builder_index, missing decided block root, andbeacon_block_rootmismatch, protecting against invalid values proposed by another operator. - Publish gate compares the full blinded envelope value, not only
payload_root. A same-payload_rootmismatch inexecution_requests,builder_index,beacon_block_root, orparent_beacon_block_rootmust return the distinct non-publish error. ExecutionRequestsGloas<E>is preserved through local blinding, QBFT SSZ, decoding, and root-equivalence tests.- Non-matching operators do not POST and are not counted as reconstruction or infra failures by Anchor-owned telemetry. At the current LH pin, the sentinel error still produces Lighthouse's generic error-level proposer-failure log after the block has already been published.
- Under normal devnet conditions, envelope QBFT, reconstruction, return, Lighthouse POST, and gossip complete before the SIP-94 payload-due cutoff. Record completion timing relative to the cutoff.
max_round = Some(2)bounds accepted QBFT rounds but does not itself impose an end-to-end deadline.
Tests
Unit coverage: local blinding preserves payload_root and all non-payload fields, expected post-consensus signing root/domain, no pre-consensus path, pre-Gloas local rejection before QBFT, non-self-build local rejection before QBFT, no consensus or partial-signature emission from either local rejection, decided-root required, value-check rejection matrix including its independent non-self-build check, no slashing DB interaction, builder content-match returns the original full envelope with signature, non-builder content mismatch returns the distinct non-publish error, same-payload-root/different-metadata mismatch, and metric/error classification.
The existing validator_store mock consensus decider echoes the proposed value, so content-mismatch tests need a configurable decided-value mock rather than only the current echo mock.
Integration (ssv-mini/devnet): a Gloas self-build slot completes both the block QBFT and envelope QBFT, at least one operator with a matching local envelope publishes the signed envelope, no operator with a non-matching envelope publishes, and PTC records payload_present = TRUE. Multiple operators holding byte-identical matching envelopes are permitted to publish.
Notes
- Depends on feat(ssv_types): add Role::EnvelopeProposer with message-validator fork gate #1120, feat(ssv_types): add BlindedExecutionPayloadEnvelope and EnvelopeConsensusData with QbftData and value check #1121, feat(qbft_manager): wire EnvelopeProposer per-proposer QBFT instances #1122, and feat(validator_store): thread the block-QBFT-decided root to the envelope value check #1124.
- go-ssv parity target: mirror
EnvelopeBuilderRunner's no-pre-consensus flow,DOMAIN_BEACON_BUILDERpost-consensus signature, structural value check, and content-matched publish. The intentional current-pin divergence is the non-builder return contract: go-ssv marks the skip successful, while LH has no clean skip result and reports Anchor's required sentinel error as a generic proposer failure. - Current LH pin caveat: the envelope GET is slot-only and
first_success, so multi-BN operators can fetch from a BN that did not build the block. The content gate contains this to a missed envelope, never a wrong publish. - Future Lighthouse/API caveat: remove BlindedExecutionPayloadEnvelope ethereum/beacon-APIs#624 currently keeps the
{slot}/{beacon_block_root}lookup but returns a fullExecutionPayloadEnvelope. It also changes publish selection toEth-Blob-Data-Included, withSignedExecutionPayloadEnvelopefor stateful publication andSignedExecutionPayloadEnvelopeContentsfor stateless publication. When Lighthouse adopts that shape, Anchor should adapt to the actual trait without changing the internal blinded-QBFT core.
Issues are directionally correct, not prescriptive; verify symbols at PR time.
Activity
shane-moore commentedon Sep 24, 2026
Closed as completed by #1263 (merged 2026-08-19, sign self-build envelopes) and #1286 (merged 2026-09-03, replaced the QBFT shape with SIP-94 §6 disseminate-and-sign); both bodies say "Closes #1126". Not auto-closed because the merge target is not the default branch.