Skip to content

Add Gloas self-build proposal support - #420

Open
AntiD2ta wants to merge 30 commits into
gloasfrom
gloas-epbs-proposer
Open

Add Gloas self-build proposal support#420
AntiD2ta wants to merge 30 commits into
gloasfrom
gloas-epbs-proposer

Conversation

@AntiD2ta

@AntiD2ta AntiD2ta commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add the Gloas-only self-build proposal path: request an ePBS proposal with its payload, validate it, sign the beacon block and matching execution-payload envelope, then publish the block followed by its envelope.
  • Keep the pre-Gloas relay auction and blinded/unblinding flow unchanged.
  • Extend the first and best proposal strategies, signing/submission layers, cache handling, and telemetry for ePBS data.
  • Add an opt-in custom beacon-node preset setting for networks that need dynamic SSZ support.

Reviewer guide

  1. Start in services/beaconblockproposer/standard/propose.go. The fork gate chooses the self-build path. Its key invariants are an included payload, the expected proposer and slot, a non-zero fee recipient, a correct block root, and completion of both signatures before anything is submitted.
  2. Follow services/signer and services/submitter. The block is still submitted before its envelope, but the envelope is fully prepared first; multinode submission lets all configured nodes receive it within the existing timeout.
  3. Review strategies/beaconblockproposal/{first,best}. They request ePBS proposals, reject malformed or payload-excluded responses when payload inclusion was requested, and preserve provider-local graffiti handling.
  4. Then inspect clients.go, docs/configuration.md, and the go-eth2-client bump for custom-preset decoding and preset-aware body-root handling.
  5. Finally, review cache and telemetry changes; Gloas signed blocks do not update execution-chain state because an ePBS bid has no execution block number.

Fork schedule lookup and performance

The proposal fork gate still uses chainTime.HardForkEpoch(...), but chain time now publishes an immutable snapshot of every *_FORK_EPOCH value returned by the beacon node's spec. Known forks use one atomic snapshot read and a map lookup: no mutex, allocation, or beacon-node request is added to Prepare() or proposeBlock().

An unknown fork name triggers a generation-coalesced refresh, allowing future hard forks to be discovered without another fork-specific cache field. Refreshes preserve last-known-good entries, reject changes to already activated forks, and publish a new snapshot only when the effective schedule changes.

Suggested review order:

  1. services/chaintime/standard/service.go for snapshot construction, lookup, and refresh behavior.
  2. service_test.go for public behavior and failure handling.
  3. service_internal_test.go for immutable publication and concurrent refresh guarantees.
  4. service_benchmark_test.go for the lookup benchmark.

Benchmark command:

gosilent test --verbose -run '^$' -bench '^BenchmarkHardForkEpoch$' -benchmem -count=10 ./services/chaintime/standard

Apple M4 median across 10 samples:

HardForkEpoch Previous implementation Cached snapshot
Time 12.31 ns/op 5.24 ns/op
Memory 8 B/op 0 B/op
Allocations 1 alloc/op 0 allocs/op

That is approximately 2.35x faster, or 57% lower lookup latency, while eliminating the allocation. The benchmark uses an in-memory provider to isolate lookup overhead; in production, known-fork lookups also avoid the proposal-time beacon-node request made by the previous implementation.

Risk and validation

  • The Gloas path deliberately avoids relay building and uses a zero builder boost factor; operators are warned when Gloas ignores their relay settings.
  • Failure validation happens before signing or submission where possible. Missing required signing/submission configuration fails at startup.
  • The focused regression tests cover fork selection, proposal validation, signing/submission order, strategy behavior, custom-preset decoding, cache behavior, and telemetry.
  • Current GitHub test, lint, and Trivy checks pass. DeepSource remains failing and should be reviewed separately.

Kurtosis devnet

A local glam-d7 devnet remained healthy and Vouch received real Gloas proposer duties. During the initial testing iterations, some beacon-node responses lacked an ePBS proposal; others contained an envelope whose beacon-block root did not match the block. Vouch rejected those responses before signing or submitting, exercising the live failure-safe path. Most of the issues were related to differences between mainnet and minimal preset, and Vouch and its dependencies being tightly coupled to mainnet preset. The devnet was running on minimal preset.

A successful end-to-end ePBS publication was later demonstrated after several fixes among Vouch and go-eth2-client.

Update on 14/09/26: Devnet 8 has been launched and this PR has not been tested end-to-end with a local devnet based on Devnet 8. Any new local devnet/enclaves will match as much as possible Devnet 8 settings, including a mainnet preset.

@AntiD2ta
AntiD2ta marked this pull request as draft August 11, 2026 17:46
proposeEPBSBlock submitted the signed beacon block and only then signed
the execution payload envelope and fetched its KZG proofs and blobs. Any
failure in that later work left an on-chain block committing to a payload
that is never revealed, costing the execution rewards and exposing the
proposer to the payload-withheld penalty.

The failure is reachable in practice: the signer treats DOMAIN_BEACON_BUILDER
as optional, so a beacon node whose spec omits it yields a signer that
cannot sign, and a dropped dirk connection between the two signatures does
the same.

Prepare the envelope in full before submitting the block so the path fails
closed. Ordering only; no behaviour or error strings otherwise changed.
SubmitExecutionPayloadEnvelope handed the same cancellable context to
every per-node goroutine and cancelled it as soon as the first node
acknowledged, aborting the in-flight submissions to all the others. If
the one node that acknowledged then failed to gossip the envelope the
payload was never revealed, costing the execution rewards and exposing
the proposer to the payload-withheld penalty. That is the single point of
failure the multinode submitter exists to remove, and the sibling
SubmitProposal avoids it by never cancelling early.

Release the context once every submission has finished rather than on
first success, so submissions remain bounded by the timeout but are no
longer aborted by a peer.

Releasing the context that way lets both arms of the select become ready
at once, and select then picks between them at random, so a successful
submission could report "no successful submissions before timeout".
Record success in a flag and consult that instead of the chosen arm.
The best strategy passed whatever Gloas proposal a beacon node returned
straight through to scoring, without inspecting the execution payload
bid that the block commits to. A node building against an unset fee
recipient therefore yielded a proposal that vouch would sign and
publish, directing that block's execution layer rewards to the zero
address.

Report such a proposal as a provider error so the strategy discards it
and settles on another node's proposal instead.
providerGraffiti aliases the backing array of opts.Graffiti. When the
{{CLIENT}} substitution succeeds bytes.ReplaceAll returns a fresh slice
and the alias is broken, but when NodeClient fails the alias survives
and the following "opts.Graffiti = [32]byte{}" zeroes the very array
being copied from. The proposal was then requested with empty graffiti
rather than the operator's configured value.

Copy into a local array and assign that afterwards, so the source is
never clobbered part way through.
EPBSProposal returned the first proposal to arrive even when it omitted
the execution payload that opts.IncludePayload asked for. The proposer
forces local building precisely so that it holds the payload it must
later reveal, so accepting a proposal without one defeats the request.

Keep waiting for a conforming proposal rather than returning the first
arrival, falling back to the existing timeout if none appears.
The Gloas path always builds locally, so a configured block auctioneer
and a non-default builder boost factor have no effect once the fork is
active. Neither was reported, leaving an operator to believe their
relay configuration still applied.

Log a warning for each on the ePBS path.
confirmEPBSProposalData checked the slot of the returned proposal but
not its proposer index, so a proposal built for a different validator
would be signed against this duty's account.

Compare the proposal's proposer index against the duty's validator
index and reject a mismatch, as the slot check already does.
The service read GLOAS_FORK_EPOCH once at construction. A beacon node
that has not yet scheduled the fork reports it as far future, and vouch
retained that value for the lifetime of the process, so a client started
before the fork was scheduled would never take the ePBS path however
long it ran.

Read the epoch from chainTime at each proposal instead, and drop the
cached field.
Several defects on the Gloas path shared a root cause: values returned
by a beacon node were used without first checking their shape.

- Sign the block and the execution payload envelope concurrently and
  wait for both before submitting either, so the path keeps failing
  closed while no longer paying for the two signatures in sequence.
- Guard the nil cases in both proposal strategies: an absent response,
  absent GloasContents when the payload is reported as included, and an
  absent block, body or execution payload bid. In the best strategy
  these were reachable dereferences that would panic the goroutine
  handling that node's response.
- Apply the zero fee recipient check to the first strategy, which until
  now only the best strategy performed.
- Require the execution payload envelope signer and submitter in the
  proposer and in both submitter implementations, so a misconfiguration
  is caught at startup rather than at the first Gloas proposal.

Adds a table driven regression test covering each arm of the ePBS
proposal path.
The attgo_current_year linter flags every file this branch modifies whose
copyright header predates the current year.  Bring the seventeen outstanding
headers up to date as a single sweep rather than folding them into the
behavioural commits, so those diffs stay reviewable.

No functional change.
go-eth2-client's generated SSZ HashTreeRoot methods inline mainnet preset
sizes as Go literals, so on a custom preset they compute the wrong body
root even though decode succeeds. The dependency now retains the body
root the transport's own preset-aware codec computed
(BeaconBlockBodyRoot) and errors rather than falling back to the
generated root when it is unset.
proposeEPBSBlock hand-composed a phase0.BeaconBlockHeader from the
GloasContents.Block fields plus the signing body root to check against
the execution payload envelope's BeaconBlockRoot. That duplicated the
header composition go-eth2-client's own VersionedEPBSProposal.Root()
now performs, so use it instead. Root() derives the block root from the
same body root that is signed here, and is the derivation the beacon
node client itself used to check the envelope, so this guard and the
signature cannot disagree about which body root is correct. The block's
own root has no preset-dependent field of its own (it merkleizes slot,
proposer index, parent root, state root and the body root, exactly like
phase0.BeaconBlockHeader), so the retained body root remains the only
preset-sensitive quantity.

Add regression coverage with fixtures where the retained body root
deliberately differs from the mainnet-shaped generated root, proving
the retained value -- not the generated one -- reaches the block
signer, and that a proposal missing its retained root fails the duty
without signing or submitting anything.
Recognize Gloas signed blocks without promoting their execution payload bids into execution state. A bid has no execution block number, so the height-indexed gas-limit cache and execution-chain head remain unchanged.

Changes:
- services/cache/standard/events.go
- services/cache/standard/events_internal_test.go

Validation: full tests, build, changed-lines custom lint, gopls checks, and diff check passed. The full custom lint reports 50 pre-existing findings outside this change.
Static analysis blocks the branch on nine issues this work introduced:
seven functions over the cyclomatic complexity threshold, and two test
stubs whose receivers go unused. Split each flagged function along a
seam it already had, and drop the two receiver names.

- best and first ePBS strategies: lift each strategy's proposal
  validation out of its response handler, as validateEPBSProposal
  returning an error for the error channel and acceptableEPBSProposal
  returning a bool and logging the discard. In best, share the selection
  logic the soft and hard timeout arms had duplicated; in first, move the
  per-provider goroutine body into fetchEPBSProposal. The two validators
  stay separate, mirroring the duplication these two packages already
  carry rather than inventing a package to hold one shared check.
- proposeBlock and proposeEPBSBlock: extract unblindingProviders and
  epbsProposalEnvelope, the latter carrying over the note on why the
  proposal's own Root() is used rather than hashing the block.
- signer New: fold the five optional domain lookups into
  optionalDomainType. DOMAIN_BEACON_BUILDER keeps its explicit branch,
  since it warns that execution payload envelope signing is unavailable.
- updateFromBlock: fold Gloas into the existing no-op case list rather
  than give it an arm of its own, restoring the complexity the function
  had before this branch. Unifying its six per-fork arms would mean
  reconciling how Bellatrix through Deneb gate the gas limit and chain
  head updates against how Electra and Fulu do, which is a behaviour
  question deserving its own commit.

No functional change. The existing ePBS tests are unchanged and are the
regression net for every extraction.
Add missing immediate envelope submission and per-provider ePBS spans. Classify payload-included self-builds as local and payload-excluded builder responses as builder while retaining the self-build rejection.
Keep response-derived source telemetry and safety coverage while splitting metric assertions from the general proposer test to satisfy the DeepSource complexity threshold.
Build the runtime binary with the fixed Go 1.26.6 Bookworm image to resolve the Trivy-reported standard library vulnerability.
Build an immutable schedule from all *_FORK_EPOCH spec values and serve known lookups from an atomic snapshot. Coalesce refreshes for newly advertised forks, preserve last-known-good values, reject activated-fork rewrites, and publish only effective changes.

BenchmarkHardForkEpoch (Apple M4, 10 samples):
- before: 12.31 ns/op, 8 B/op, 1 alloc/op
- after: 5.24 ns/op, 0 B/op, 0 allocs/op
- result: 2.35x faster, 57% lower latency

Validation:
- gosilent test ./... (919 tests)
- gosilent test -race ./services/chaintime/standard
- go build ./...
- gopls check on all four changed files
- git diff --check

The custom lint still reports 50 pre-existing findings outside this change; none are in the changed files.
Separate immutable schedule reconciliation from refresh coordination and document the non-obvious generation and activation invariants.

Reduce refreshForkSchedule cyclomatic complexity from 19 to 10 while preserving the allocation-free hard-fork lookup path.
Start a gloas section capturing the self-build ePBS proposal path, the
strategy and startup changes it brings, the custom-spec-support setting,
and the supporting chaintime, cache, metrics, and dependency updates.

This section accumulates gloas entries until Glamsterdam support is
complete, at which point it is folded into a proper release section.
@AntiD2ta
AntiD2ta marked this pull request as ready for review August 14, 2026 16:21
@AntiD2ta AntiD2ta self-assigned this Aug 14, 2026
@AntiD2ta
AntiD2ta requested a review from Bez625 August 14, 2026 16:22
A self-built bid pays nothing. The spec requires bid.value to be zero for
BUILDER_INDEX_SELF_BUILD and records no builder payment for it, so the bid fee
recipient that the proposal strategies guard is not the one collecting the
slot's revenue. That is the execution payload's own fee recipient, carried in
the envelope, and nothing checked it: a proposal paying the zero address was
signed and published, forfeiting the slot's priority fees and MEV.

Reject it in epbsProposalEnvelope, which runs before either signature is
requested. The strategies keep their bid check, which becomes load-bearing once
proposals built on a staked builder's bid are supported.

Also correct the IncludePayload comment. A Gloas block never carries an
execution payload, it commits to a bid, so the option selects whether the
payload envelope travels back with the block or stays cached on the producing
node rather than whether the block includes a payload.
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.

1 participant