Skip to content

feat(validator_store): implement sign_execution_payload_envelope #1126

Closed

Description

@shane-moore

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 BlockService can call the method after block publish, and an operator returns a SignedExecutionPayloadEnvelope<E> only when its local full envelope blinds to the decided value; the returned message is 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 calls decide_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, and beacon_block_root mismatch, protecting against invalid values proposed by another operator.
  • Publish gate compares the full blinded envelope value, not only payload_root. A same-payload_root mismatch in execution_requests, builder_index, beacon_block_root, or parent_beacon_block_root must 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

Issues are directionally correct, not prescriptive; verify symbols at PR time.

Activity

  1. added
    epbsePBS / EIP-7732 / Gloas implementation
    on Jul 9, 2026
  2. shane-moore commented on Sep 24, 2026

    @shane-moore
    MemberAuthor

    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.

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

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