Add Gloas self-build proposal support - #420
Open
AntiD2ta wants to merge 30 commits into
Open
Conversation
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.
AntiD2ta
force-pushed
the
gloas-epbs-proposer
branch
from
August 13, 2026 06:23
04ffb34 to
cc08b1a
Compare
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Reviewer guide
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.services/signerandservices/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.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.clients.go,docs/configuration.md, and the go-eth2-client bump for custom-preset decoding and preset-aware body-root handling.Fork schedule lookup and performance
The proposal fork gate still uses
chainTime.HardForkEpoch(...), but chain time now publishes an immutable snapshot of every*_FORK_EPOCHvalue 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 toPrepare()orproposeBlock().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:
services/chaintime/standard/service.gofor snapshot construction, lookup, and refresh behavior.service_test.gofor public behavior and failure handling.service_internal_test.gofor immutable publication and concurrent refresh guarantees.service_benchmark_test.gofor the lookup benchmark.Benchmark command:
gosilent test --verbose -run '^$' -bench '^BenchmarkHardForkEpoch$' -benchmem -count=10 ./services/chaintime/standardApple M4 median across 10 samples:
HardForkEpochThat 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
Kurtosis devnet
A local
glam-d7devnet 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.