Skip to content

ePBS (EIP-7732 / Gloas) — SIP-94 - #2901

Draft
iurii-ssv wants to merge 127 commits into
boole-forkfrom
epbs-gloas
Draft

ePBS (EIP-7732 / Gloas) — SIP-94#2901
iurii-ssv wants to merge 127 commits into
boole-forkfrom
epbs-gloas

Conversation

@iurii-ssv

@iurii-ssv iurii-ssv commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Node-side ePBS (EIP-7732 / Gloas) support, per SIP ssvlabs/SIPs#94.

Built off boole-fork (ePBS depends on the Boole baseline: consolidated roles, ProposerConsensusData, the node-side switches). It will rebase onto stage once Boole lands there.

The ePBS wire constants (runner/beacon roles, partial-sig types, beacon domains 0x0B/0C/0D, and the #2962 request-auth pair DomainRequestAuth 0x0B000001 / RequestAuthPartialSig(9)) live in ssv-spec via PR #632 — the established spectypes pattern, and required because ekm/ssvsigner reaches signing domains only through spectypes. Their golden test lives there; both go.mods are pinned to that branch until it merges.

What's here

  • protocol/v2/types/gloas package — node-side Gloas wire types (SSZ + JSON): the PTC containers (PayloadAttestationData/Message, PTCDuty), GloasBeaconVote (fixed 120-byte, cross-fork decode fails cleanly), the §4 block family (BeaconBlock, ExecutionPayloadBid, EIP-8282 five-list ExecutionRequests), the §6 envelope family (full + blinded), and ProposerPreferences. Layouts pinned by round-trip tests and a devnet golden block.
  • Fork gating & retiming (SIP §1)GLOAS_FORK_EPOCH read from the BN spec into the fork map; IsGloas/IsGloasAtSlot, safe on pre-Gloas networks. IntervalDuration becomes slot-keyed (1/3 of the slot → 1/4 at the fork) and drives duty deadlines, QBFT round-1 head starts, aggregation waits, and attestation fetch budgets. QuickTimeout is deliberately not retimed — the Gloas proposer is round-1-must-succeed pending real round-trip data.
  • §2 Gloas attestations — the committee runner agrees on GloasBeaconVote (carrying the BN's payload-status index) on Gloas slots: fork-aware decode across runner, post-consensus validation, committee observer, and duty tracer; NewGloasVoteChecker; index-preserving aggregation; and a hand-rolled attestation-data GET on Gloas slots (go-eth2-client's post-Electra Index==0 check rejects the healthy FULL status).
  • §3 PTC attestation — full slice — goclient endpoints; PTCAttesterRunner (no consensus, partial-signature only, honest convergence over the frozen 75%-cutoff observation, abstains when no block is seen); scheduler handler firing at the cutoff; duty store + message validation; ekm signing.
  • §4 Gloas block proposal — produce/publish via the merged beacon-APIs endpoints (v4 produce, include_payload=false); the bid-only block is the QBFT value directly (no blinding); every operator submits the decided block (BN dedupes by root — to be re-confirmed on a Gloas BN); slashing-protected local signing (check→record→sign under a dedicated lock); build-source telemetry; new ProposerDelayEPBS knob (hard-capped at 1s, no dangerous override).
  • §5 Proposer preferences — one duty per upcoming proposal slot across the lookahead, plus the MIN_SEED_LOOKAHEAD pre-fork emission window; a multi-slot dispatcher runner (honest convergence over fee recipient / target gas limit / dependent_root, singleflighted dependent_root fetch); message validation with a future-slot allowance and a bounded distinct-root dedup (admitting dependent_root re-emissions); ValidatorRegistration is deprecated at the fork (rejected wire-side, drained scheduler-side).
  • §6 Execution-payload envelope (self-build only) — the proposer records its §4-decided block root and triggers a second QBFT instance (EnvelopeProposerRunner, role 9) over the blinded envelope; only the operator whose cached envelope content-matches the decided value publishes the full body; the value check ties the envelope to the §4-decided root. The envelope QBFT window is now [25%, 50%] of the slot — consensus-specs#5414 lowered PAYLOAD_DUE_BPS 75%→50% (§3 PTC's PAYLOAD_ATTESTATION_DUE stays 75%).
  • ePBS/Gloas: external-builder authentication & per-builder bid preferences — design questions #2962 phase 1 — direct-builder request auth (opt-in overlay) — the threshold RequestAuthV1{data, proposal_slot} signing (builder-specs DOMAIN_REQUEST_AUTH, genesis-style) rides the §5 dispatcher: per configured builder — new Builders cluster config in keymanager-APIs#87 vocabulary, validated at startup, required identical across all n operators — one single-root RequestAuthPartialSig per proposal slot, collected in a dedicated container with no succeeded-gate, reconstructed into a per-validator auth cache for the produceBlockV4-POST attach (beacon-APIs#625, upstream-gated). Message validation admits the new type under role 8 with its own distinct-root budget (§5 two-tier REJECT/IGNORE). Sub-quorum degrades silently to the enshrined flow — never blocks the proposal; reconstructions are counted and the §4 build-source telemetry is now a typed enum staged for the phase-2 reason split. Design/tracking: #2962 (phases 2–3 stay upstream-gated).
  • Riding along (not Gloas-specific) — duty-fetch retry when no validators are eligible yet (attester/proposer/sync-committee handlers); scheme-less beacon-address normalization; zero-registry-address guard in SSVConfigByName; GetStateRoot nil-state guard; runner-state JSON dedup.
  • Exporter cleanup (unrelated tagalong) — removed a dead vendor/ sszgen directive; documented that exporter/model_encoding.go is hand-maintained.

Validation — hermetic Gloas devnet (local_testnet_gloas)

Validated on a hermetic Gloas devnet (geth/lighthouse glamsterdam-devnet-6, lighthouse v8.2.0-36b70da, Gloas fork @ epoch 2), read from Loki node logs + the beacon API. Suites: ssvlabs/aetheria#130 (proposer, §4/§5/§6) + #128 (ptc, §3).

§ Duty Status
§2 Attestation ✅ works
§3 PTC (payload attestation) ✅ works post-fork
§4 Proposer block ✅ works — publication passes; proposed block canonical on-chain
§5 ProposerPreferences ✅ works — emit → submit, 0 errors; POST …/validator/proposer_preferences → 200
§6 Execution-payload envelope ✅ publishes + lands on Lodestar v1.43.0 (#2921 — full/unblinded body); residual all-publish dedup → #2923

The node's ePBS implementation is spec-correct — §2–§5 are confirmed working on-chain, and §6 publishes and lands on Lodestar v1.43.0 (the live devnet-6 CL). §6 produce is missing in lighthouse v8.2.0 (a generic beacon-APIs#580 404) but works on Lodestar. #2921 now publishes the full/unblinded envelope (SignedExecutionPayloadEnvelopeContents), which Lodestar accepts — the earlier 400 "Offset out of bounds" (blinded body) is gone and the envelope lands on-chain. #2922 makes the all-operators §4 submit handle Lodestar's 500 BLOCK_ERROR_ALREADY_KNOWN as success (the "BN dedupes by root" assumption doesn't hold on Lodestar; the block still lands). Residual: §6 has the same all-publish dedup as §4 — on self-build every operator publishes the identical envelope, so the redundant ones hit 500 EXECUTION_PAYLOAD_ENVELOPE_ERROR_ALREADY_KNOWN (the §6 analog of #2922, tracked in #2923). E2E-verify context in #2920 (V1/V2/V6 — resolved there; the issue is closed, see the e2e strategy below). (§3 also shows a transient pre-fork 500 IncorrectStateVariant on lighthouse's PTC-duties lookahead, self-clearing once the head crosses the fork.)

E2E strategy. The hermetic Aetheria local_testnet_gloas net above is the ePBS proving ground, and public-network e2e rides the Sepolia/Hoodi Gloas forks on the existing SSV deployments there — GLOAS_FORK_EPOCH is read from the BN at runtime, so no new networkconfig is needed. A dedicated SSV cluster on the public glamsterdam devnet was considered and dropped: standing up a fresh SSV environment (contract deploy, registry bootstrap, operator onboarding) buys little over those two paths. #2920 is closed accordingly, its remaining verify items split across the successors: the manual Sepolia fork log-check (#2953), the node-design verification items V5/V8/V9/V11 (#2954), the Aetheria harness on Hoodi post-fork (ssvlabs/aetheria#139), and automated fork-transition coverage (ssvlabs/aetheria#141). The GlamsterdamDevnet networkconfig stub has been dropped from the branch accordingly (the zero-registry-address guard it motivated in SSVConfigByName stays).

Known limitations / deferred

  • Remote signing (Web3Signer): no request types for the four new domains (incl. ePBS/Gloas: external-builder authentication & per-builder bid preferences — design questions #2962's request auth) and no Gloas block path — remote-signing operators fail those duties (bounded: the cluster reconstructs while ≤ f operators are remote). Remote fork_info now carries the Gloas fork so every other remote duty signs under the correct domain (whether that alone satisfies a live Web3Signer is an e2e-confirm — #2954 (V11)). Deferred §4 fix direction: pass the block header (HTR-equal to the block, nameable across the module boundary) instead of the Gloas block; whether Web3Signer accepts a Gloas-version block request is unverified.
  • §5 publish-finality: preferences publish on quorum with no hold-until-final guard, so a preference published just before a reorg shifts its dependent_root is not retracted. Re-emission itself now works — message validation admits up to N=4 distinct signing roots per (slot, signer) and the scheduler re-emits only on a real dependent_root change (the now-agreed SIP-94 §5 rule, still to be matched by Anchor). Only the pre-publish finality hold stays deferred. Low severity (reorg-gated, §5 is observational).
  • Upstream-gated: the Gloas endpoints are hand-rolled HTTP until go-eth2-client ships Gloas (then typed provider calls); DataVersionGloas placeholder reconciliation — including the settled SIP §2 decision to stamp DataVersionGloas (not the temporary Fulu cap) into AggregatorCommitteeConsensusData.Version to byte-match Anchor, which is gated on ssv-spec: GetAggregateAndProofs/GetAggregateAndProofHashRoot both switch on Version with no Gloas arm, and go-eth2-client's VersionedAggregateAndProof has no Gloas field (the aggregate itself stays Electra-shaped); eth2-key-manager purpose-named root signer.

Merge & follow-up checklist

Merge blockers — to clear before this PR lands on stage:

  • Land the outstanding ePBS/Gloas: external-builder authentication & per-builder bid preferences — design questions #2962 review fixes on the branch: the stash-replay gate on re-emission (hasDutyRunninghasDutyAssigned), domain-fetch hard-fail, and minor polish
  • ssv-spec#632 merges → re-point both go.mods at the tagged ssv-spec (today they pin the branch tip, which already carries the EnvelopeBuilderEnvelopeProposer rename and the ePBS/Gloas: external-builder authentication & per-builder bid preferences — design questions #2962 request-auth constants; the tag should also carry the §2 AggregatorCommitteeConsensusData Gloas arm noted above)
  • Boole lands on stage → move the ePBS commits with git rebase --onto stage <boole-fork-tip> epbs-gloas (prefer a merge/FF over a squash for the Boole landing so the rebase stays mechanical); keep absorbing boole-fork refreshes until then
  • Re-run the Aetheria local_testnet_gloas suites (proposer + ptc) after the rebase and after each boole-fork refresh

Tracked follow-ups (post-merge / external, not in-branch):

  • E2E verify — the ePBS devnet e2e — glamsterdam-devnet (SIP-94, #2901) #2920 successors (that issue is closed; no dedicated SSV cluster on the public glamsterdam devnet, see the e2e strategy under Validation): manual Sepolia fork log-check #2953; node-design verification items V5/V8/V9/V11 #2954; Aetheria harness on Hoodi post-fork ssvlabs/aetheria#139; automated fork-transition coverage ssvlabs/aetheria#141. The hermetic local net is done & green — ssvlabs/aetheria#125.
  • Builders-configured local_testnet_gloas run — gauge the restart re-broadcast cost (a restart mid-lookahead re-broadcasts every auth root, up to entry-cap+1 same-peer REJECTs per pending slot; noted at broadcastAuthRoots) and the request-auth reconstruction telemetry
  • Cross-client / upstream: re-check the §5/§6 wire constants against Anchor once it implements them (PTC already verified against sigp/anchor) — now also RequestAuthPartialSig(9)/DomainRequestAuth and the role-8 dual-type validation rule; reconcile the SIP-94 §4/§6 text to the merged beacon-APIs#580 flow this implementation tracks (include_payload=false + blinded publish); run a computational SSZ/HTR cross-check (incl. the EIP-8282 five-list ExecutionRequests) once canonical Gloas spec vectors exist
  • SIP-94 request-auth amendment lands in SIPs#94 once builder-specs#165 merges (draft posted on #2962)
  • ePBS/Gloas: external-builder authentication & per-builder bid preferences — design questions #2962 phases 2–3 — the produceBlockV4 POST attach (gated on beacon-APIs#625 + the gossip-vs-contacted answer) and the ahead-of-time submitBuilderPreferences (gated on the AOT-channel answer); tracked on ePBS/Gloas: external-builder authentication & per-builder bid preferences — design questions #2962, not in this PR
  • MEV knobs: docs/MEV_CONSIDERATIONS.md ePBS rewrite (config.example.yaml already points readers there for ProposerDelayEPBS); re-evaluate proposals: mev/commit-boost driven MEV flow #2855 (ProposalSoftDeadline) against the tighter 25% proposal deadline once ePBS lands — decision + rationale recorded on proposals: mev/commit-boost driven MEV flow #2855.
  • §5 publish-finality — re-emission (N=4 distinct-root dedup + dependent_root-gated re-emit) is implemented; only the pre-publish finality hold remains deferred (low severity). Anchor still to match the §5 validation rule.
  • Deferred code refinements: assert the Eth-Consensus-Version response header on Gloas produce; unify the hand-rolled JSON/SSZ HTTP helpers; relocate DefaultGasLimit/feeRecipientProvider out of validator_registration.go when the VR runner is removed post-fork; reuse the shared Gloas test-block fixture in the goclient proposer tests.

@iurii-ssv
iurii-ssv force-pushed the epbs-gloas branch 2 times, most recently from bc977ed to 838fe4b Compare June 24, 2026 13:01
iurii-ssv added a commit that referenced this pull request Jun 28, 2026
Promote EPBS_IMPLEMENTATION_PLAN.md from local-only (.git/info/exclude) into
the branch so the in-flight ePBS planning context is shared, not local. The
file carries an explicit action item: before #2901 is marked ready for review,
move all remaining/useful action items into the PR description and delete this
file — it must not outlive the PR.
@iurii-ssv
iurii-ssv marked this pull request as ready for review June 28, 2026 12:10
@iurii-ssv
iurii-ssv requested review from a team as code owners June 28, 2026 12:10
@iurii-ssv iurii-ssv changed the title ePBS (EIP-7732 / Gloas): node-side foundation — wire types + fork gating ePBS: (EIP-7732 / Gloas / SIP-94) Jun 28, 2026
@greptile-apps

greptile-apps Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds the node-side foundation for ePBS/Gloas. The main changes are:

  • New Gloas wire types and SSZ/JSON encoders.
  • Beacon API clients for PTC, proposer preferences, Gloas blocks, and envelopes.
  • Fork gating and Glamsterdam devnet configuration.
  • PTC, ProposerPreferences, and EnvelopeBuilder runner wiring.
  • Local signer support for the new Gloas domains.

Confidence Score: 4/5

The Gloas message validation and block production paths need fixes before merging.

  • First-fork ProposerPreferences messages can be rejected during the intended lookahead window.
  • Valid payload-included Gloas block responses can fail to decode and skip proposal work.
  • The new runner and type surfaces otherwise look consistent with the inspected changes.

message/validation/signed_ssv_message.go; beacon/goclient/gloas_proposer.go

Important Files Changed

Filename Overview
message/validation/signed_ssv_message.go Adds fork-aware role validation for new Gloas roles, but ProposerPreferences does not account for the intended prior-window broadcast.
beacon/goclient/gloas_proposer.go Adds Gloas block produce and publish clients, but produce response handling only supports the bare block variant.
protocol/v2/types/gloas Adds node-side Gloas wire structures with generated SSZ support and round-trip tests.
protocol/v2/ssv/runner Adds PTC, proposer-preferences, envelope, and Gloas proposer runner paths.
operator/duties Adds duty handlers and scheduler wiring for PTC and ProposerPreferences.

Reviews (1): Last reviewed commit: "gloas: log §2 vote index + §5 proposer-p..." | Re-trigger Greptile

Comment thread message/validation/signed_ssv_message.go Outdated
Comment thread beacon/goclient/gloas_proposer.go
Comment thread message/validation/common_checks.go
Comment thread protocol/v2/ssv/runner/ptc_attester.go
Comment thread beacon/goclient/proposer_preferences.go
Comment thread networkconfig/glamsterdam-devnet.go Outdated
@iurii-ssv
iurii-ssv requested a review from momosh-ssv June 29, 2026 16:15
iurii-ssv added a commit that referenced this pull request Jun 30, 2026
Comment/doc-only response to a #2901 review (most findings were non-issues or
over-stated — assessed in the plan); the actionable bits:

- beacon_block.go: note blob KZG commitments also leave the body (the payload
  and blobs ship in the §6 envelope) — they were missing from the drops list.
- ptc.go: clarify the hand-rolled client's missing custom-TLS matches the main
  eth2clienthttp path (system-CA https + basic-auth), so it's no regression.
- plan §2: devnet-verify that a Gloas BN accepts the Fulu-tagged attestation
  submission (BeaconForkAtEpoch caps at Fulu; TODO(gloas) to extend if rejected).
- plan §2b: record the remote-signer limitation — Web3Signer has no PTC /
  proposer-preferences / envelope sign types; bounded by f, local-sign
  workaround; operator-facing.
@iurii-ssv

iurii-ssv commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

The ePBS validation log that lived in this comment has been moved into the PR description (the Validation section) — so it sits alongside the implementation summary and is easy to find. This comment is kept only as a pointer.

iurii-ssv added a commit that referenced this pull request Jun 30, 2026
Promote EPBS_IMPLEMENTATION_PLAN.md from local-only (.git/info/exclude) into
the branch so the in-flight ePBS planning context is shared, not local. The
file carries an explicit action item: before #2901 is marked ready for review,
move all remaining/useful action items into the PR description and delete this
file — it must not outlive the PR.
iurii-ssv added a commit that referenced this pull request Jun 30, 2026
Comment/doc-only response to a #2901 review (most findings were non-issues or
over-stated — assessed in the plan); the actionable bits:

- beacon_block.go: note blob KZG commitments also leave the body (the payload
  and blobs ship in the §6 envelope) — they were missing from the drops list.
- ptc.go: clarify the hand-rolled client's missing custom-TLS matches the main
  eth2clienthttp path (system-CA https + basic-auth), so it's no regression.
- plan §2: devnet-verify that a Gloas BN accepts the Fulu-tagged attestation
  submission (BeaconForkAtEpoch caps at Fulu; TODO(gloas) to extend if rejected).
- plan §2b: record the remote-signer limitation — Web3Signer has no PTC /
  proposer-preferences / envelope sign types; bounded by f, local-sign
  workaround; operator-facing.
iurii-ssv added a commit that referenced this pull request Jul 1, 2026
The proposer, attester and sync-committee duty-fetch handlers marked an
epoch/period intent fulfilled even when no validators were eligible at fetch
time, so the duties were never fetched once validators did become eligible
(e.g. after a beacon-metadata sync that arrives without an accompanying
indices-change event). On the Gloas devnet this surfaced as the proposer
missing every block it was assigned.

fetchAndProcessDuties now returns (fetched bool, err error); the caller marks
the intent fulfilled only when a beacon fetch actually ran. "No eligible
validators" returns fetched=false, leaving the intent pending so a later tick
retries — the same model the PTC and proposer-preferences handlers already use.

Also drop the now-redundant per-fetch bracket log lines, and add a temporary
proposer diagnostic (#2901) that dumps the Validators()/
SelfValidators() view on no-eligible to confirm the root cause on devnet.
Scheduler tests updated to assert the intent stays pending and that a late
indices-change remains the sole re-fetch trigger.
iurii-ssv added a commit that referenced this pull request Jul 1, 2026
Promote EPBS_IMPLEMENTATION_PLAN.md from local-only (.git/info/exclude) into
the branch so the in-flight ePBS planning context is shared, not local. The
file carries an explicit action item: before #2901 is marked ready for review,
move all remaining/useful action items into the PR description and delete this
file — it must not outlive the PR.
iurii-ssv added a commit that referenced this pull request Jul 1, 2026
Comment/doc-only response to a #2901 review (most findings were non-issues or
over-stated — assessed in the plan); the actionable bits:

- beacon_block.go: note blob KZG commitments also leave the body (the payload
  and blobs ship in the §6 envelope) — they were missing from the drops list.
- ptc.go: clarify the hand-rolled client's missing custom-TLS matches the main
  eth2clienthttp path (system-CA https + basic-auth), so it's no regression.
- plan §2: devnet-verify that a Gloas BN accepts the Fulu-tagged attestation
  submission (BeaconForkAtEpoch caps at Fulu; TODO(gloas) to extend if rejected).
- plan §2b: record the remote-signer limitation — Web3Signer has no PTC /
  proposer-preferences / envelope sign types; bounded by f, local-sign
  workaround; operator-facing.
iurii-ssv added a commit that referenced this pull request Jul 1, 2026
The proposer, attester and sync-committee duty-fetch handlers marked an
epoch/period intent fulfilled even when no validators were eligible at fetch
time, so the duties were never fetched once validators did become eligible
(e.g. after a beacon-metadata sync that arrives without an accompanying
indices-change event). On the Gloas devnet this surfaced as the proposer
missing every block it was assigned.

fetchAndProcessDuties now returns (fetched bool, err error); the caller marks
the intent fulfilled only when a beacon fetch actually ran. "No eligible
validators" returns fetched=false, leaving the intent pending so a later tick
retries — the same model the PTC and proposer-preferences handlers already use.

Also drop the now-redundant per-fetch bracket log lines, and add a temporary
proposer diagnostic (#2901) that dumps the Validators()/
SelfValidators() view on no-eligible to confirm the root cause on devnet.
Scheduler tests updated to assert the intent stays pending and that a late
indices-change remains the sole re-fetch trigger.

@momosh-ssv momosh-ssv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

amazing, gj!

iurii-ssv added a commit that referenced this pull request Jul 1, 2026
Two TEMP logging-only diagnostics to pinpoint where an assigned Gloas proposer
duty is lost between fetch and the runner. The existing zero-eligible diagnostic
only covers the "never fetched" case; these cover the loaded-epoch case:

- logSlotDispatchDiagnostic (processExecution): on any slot carrying a stored
  proposer duty, reports stored_any / in_committee / executable so a run can
  separate an InCommittee-flag drop from a one-slot-window miss from a downstream
  dispatch loss (cross-checked against the existing 🔧 executing validator duty /
  could not find validator logs).
- logFetchDispatchDiagnostic (fetchAndProcessDuties): flags in-committee duties
  stored for already-passed slots (fetched-too-late), and surfaces the InCommittee
  split on the fetch-success path.

Read-only; no control-flow change. Remove after devnet confirmation.
@ovidiu-ssv-labs ovidiu-ssv-labs added this to the QA - Glamsterdam Fork milestone Jul 2, 2026
@iurii-ssv iurii-ssv changed the title ePBS: (EIP-7732 / Gloas / SIP-94) ePBS (EIP-7732 / Gloas) — SIP-94 Jul 2, 2026
iurii-ssv added 5 commits July 3, 2026 11:36
The SIP #94 §1 retiming made duty deadlines interval-based (intervals of
1/3 of the slot before Gloas, 1/4 from Gloas on), leaving a few comments
and one error message still hard-coding pre-Gloas fractions:

- aggregator_committee: the aggregation-deadline wait call site said
  "spec: 2/3 into slot"
- goclient fetchVersionedAggregate: "gossip backfill before 2/3 of the
  slot" is now "before the aggregation deadline"
- goclient GetSyncCommitteeContribution: the "wait for 1/3 of slot"
  error is now "wait for sync message deadline", matching the sibling
  aggregation/contribution deadline errors; test assertion updated
- duties scheduler: SlotTicker doc said "1/3 of slot-time past slot
  start" though the ticker waits IntervalDuration(slot)

Also corrects waitIntoSlot's deadline labels: intervals=1 is the
attestation/sync-message deadline and intervals=2 the
aggregation/contribution deadline (contributions are due at two
intervals, as its own callers already say).
…1008)

The main-module misspell failures were masking this: make lint runs the
ssvsigner module's golangci pass only after the main module's passes, so
this one surfaced only once the misspellings were fixed. Full make lint
(both golangci passes, deadcode, openapi, ssvsigner-boundary) now passes
locally.
The §6 analog of #2922. On the self-build path every operator publishes the
identical execution-payload envelope, so the non-winning ones race the canonical
publish and Lodestar rejects them with 500
EXECUTION_PAYLOAD_ENVELOPE_ERROR_ALREADY_KNOWN — which surfaced at ERROR (could
not submit execution payload envelope), marked the §6 duty failed on those
operators, and skewed failed-submission metrics.

Generalize the §4 already-known classifier (isBlockAlreadyKnown -> isAlreadyKnown;
it already matched the envelope token) and apply it to the envelope publish, so
an already-known envelope is treated as success — keeping the all-publish
redundancy, matching the §4 handling.
…hange

SIP #94 §5 has a proposer re-emit its preference when the proposal slot's
dependent_root changes (e.g. after a reorg). Two node-side gaps blocked it:

- Message validation capped ProposerPreferences at one pre-consensus partial
  sig per (slot, signer), so a refresh (a new signing root) was rejected as a
  duplicate and gossip-penalized. Now track distinct signing roots per
  (slot, signer) and admit up to 4, treating a repeat root as a duplicate
  (same-peer REJECT, relayed IGNORE).

- The proposer-preferences scheduler re-emitted on every reorg, so a no-op
  reorg re-broadcast an identical preference and self-inflicted a gossip
  penalty. Now track the dependent_root last emitted per epoch and, on a reorg,
  re-emit only the epochs whose dependent_root actually changed
  (ProposerDutiesDependentRoot added to the scheduler BeaconNode interface).

Cleanups: fix a stale filename doc comment, and make ProposerPreferences
explicitly root-tracked rather than reusing the shared pre-consensus bit.
iurii-ssv added 2 commits July 8, 2026 13:16
SIP #94 §7 classifies a distinct, validly-signed ProposerPreferences root past
the per-(slot,signer) cap as rate-limiting (IGNORE), not a provable violation.
REJECT now fires only on a same-peer repeat of an already-seen root; a relayed
repeat or a distinct root past the world cap is IGNORE'd.
Two impl-vs-spec gaps surfaced by the SIP §7 cross-check of the
message-validation / QBFT value-check for the new Gloas roles:

- PTC duty-limit: was capped at SlotsPerEpoch (grouped with
  proposer-preferences and self-build envelopes on a mistaken
  "one per slot" basis). A PTC member is drawn from a beacon
  committee and a validator sits on exactly one beacon committee
  per epoch, so it has at most one PTC duty per epoch. Cap it at 2
  (one duty + reorg margin), matching aggregation / validator
  registration.

- Proposer value-check: reject a value on a Gloas slot carrying a
  pre-Gloas Version. ssv-spec's ProposerValueCheckF branches to
  Gloas on cd.Version while the node branches on the slot; without
  the guard a Byzantine leader could split the value-check across a
  mixed ssv/anchor cluster. Honest proposers always stamp
  Version == the slot's fork, so the guard only rejects malformed
  values (the reverse, a Gloas Version on a pre-Gloas slot, is
  already rejected via GetBlockData's unknown-version error).

Also correct the duty-limit rule summary: voluntary exit uses a
tracked count (not a fixed 2), and self-build envelopes share the
SlotsPerEpoch cap.
Hand-rolled Gloas requests (ptcDo) now surface non-2xx responses as a
typed *httpStatusError — same message, but callers can branch on the
status code. The §5 proposer-preferences submit uses it to wrap a 404
with a missing-endpoint hint: a beacon node predating the merged
beacon-APIs#608 route (e.g. Lodestar releases through v1.44.0, which
only serve an earlier draft path) 404s the final
POST /eth/v1/validator/proposer_preferences, and the duty-failure log
now says so instead of looking like a transient submit failure.
Operators broadcast their §5 partial signature exactly once, at their
own emission tick, and those ticks skew across the committee
(registration/event-sync timing). A partial arriving before the local
duty (re)started was only replayed by the queue for ~1 slot and then
dropped — unrecoverably, since the sender never re-broadcasts and
message validation dedups a same-root re-broadcast by signing root.
Every indices-change re-emission also replaced the sub-runner and
discarded the partials it had collected. On a devnet this starved every
first-§5-epoch duty (the Gloas fork-boundary epoch) on all operators:
each node held fewer than quorum partials and logged 'duty did not
complete before slot end'.

Runner (dispatcher):
- stash every §5 partial per proposal slot (bounded by committee ×
  the wire's distinct-root cap, deduplicated, pruned with past slots)
  and replay the stash into every (re)started sub-runner, so quorum
  forms regardless of emission order and container replacement.
- conclude a replaced sub-runner as not-required (superseded, not
  stuck), and carry the already-submitted preference over so an
  unchanged re-emission is idempotent: no duplicate gossip broadcast
  (peers would reject it) and no duplicate beacon-node submit; a
  dependent_root change still re-signs, re-broadcasts and resubmits.

Outcome watcher: a §5 duty legitimately keeps converging until its
proposal slot, so its stuck horizon is that slot's start rather than
the emission slot's end (which produced false 'stuck' warnings for
duties that could still complete).

Scheduler:
- give each §5 duty an execution window running to the end of its own
  proposal slot (was: end of the emission slot), matching the watcher.
- skip assignments whose proposal slot is already reached — their
  preference is moot and peers would reject the partials as late.
- force a one-time lookahead recheck on the first Gloas tick, like a
  reorg would: if the CL's reported dependent_root for the boundary
  epoch shifts at the fork transition, the pre-fork emission is
  refreshed; with an unchanged root the recheck is a no-op.
A §5 partial is broadcast exactly once and its bytes are deterministic,
so gossipsub's seen-cache makes any re-broadcast a no-op: a receiver
that wrongly drops the first copy can never recover it. The wrong drop
we observed: the §5 duty-existence rule evaluated against a proposer
duty view fetched before the receiver processed a just-added
validator's registration — honest partials rejected with 'no duty for
this epoch', quorum starved for that proposal slot (seen on the first
§5-eligible epoch right after a registration wave; the stash/replay
mechanism can't help since the message dies before the queue).

Two complementary changes:

Validation freshness: the duty store now tracks per-epoch staleness —
the ProposerHandler marks the current and next epoch stale the moment
an indices change arrives, and a completed refetch (Set) clears it.
The §5 proposer-preferences and §6 self-build-envelope duty-existence
checks treat a stale epoch like a not-yet-fetched one (skip, don't
reject); data keeps being served to checks that must always enforce
assignment. During such windows §5/§6 messages remain bounded by
committee membership, the per-signer distinct-root cap and the
per-epoch duty-count cap.

Emission grace: the §5 handler defers (re-)emission for two slots
after a validator-set change, so committee peers learn of the new
validators (contract-event sync) and refresh their duty views before
the one-shot partials are broadcast. Preferences target future slots,
so the delay costs nothing; a pending reorg/fork recheck survives the
grace and is consumed by the first post-grace tick.
…next tick

The ProposerHandler consumed indicesChangeCh only inside its tick loop,
raced against an intra-slot deadline, so the freshness marking §5/§6
message validation relies on could trail the event by up to a slot —
eating most of the §5 emission grace. A dedicated top-level case now
consumes the event immediately whenever the loop is idle: it marks the
current and next epochs stale and declares the refetch intents (the
next tick processes intents first thing, before duty execution). A
change landing while a tick is being processed is still caught by the
tick's own indices-change wait, keeping the same-slot refetch. Eager
consumption also relieves the fan-out back-pressure the buffered
handler channel was added to mitigate.
…e-emission

An indices-change re-emission of a §5 duty that had broadcast its
partial but not yet submitted (quorum still converging) re-signed and
re-broadcast the identical preference. Peers reject that as a same-peer
duplicate by signing root, so the operator self-inflicted a
gossip-scoring penalty for a message that was also useless — peers
stash the first copy, and the dispatcher replays it into the
replacement sub-runner anyway.

The slot runner now carries the previously broadcast preference across
replacements (alongside the previously submitted one): a re-emission
that rebuilds it byte-identically freezes it and keeps converging
without re-signing; a dependent_root change still re-signs and
broadcasts under the new root. This is the in-flight remainder of
issue #2934 — the already-submitted case was covered by the earlier
idempotent-re-emission change.
A message routed to a validator with no running local instance (and no
matching committee, non-exporter) was discarded silently. That hop was
the one place a §5 proposer-preferences partial could die without any
log signature: wire validation already passes on the stored share while
the validator instance is still starting (the seconds right after a
registration wave), and the one-shot broadcast lost there has no
redelivery — the §5 duty starves with nothing to grep. Every other hop
already logs its drops (validation ignore/reject with role+slot fields,
router buffer-full, queue replay exhaustion).

The fall-through now logs at debug — but only for validators that
belong to this operator: on shared subnets the same branch routinely
swallows other operators' validator traffic, which must stay quiet. The
line carries role, message type, pubkey and, for partial-signature
messages, slot and signer, making a starved duty attributable in one
query.
iurii-ssv added 10 commits July 26, 2026 14:40
V5 — §2 aggregate index across BNs. The aggregation path re-derives the
attestation-data root only when the cache of our own submitted root
misses, and that cached root is the QBFT-decided value, so the decided
payload-status index was never at risk on the common path. Make the
fallback self-correcting instead: on Gloas a 404 under our beacon
node's index retries under the flipped bit, the only other value the
§2 index can hold. Both roots come from one fetch, so nothing else can
differ. The retry is deliberately not applied on a cache hit, whose
root is decided by construction — a 404 there is a real miss and must
surface. The re-derivation also stops writing Index through the shared
per-slot attestation-data cache pointer; that write-through was dead in
practice (Electra+ beacon nodes already return Index=0) but live on the
pre-Electra branch.

V9 — telemetry gauging. PTC non-convergence now reports as its own
no_quorum duty outcome rather than hiding inside the generic "likely
stuck" every role shares. §3 has no consensus phase and already marks
every other terminal path — abstain to not_required, beacon-node,
signing or broadcast failure to failed — so a PTC duty still unmarked
at the deadline can only be a convergence miss. The classification keys
off the immutable runner role, so it stays race-free against the
watcher goroutine. On the §6 side, add ssv.runner.envelope.build_match
so reconstruction misses are countable: per operator an "other" share
is expected, and the signal is cluster-wide — a decided envelope no
operator matched means the builder's bytes were lost.

V11 — Web3Signer fork_info on Gloas. No code change needed, but the
reasoning is now recorded on GetForkInfo so it need not be re-derived:
Web3Signer takes fork.current_version whenever epoch >= fork.epoch and
hashes it into a ForkData root without consulting any milestone enum,
so an unrecognized Gloas version still yields the correct domain. Its
AttestationData schema also has no post-Electra index == 0 check, so
the §2 payload-status index survives into the signing root. A
live-instance confirm remains.

Also unify the package's two 404-detection idioms behind one isNotFound
helper covering both the typed go-eth2-client error and the
httpStatusError the hand-rolled Gloas endpoints return.
A go.mod replace of ssv-spec with a version-less target is a local directory
by go.mod semantics, not a module in the cache; GetSpecDir fed it to the
module-cache resolver and failed on a malformed module path. Use the directory
directly (resolved against the module root when relative), so the spec-test
suites keep running while ssv-spec is replaced with a local checkout during
spec development.
…elopeProposer rename

Both go.mods move to the ssv-spec#632 branch tip, which adds DomainRequestAuth
(builder-specs' DOMAIN_REQUEST_AUTH, 0x0b000001) and RequestAuthPartialSig(9)
for issue #2962, and renames the §6 role EnvelopeBuilder to EnvelopeProposer
to match SIP-94's wire naming. The rename fallout is mechanical — identifiers,
the message-validation test file name, role-referring prose — and envelope.go's
error-path domain sentinel joins the prevailing spectypes.DomainError
convention while touched.
…#2962)

RequestAuthV1{data ByteList[4096], slot} / SignedRequestAuthV1 per
builder-specs — SSZ via sszgen plus the builder-API JSON form — with hash tree
roots pinned against an independent merkleization of the builder-specs wire
example. BuilderEntry adopts keymanager-APIs#87's field vocabulary for the
cluster's direct-builder list: entry cap, at most one default (empty-URL)
entry, (URL, AuthData) identity, and auth data defaulting to the UTF-8 bytes
of the URL exactly as configured. The shared distinct-root budgets both the
§5 dispatcher stash and message validation size from live here too, including
SIP-94 §7's normative cap of 4 preference roots.
goclient computes it genesis-style alongside DomainApplicationBuilder — the
application-namespace domains never derive from a fork-versioned state; the
local key manager signs it as a plain SSZ root with the other Gloas domains
(nothing of it is in the slashing predicate); the remote key manager reports
it unsupported like the rest of the Gloas set — Web3Signer has no request-auth
type, so remote-signing operators sit out the direct-builder overlay (bounded:
the cluster reconstructs while at most f operators are remote-signing).
…cher

For each authenticatable configured builder, the §5 slot sub-runner freezes
RequestAuthV1{data, proposal_slot} ahead of the preference logic, broadcasts
one single-root RequestAuthPartialSig per builder — once per root across
re-emissions, since auth roots don't move with dependent_root — collects
partials in a dedicated container with no succeeded-gate (auth collection
legitimately outlives the §5 preference submit), and reconstructs each quorum
into a per-validator RequestAuthCache shared with the proposer runner for the
produceBlockV4 POST attach (beacon-APIs#625, upstream-gated). Sub-quorum
degrades silently to the enshrined flow — gossiped bids or self-build — and
never blocks the proposal; reconstructions are counted, and the §4
build-source telemetry becomes a typed enum staged for the phase-2 reason
split (no bid / economics / auth unavailable).

Message validation admits the new type under RoleProposerPreferences with its
own distinct-root budget and the §5 two-tier REJECT/IGNORE semantics; every
other §5 wire rule (earliness, lateness, slot-advance exemption, assignment
check, duty limit, fork gates) is role-scoped and applies unchanged.

Config: the Builders YAML list (validated at startup) plumbs through
ControllerOptions and CommonOptions — now built from a struct literal instead
of the 17-argument constructor — to the runner options; documented in
EXTERNAL_BUILDERS.md with the all-operators-identical requirement. Riding
along: the remaining error-path domain sentinels join spectypes.DomainError.
…ken builders, budget = entry cap

RequestAuthCache eviction is now measured against the current slot instead of
the slot being written: §5 writes land up to a proposer lookahead ahead, so
retention anchored to the (arbitrarily future) write slot could evict a
sibling lookahead slot that was still ahead. The cache also drops its
redundant per-validator key — it is one instance per validator, like
ProposedBlockRoots.

A frozen request auth now carries every configured builder relationship
behind its signing root: the root derives from (data, slot) alone, so
distinct (URL, AuthData) entries sharing one pre-agreed token converge on one
root and one reconstruction — previously last-write-wins left all but one of
them silently unauthenticated. The single broadcast per root is unchanged.

The request-auth distinct-root budget drops from entry cap + 4 to the entry
cap exactly: the builder list is startup-only and auth roots are
dependent_root-independent, so no in-window churn exists for headroom to
cover — and wire validation being config-independent, every extra admitted
root was pure surface for clusters that never opt in.

Also: a runner-layer validator-index guard on auth partials (the auth root,
unlike the §5 preference, doesn't bind the index); nodes with Builders
configured and a remote signer now warn once at startup and disable the
overlay locally instead of warning per builder per emission forever;
GetSpecDir walks the module tree once; and a conciseness pass over the new
surface's comments, deduplicating rationale to single homes.
…ers; contract and metric wording

A node with no local builder entries (never configured, or disabled for a
remote signer) never freezes auth roots, so peer request-auth partials for a
started duty now fail hard instead of exhausting the retry budget — retrying
could never help there; the duty-start race keeps its retryable path.
Regression-tested.

Wording follow-ups: the cache documents its shallow-copy contract (returned
auths are shared across the cache, the runner's frozen state, and
token-sharing identities — treat as immutable) and no longer implies a
present-day reader; the reconstruction metric states its root granularity
(token-sharing builders count once, unit {root}); the budget comment owns the
restart-with-a-changed-list case as an accepted, self-healing cost; and the
loop-invariant cache nil-check is hoisted.
…s; hard-fail on frozen-nothing

The dispatcher's stash replay now gates on duty-ASSIGNED rather than running:
an unchanged-preference re-emission concludes not-required immediately
(State.Succeeded), which used to skip the replay and leave the replacement's
fresh request-auth container permanently short of quorum — peers broadcast
their partials exactly once. Preference partials replayed into a concluded
duty bounce off the succeeded-gate harmlessly; the auth rounds have no such
gate by design. Regression-tested (the new test fails on the old gate).

A domain-fetch failure now freezes an empty root set, so peer auth partials
hard-fail as unknown roots instead of burning queue retries — the dispatcher
stash keeps them for replay should a re-emission freeze successfully. Also: a
real AuthDataBytes error is logged instead of silently treated as the default
entry; the URL-length check applies only when the URL's bytes are the auth
data; the in-memory broadcast markers' restart re-broadcast cost is TODO'd
for devnet gauging; message validation's per-type seen-root sets collapse
into one seenRootSet type with a shared budget check; and the request-auth
round moves to its own file beside the §5 dispatcher.
The public-devnet SSV-cluster target is fully retired (#2920 closed in favor
of the split successors): e2e rides the Aetheria local_testnet_gloas net and
the Sepolia/Hoodi Gloas forks, so this placeholder config — zero registry
address, bootnodes TBD — would never be filled in. The zero-registry-address
guard it motivated in SSVConfigByName stays; it protects any future
placeholder config the same way.
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.

3 participants