ePBS (EIP-7732) Support - #94
shane-moore wants to merge 85 commits into
Conversation
iurii-ssv
left a comment
There was a problem hiding this comment.
Appreciate this effort! Doing first pass, just started looking into Gloas, forgive AI-heavy commentary (seems relevant though).
Nit: would be nice to list/organize the duties affected/added by the actual slot timeline (eg. "Proposer Preferences Duty", should come first, then "Modified Proposer Duty", then "Modified Attestation Duty", etc.)
thanks for taking a look! will resolve all the comments this week 😃 |
|
I opened ethereum/EIPs#11684 to update the EIP-7732 Gloas summary against the current consensus-specs Gloas files: Since this SIP depends on those EIP-7732 details, I would appreciate review there as well. |
the current top-level order mirrors upstream's grouping in |
…erences Pin updated from f1371480c4 to upstream master HEAD following PR review feedback from iurii-ssv and diegomrsantos. Net changes in the Proposer Preferences section: ProposerPreferences now carries dependent_root, bid handshake matches on (proposal_slot, dependent_root), gossip rule is first-valid-per-tuple, new Security Considerations entry on too-early publication. PTC paragraph also tightened to distinguish PAYLOAD_ATTESTATION_DUE_BPS from PAYLOAD_DUE_BPS. Slot Timing, Attestation Duty, and Proposer Duty sections unchanged at target pin. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A fresh-eyes review found §6 and §7 disagreeing on what first-valid means: §6 said operators store the first dissemination passing the decision-binding checks, while §7 records the first structurally valid message and classifies the binding checks as runner concerns, making §6's reading unreachable. §6 now matches §7: the first §7-valid dissemination is stored, and the runner abstains if the stored envelope fails its own decision bindings. Also makes the §7 decode rule explicit about the inner blinded container (undecodable bytes must not consume the slot's dissemination budget), bounds it by the SSV message data cap rather than an undefined progressive-container maximum, and broadens the Security Considerations residual: any well-formed first dissemination from a Byzantine member costs the reveal, whether or not it passes the binding checks.
Node-side surface for Gloas (ePBS) Payload Timeliness Committee duties, per SIP ssvlabs/SIPs#94: - types/gloas: PayloadAttestationData (42B SSZ) and PayloadAttestationMessage (146B SSZ), plus JSON-only PTCDuty, matching the beacon-APIs Gloas schemas; SSZ round-trip and golden JSON wire-format tests. - goclient: hand-rolled HTTP for the three PTC endpoints (get duties, produce data, submit messages). go-eth2-client has no Gloas provider yet, so these are direct requests until it is rebased. - beacon: standalone PTCCalls interface (folded into BeaconNode with the PTC runner later) plus regenerated MockPTCCalls; GoClient conformance asserted in tests.
iurii-ssv
left a comment
There was a problem hiding this comment.
The updated local block building LGTM, left a couple suggestions to polish/finalize it below.
Anchor implemented this in sigp/anchor#1288 and validated it on a Gloas devnet, so the rule is known implementable before it is written down here. Section 7 keyed the dissemination dedup on (MessageID, slot), so the first structurally valid carrier was the only one an operator would forward or consider. One committee member broadcasting a well-formed but decision-unbound envelope ahead of the builder therefore made every operator abstain, costing the cluster its reveal. Dedup is now one per (MessageID, signer, slot), and section 6 signs the first candidate whose envelope satisfies the four bindings rather than the first that arrives, continuing to the payload-due cutoff. The previous commit reconciled section 6 to section 7 after a review found them disagreeing about what first-valid meant. That resolved the conflict in the wrong direction: section 6's original content-based reading was correct, and section 7's per-slot dedup was what made it unreachable. Two rules that were implicit are now stated, because an implementation that reads them differently is exploitable or unbounded. Recording must follow the signature check, or a forged carrier claiming another operator's identity consumes that operator's budget and suppresses its honest message. And forwarding is bounded by committee size, which holds only because the signature check is scoped to committee members. Security Considerations narrows accordingly: the binding-failing carrier is no longer a liveness risk, and what remains is a binding-passing forgery that differs only in the unchecked PayloadRoot, splitting operators across two roots by node-local arrival order. That case tolerates no Byzantine member when ExecutionRequests is empty, and the named hardening is now sign-all relative to the new rule rather than to first-valid. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJP7vu2EGTFknFswZpjNjJ
Section 6 named a sole disseminator and publisher without saying how an operator learns it is that one. "The QBFT leader" does not work: after a round change the deciding leader re-proposes an earlier leader's block, so its beacon node did not build it. An operator is now the builder operator when the block in its own produce response carries the decided root, the local test section 4 already uses for the Eth-Builder-Url echo, which is round-change safe. The envelope now comes from the operator's own BlockContents, obtained by calling produceBlockV4 with include_payload=true, so no beacon-node call sits between the decision and the dissemination. The stateful branch is dropped: the envelope GET answers only on the node that built the block and the publish POST rejects a body produced elsewhere, so it forces node affinity on any operator with more than one beacon node. Publication is the Contents form to every beacon node, matching the section 4 block publish redundancy.
The previous commit deleted it, leaving a SHOULD on the inline produce variant with no alternative described, so a client that fetches the envelope from its beacon node had no path in the text at all. That is the common case today: a validator client can own the fetch and hand the envelope to the SSV layer to sign, which leaves the SSV side no way to choose the inline variant on its own. Both paths are described again, with the node affinity stated as the reason to prefer the inline one rather than as grounds for removing the other. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJP7vu2EGTFknFswZpjNjJ
The previous commit spelled out the beacon-node fetch as a separate allowed path, which SHOULD already grants. Keeping both said the same thing twice and read as an argument rather than a rule. The affinity cost now sits with the SHOULD as its justification, and the restatement is gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJP7vu2EGTFknFswZpjNjJ
Node-side surface for Gloas (ePBS) Payload Timeliness Committee duties, per SIP ssvlabs/SIPs#94: - types/gloas: PayloadAttestationData (42B SSZ) and PayloadAttestationMessage (146B SSZ), plus JSON-only PTCDuty, matching the beacon-APIs Gloas schemas; SSZ round-trip and golden JSON wire-format tests. - goclient: hand-rolled HTTP for the three PTC endpoints (get duties, produce data, submit messages). go-eth2-client has no Gloas provider yet, so these are direct requests until it is rebased. - beacon: standalone PTCCalls interface (folded into BeaconNode with the PTC runner later) plus regenerated MockPTCCalls; GoClient conformance asserted in tests.
GalRogozinski
left a comment
There was a problem hiding this comment.
Note, no special dissemination is needed on the self-building path
| - [`execution_payload` gossip topic (carries `SignedExecutionPayloadEnvelope`)](https://github.com/ethereum/consensus-specs/blob/a5a1bc630401eedbe2f3d87934c99012578c113b/specs/gloas/p2p-interface.md#new-execution_payload) | ||
| - [`POST /eth/v1/beacon/execution_payload_envelopes` endpoint](https://github.com/ethereum/beacon-APIs/pull/624) | ||
|
|
||
| On the self-build path (`bid.builder_index == BUILDER_INDEX_SELF_BUILD` per [EIP-7732](https://eips.ethereum.org/EIPS/eip-7732)), the proposer signs `SignedExecutionPayloadEnvelope` after block publication. The [§4](#4-modified-proposer-duty) decision pins exactly one valid envelope: `bid.block_hash` commits to the whole payload, and [`verify_execution_payload_envelope`](https://github.com/ethereum/consensus-specs/blob/a5a1bc630401eedbe2f3d87934c99012578c113b/specs/gloas/fork-choice.md#new-verify_execution_payload_envelope) checks every envelope field against the committed bid and block, so this duty runs no consensus. The flow is one dissemination round plus one partial-signature round; the operator whose beacon node built the decided block (the builder operator) both disseminates and publishes. |
There was a problem hiding this comment.
Suggestion: carry PayloadRoot in ProposerConsensusData and drop the dissemination round
If the §4 decision pins exactly one valid envelope, that value can be carried in the decision rather than distributed after it.
Prerequisite, worth doing anyway: blind execution_requests to its root too. The root formula below already shows this is free, its input is hash_tree_root(execution_requests). It also turns the binding check into a 32-byte comparison against bid.execution_requests_root, and keeps an unbounded ProgressiveList off the SSV wire. Then every envelope field is derivable from the decided block except PayloadRoot, a 32-byte gap.
Option A. Add PayloadRoot to ProposerConsensusData at Gloas, from the leader's own produce response, which it holds under §6's include_payload=true guidance. Require zero when builder_index != BUILDER_INDEX_SELF_BUILD, derivable from the same value, so an honest leader never trips it and the block value check gains no new failure mode. §6 keeps its signing round and publication rules, and drops the dissemination message type, container, four binding checks, selection rule, and §7 clauses.
Option B. As A, plus fold the envelope partial signature into the block's PostConsensusPartialSig packet as a second entry under DOMAIN_BEACON_BUILDER. Removes the round as well. Every SSV message carries a per-operator RSA signature (types/operator_signer.go), so each eliminated broadcast saves one signing and n-1 verifications per operator. Needs an exception to §7's one-entry rule for this role.
This is a Byzantine-tolerance improvement, not just bandwidth. Under a Byzantine leader both designs lose the reveal identically, since the bad root comes from the deciding leader either way. The difference is a Byzantine non-leader: the Security Considerations entry on forged dissemination concedes a binding-passing forgery splits the cluster on node-local arrival order and that the path "tolerates no Byzantine member." QBFT admits one justified value, so that entry and its hardening note both disappear.
Cost: a Gloas-conditional field in a container every proposer duty uses, plus a presence rule in §4's value check. Option B also touches a shared §7 rule.
The rationale bullet argues against consensus over the envelope, which is a different proposal. Neither option negotiates the envelope. They extend a negotiation that already has to succeed before the attestation deadline by 32 bytes.
There was a problem hiding this comment.
I think, back when we discussed it with Shane, I proposed "dissemination" for clarity / explicitness (conceptual simplicity) ... but if you think this is easy to optimize away, I don't mind - implementation-wise both approaches are relatively comparable in terms of complexity.
With execution_requests blinded to its root, everything in the envelope except PayloadRoot is derivable from the decided block, so carrying those 32 bytes in the §4 value lets every operator compute the signing root the moment §4 decides. That removes the new MsgType, the carrier, the per-signer dedup and the forged-dissemination residual, and keeps the unbounded requests list off the SSV wire.
I'd go for Option B directly. Nothing is deployed yet, and B is where this ends up: with the envelope signature riding the block's post-consensus packet there is no role 9, no EnvelopePartialSig, no envelope runner, and no §4→§6 handoff at all — the proposer runner signs both roots after the decision, reconstructs both from the same packets, and the builder operator publishes the reveal right behind the block, one gossip hop earlier than A. Stopping at A would leave role 9 and EnvelopePartialSig on the wire for one revision and then remove them, i.e. two migrations for Anchor and go-ssv instead of one. A is acceptable only as a deliberate stop if the shared change below is contested.
What B needs spelled out:
- Per-root domains in post-consensus verification. Today
expectedPostConsensusRootsAndDomainreturns roots plus one domain (spec and node alike); the packet would carry the block root underDOMAIN_BEACON_PROPOSERand the envelope root underDOMAIN_BEACON_BUILDER, so the domain becomes per root. Small per site, but it touches every runner and the post-consensus vectors. - §7 packet rule. The proposer's post-consensus packet carries up to two entries at Gloas slots; validation is content-agnostic, so the exception is by role and slot, not by build path.
Three things to pin down either way:
- Stateless produce becomes a MUST for the proposing operator. The leader has to know
PayloadRootbefore it proposes, so §6's SHOULD oninclude_payload=trueturns into a requirement on whoever proposes — otherwise the envelope GET lands on the block hot path, the objection that ruled folding out in June. go-ssv already runs the stateless path; worth confirming how Anchor's VC hands the root over. - Packaging. Keep
ProposerConsensusData(Duty,Version,DataSSZ) as is and make the GloasDataSSZa{block, payload_root}wrapper decoded by the existingVersionbranch — the fork-swap pattern §2 already uses for the vote type, so no cross-fork wire change. - Value check.
payload_rootMUST be zero iffbid.builder_index != BUILDER_INDEX_SELF_BUILD; beyond that it stays leader-trusted, asPayloadRootis today.
Publication is unchanged: builder operator by produce-root match, Contents body to each beacon node.
There was a problem hiding this comment.
Done in 0f7fe152, Option B as you both described. One addition: the envelope entry is best-effort. A block-only packet is valid and final, so verifyExpectedRoot's exact-count check becomes "block root required, envelope root optional, nothing else counted".
There was a problem hiding this comment.
Agreed on the receiver side: a block-only packet must stay valid and final. On the sender side I'd make it a MUST rather than a SHOULD. An operator that decided a self-build value derives the envelope root from that value with nothing extra, so omission is never a legitimate choice, and SHOULD reads as if it were; a compliant-but-omitting implementation would quietly deny the cluster its reveal. "MUST include, receivers MUST NOT require" is the usual split.
There was a problem hiding this comment.
@MatheusFranco99
See if you like how @iurii-ssv answered your question :-)
Being lenient means that if for whatever reason we need to change behavior, it can be done w.o a fork.
I don't think there's room for much security considerations since only the leader holds the envelope
There was a problem hiding this comment.
Agreed. Sender MUST include the envelope entry; receivers MUST NOT require it, so a faulty sender loses only its envelope share and, as Gal says, behavior can change later without a fork. 5d6addeb.
…o post-consensus
Adopt Option B from the section 6 review thread: the Gloas DataSSZ becomes
{block, payload_root}, execution_requests is blinded to its root, and the
envelope signature rides the proposer's post-consensus packet as a second
entry under DOMAIN_BEACON_BUILDER. The dissemination message type, role 9,
EnvelopePartialSig, and their section 7 rules are removed. include_payload=true
becomes a MUST at Gloas slots. Section 2 gains a MAY for the same-slot check.
|
Great improvement folding the envelope into the block duty! One suggestion on §4: make the envelope entry a MUST for the sender, and keep the receiver lenient. Right now the sender "SHOULD include" it and the receiver treats it as MAY. But every field of the envelope's signing input is now derivable from the decided value (that's the point of carrying About the receiver side, I'm still not sure:
What do you guys think? @shane-moore @GalRogozinski |
The envelope entry in the Gloas proposer post-consensus packet is a MUST for senders; receivers still must not require it, so a faulty sender loses only its envelope share. Section 6 states that the payload, requests, blobs, and proofs never cross the SSV wire, replacing the narrower blobs sentence in section 4.
|
Small nit: §2 and Security Considerations now disagree about the The clause added in
Security Considerations still reads, unchanged:
Both statements are defensible on their own — the accepted tradeoff still holds for the general non-same-slot case, and the new clause is scoped to same-slot only. But a reader who consults Security Considerations alone will conclude no local check is available, which is now incomplete. Suggest a sentence there noting the §2 same-slot exception and that it is optional. Measurement that may be useful for the MAY/SHOULD question. We ran the same-slot case in QA on 2026-09-09 (ssv-mini, 4 operators, Gloas active): a leader flipping a valid index caused the beacon node to refuse the committee's attestations with a gossip REJECT per Implementation status for reference: not implemented on Also noting @iurii-ssv raised this in the §2 thread on 2026-05-17 and @shane-moore ruled out a BN lookup on 2026-05-18 as reintroducing per-operator agreement on the hot path — the new clause appears to answer that by using only instance-start knowledge. |
Node-side surface for Gloas (ePBS) Payload Timeliness Committee duties, per SIP ssvlabs/SIPs#94: - types/gloas: PayloadAttestationData (42B SSZ) and PayloadAttestationMessage (146B SSZ), plus JSON-only PTCDuty, matching the beacon-APIs Gloas schemas; SSZ round-trip and golden JSON wire-format tests. - goclient: hand-rolled HTTP for the three PTC endpoints (get duties, produce data, submit messages). go-eth2-client has no Gloas provider yet, so these are direct requests until it is rebased. - beacon: standalone PTCCalls interface (folded into BeaconNode with the PTC runner later) plus regenerated MockPTCCalls; GoClient conformance asserted in tests.
…tions Section 2's same-slot AttestationDataIndex check becomes a SHOULD after SSV Labs measured the loss it prevents, and the Security entry that says the index is leader-trusted now names this check as its one exception.
|
Good catch, the Security entry now names the §2 exception. Your measurement settled the keyword: SHOULD in |
|
Coming at this from implementing it in ssv-spec (ssvlabs/ssv-spec#633): I'd like to propose relaxing the one-entry rule for Today §5 has each distinct auth signing root travel in its own single-entry packet, and §7 enforces at-most-one-entry (REJECT above 1) for the validator-scoped roles. The stated reason is isolation: a divergent builder entry should cost that builder's bids, never the slot. I think we get that isolation more cheaply on the receive side. All the auth roots belong to the same duty, the same proposal slot and the same runner — the only thing that differs is which configured entry each one is for. So the runner can validate per entry instead of per packet: derive the root set from its own configured entries, accept the entries whose root is in that set, ignore the rest. A divergent entry then costs that builder and nothing else, which is exactly the property §5 is after, with one packet per slot instead of up to eight. Message validation would then only have to bound the size: allow The reason this has to be written into §7 rather than left to the runner: validation is content-agnostic, so it can't do the per-entry matching itself, and the one-entry rule fires before the runner ever sees the packet. As it stands, a multi-entry auth packet is REJECT'd on the wire regardless of what the runner would have done with it. |
|
§3 says an operator that has seen no beacon block for the slot abstains, and §7 fixes the REJECT/IGNORE convention, but neither says what the abstaining operator should do with the PTC partials that keep arriving from its peers. That case is worth pinning, because the two outcomes differ for the sender: the abstainer is still a forwarding peer, and a REJECT costs the gossip score of a peer whose only difference is that it did see the block. That's an honest divergence of observation, not a fault located in the message — which by §7's own rationale is the IGNORE side of the line. Suggestion: add a line to §3 or the §7 table saying that an operator with no frozen observation IGNOREs incoming PTC partials rather than treating them as invalid. It also gives implementations something to map onto, since otherwise "I abstained" and "you signed the wrong root" surface identically. |
Node-side surface for Gloas (ePBS) Payload Timeliness Committee duties, per SIP ssvlabs/SIPs#94: - types/gloas: PayloadAttestationData (42B SSZ) and PayloadAttestationMessage (146B SSZ), plus JSON-only PTCDuty, matching the beacon-APIs Gloas schemas; SSZ round-trip and golden JSON wire-format tests. - goclient: hand-rolled HTTP for the three PTC endpoints (get duties, produce data, submit messages). go-eth2-client has no Gloas provider yet, so these are direct requests until it is rebased. - beacon: standalone PTCCalls interface (folded into BeaconNode with the PTC runner later) plus regenerated MockPTCCalls; GoClient conformance asserted in tests.
This SIP describes the ssv spec changes needed to keep operators performing validator duties correctly after ePBS (EIP-7732) lands in the consensus layer Gloas fork. Covers earlier slot deadlines,
AttestationData.Indexpropagation throughBeaconVote, the new PTC committee duty, theproduceBlockV4proposer flow (self-build vs external-builder variants), and the newSignedProposerPreferencesbroadcast.