Skip to content

merge upstream 0.30.0 - #62

Open
pk910 wants to merge 6 commits into
masterfrom
pk910/upstream-0.30
Open

pk910 wants to merge 6 commits into
masterfrom
pk910/upstream-0.30

Conversation

@pk910

@pk910 pk910 commented Oct 7, 2026

Copy link
Copy Markdown
Member

No description provided.

Bez625 and others added 3 commits October 5, 2026 13:27
* feat: add Gloas type foundation

* fix: annotate Gloas SSZ bounds

* fix: align Gloas SSZ tags

* feat: add Gloas versioned API surface

* fix: support Gloas signed proposal block hashes

* fix: correct Gloas API generation and decoding

* test: cover Gloas versioned wrappers

Exercise populated Gloas arms and nil guards across execution payload bids, envelopes, and execution requests.

Pin the no-blinded Gloas proposal invariant in wrapper coverage and bind API SSZ generation cleanup to its sole generated output.

Files: versioned wrapper tests, Gloas API generator directive, bid formatting.
Notes: no blockers.

* feat: add ePBS block production and envelope transport

* fix: keep BlockContents generation with transport

* fix: keep ePBS transport self-contained

* fix: validate ePBS response integrity

* fix: satisfy ePBS transport checks

* fix: bound HTTP POST responses

* test: move ePBS submission coverage to harness

* feat: expose ePBS proposal proposer index

Use the established proposal block selector to retain arm and version validation.\n\nAdd both-arm and error-path coverage; remove an obsolete lint suppression.\n\nFiles: api/versionedepbsproposal.go, api/versionedepbsproposal_test.go, http/http.go.\nBlockers: none.

* test: cover custom-spec Gloas block decoding

* fix: compute epbs proposal roots with the request's dynssz codec

Generated HashTreeRoot() methods on the gloas SSZ types are unrolled hasher
walks with mainnet preset sizes inlined as Go literals (a 512-bit sync
committee bitvector, for example). They never consult a *dynssz.DynSsz
instance, unlike MarshalSSZ/UnmarshalSSZ, which delegate to the request's
codec. On a custom preset this makes every generated hash tree root wrong
while decode stays correct.

A beacon block's own hasher walk merkleizes slot, proposer index, parent
root, state root and the body's root, exactly like phase0.BeaconBlockHeader,
which has no preset-dependent field. The body root is therefore the only
preset-sensitive quantity in a block; the block root is a preset-independent
function of it.

Retain the body root computed by the codec that decoded the proposal on
VersionedEPBSProposal.BeaconBlockBodyRoot, have BodyRoot() return it with no
fallback to the generated computation, and derive Root() by composing a
BeaconBlockHeader instead of calling the block's own HashTreeRoot(). Wire
epbsProposalFromResponse to compute and retain that root for both the SSZ and
JSON decode paths, and switch the included-envelope guard to compare against
Root() so it shares the same computation as everything else that needs the
block root. Update the mock to populate the field from the correct mainnet
root it already serves.

Before this fix, a minimal-preset Gloas proposal with its execution payload
included was rejected as inconsistent, and BodyRoot() (the value a proposer
signs over) was silently wrong on any non-mainnet preset.

* fix: SignedExecutionPayloadEnvelope silently accepted an empty response plus tidy ups

* feat: add PTC and attestation compatibility

* fix: keep PTC provider support with PTC APIs

* fix: harden PTC and attestation endpoints

* fix: resolve PTC lint issues

* fix: reject nil attestations in pool responses

A JSON null element in an attestation pool response decodes to a nil fork
pointer inside a non-nil VersionedAttestation, so encoding/json accepts it
without error and the version verifiers dereferenced it. A response body of
{"version":"gloas","data":[null]} therefore panicked the client with a nil
pointer dereference instead of returning an error.

Guard the element and its data in every version arm, matching the check
payloadAttestationPoolFromResponse already performs, and cover all three arms
with a decode test.

Narrow verifyPostElectraAttestation's committee bits parameter from an
anonymous interface to bitfield.Bitvector64: electra.Attestation and
gloas.Attestation both declare that concrete type, so the interface only
added an allocation per pool element and bought no polymorphism.

* fix: send payload attestation data slot as a query parameter

beacon-APIs #626 (merged 2026-07-09, after the last tagged release) moved the
slot of producePayloadAttestationData out of the path and into a required
query parameter, aligning the endpoint with produceAttestationData. Prysm has
adopted it and registers the route as /eth/v1/validator/payload_attestation_data
with no {slot} template, so a PTC validator using the path form gets a 404 on
every slot and can never obtain a datum to sign.

Lighthouse has not adopted the change yet — v8.2.1 and unstable both still read
the slot from the path segment, and reply 400 "Invalid slot" to the query form.
This follows the specification rather than the lagging implementation; against a
Lighthouse node the endpoint stays unusable either way until it catches up.

The path test asserted the old endpoint and an empty query, so it defended the
stale form; it now pins the query parameter instead.

* fix: accept prysm's ptc_window shape when decoding a Gloas state

Prysm serves BeaconState.ptc_window as an array of objects carrying
validator_indices rather than the spec's nested arrays:

  "ptc_window": [{"validator_indices": ["14", "127", "60", ...]}, ...]

consensus-specs types the field Vector[Vector[ValidatorIndex, PTC_SIZE], (2 +
MIN_SEED_LOOKAHEAD) * SLOTS_PER_EPOCH], whose JSON mapping is nested arrays of decimal
strings, so our type is correct and prysm's output is the deviation: the object wrapper
is a protobuf artifact, because proto3 cannot express a nested repeated field without an
intermediate message. The consequence was that a Gloas state fetched from prysm was
rejected outright with "ptc_window: json: cannot unmarshal object into Go value of type
[]string", leaving BeaconState undecodable in JSON against the only client that serves
the ePBS routes. SSZ carries no such wrapper and was unaffected, which is why this stayed
hidden until a JSON decode was forced.

The spec shape is attempted first and remains the only shape marshalled, so the deviation
is absorbed where it arrives and never propagated; TestBeaconStateJSONWireShapes already
pins what we emit. This follows http.unmarshalVersionedEventData, which accepts prysm's
unwrapped SSE payloads by the same spec-first-then-fallback structure. When neither shape
decodes, the spec shape's error is the one reported, so the familiar diagnostic is
unchanged, and the wrapper path still parses and range-checks every index.

Verified against a live Gloas devnet: the captured 5.9MB prysm state now decodes in full
(96x512 window, first indices [14 127 60 86]), and TestBeaconState/Head_(json) passes
where it previously failed. Compared against unmodified HEAD in a detached worktree, that
is the only change to the http suite's failure set.

* refactor: streamline Gloas attestation decoding logic

* fix: reject nil aggregate and proofs for every fork

Only the Gloas arm of createUnversionedAggregates checked its versioned
field for nil.  Every other arm appended the field unconditionally, so a
VersionedSignedAggregateAndProof carrying the right version but no payload
produced a typed nil in the unversioned slice.  That marshals to JSON null
and is submitted to the beacon node, which rejects the request with an
opaque error far from the actual mistake.

Guard each arm with the wording the Gloas arm already established, and
expand the unit test into a table covering every fork: the positive arm
forwards the field matching its version, and a nil payload is rejected.
Each fork uses a distinct aggregator index, so forwarding the wrong field
fails with a legible diff -- five forks share the phase0 container and two
share the electra one, so a mis-wired arm compiles cleanly and is otherwise
invisible.

Also covers the nil element, mixed version and unknown version branches.

* feat: add MultiForkProposalProvider

ProposalProvider is frozen at the last pre-gloas fork and
EPBSProposalProvider is gloas-onwards only, so a caller that produces
blocks across the fork boundary needs both. Until now that requirement
was stated only in EPBSProposalProvider's doc comment, leaving every
consumer to compose the pair for itself.

Name the composition instead, so the requirement is a type rather than a
convention. It introduces no endpoint of its own: implementations need no
change, and the existing per-endpoint parity assertions already cover it.

"MultiFork" counts forks spanned, not nodes; it is unrelated to the multi
package.

* Add service interface conformance assertions

* Silence staticcheck for deprecated GenesisTimeProvider assertions

* chore: bump golangci-lint pin to v2.13.1

A custom-gcl built from the v2.8.0 pin cannot read the export data of a
newer Go toolchain.  On Go 1.27 it fails with

  export data version 4 is greater than maximum supported version 2

then reports a few (typecheck) import errors and stops.  Every linter that
needs type information analyses nothing.  The run does not look broken, it
looks quiet: 2 issues where a working gate finds 10.

CI is unaffected today because golangci-lint.yml still pins Go 1.25.  The
reason to fix it now is update-go-version.yml, which rewrites go-version in
every workflow file when a new Go ships and touches neither golangci-lint
pin.  The first time that bot moves the repo to 1.27, the gate goes quiet in
CI the same way, and nothing in the output says so.

The version is pinned twice and both pins have to move together: the
version key that golangci-lint custom reads, and the go install line that
installs the builder.

.golangci.yml gains two disable entries.  v2.13.1 ships exhaustruct_v5
beside exhaustruct, so default: all turns it on and the existing
- exhaustruct line stops meaning what it says; 50 findings appear across
api/ without it, and the config already handles this case for wsl and
wsl_v5.  v2.13.1 also deprecates gomodguard in favour of gomodguard_v2,
which default: all already enables, so disabling the old one silences the
warning and leaves the same 10 findings.

* test: remove the direct handleEvent unit test

http/events_internal_test.go drove Service.handleEvent directly with synthetic
sse.Event values.  This is a pure deletion: 200 lines out, none in.

It leaves handleEvent with no test that calls it, including the Gloas dispatch
arms the next commit adds.  Nothing in this repo replaces that coverage, and
neither does the stacked PR #313.

* feat: add Gloas event forwarding

* fix: decode the Gloas event topics

SupportedEventTopics gained the seven Gloas topics but the topic switch in
Event.UnmarshalJSON did not, so Events() accepted a subscription to a topic that
could not then be round-tripped through JSON.  A caller using the generic
handler, marshalling an apiv1.Event onto a queue and unmarshalling it elsewhere,
got "unsupported event topic fast_confirmation".

execution_payload and execution_payload_gossip share a single arm because they
share a payload shape, as ExecutionPayloadEvent's own doc comment records.

The test asserts the invariant rather than listing the seven topics by name, so
a later fork cannot add to one list and not the other without failing the build.

* docs: stop citing tests that no longer exist

TestEventsGloasTopics' comment argued that it, rather than any test calling
handleEvent directly, covers the SupportedEventTopics gate.  It named
TestEventHandler and TestEventHandlerGloasRouting as the tests it was
distinguishing itself from.  Neither exists: the first went with
http/events_internal_test.go and the second never landed.

The argument stands without them, so make it about any test that calls
handleEvent directly.

* test: run node-independent HTTP tests without an endpoint

Always invoke m.Run, but initialize the shared live-node service only when HTTP_ADDRESS is configured. Endpoint-dependent tests now skip visibly through testService, while constructor unit fixtures use a fixed local address.

Files changed: http/main_test.go and http/service_test.go.

Validation: the Go 1.25.2 full suite and build pass; the full PR diff passes the custom linter. Full-tree lint still reports two pre-existing copyright findings in the gloas base.

* test: cover HTTP event dispatch

Add node-independent coverage for every event dispatch arm, including Gloas wire forms, handler precedence, malformed data, and the versioned event decoder fallback.

Files: http/events_internal_test.go.

Validation: focused and package HTTP tests pass. The repository suite retains documented JSON error-text drift outside this change.

* test: skip empty blob heads

Skip live blob endpoint assertions when the head block has no committed blobs, while preserving endpoint checks for heads that do.

Files: http/blobs_test.go, http/blobsidecars_test.go.

Validation: node-independent HTTP package tests, build, and vet pass. CI is the authenticated Hoodi reproduction path.

* fix: distinguish gossip payload events from imported payloads

Use a separate gossip event type without an optimistic flag, require builder identity and hash on both payload events, and expose the typed self-build sentinel. Cover the upstream topic examples and malformed inputs.

Next iteration: the Gloas vector suite still fails on existing YAML round-trip mismatches. Commit proceeds with the approved vector and unchanged-file lint exceptions; leave those criteria open until the follow-up resolves them.

* test: round-trip a Gloas event through Event JSON

* fix: keep an absent fast_confirmation current_slot absent

current_slot is optional on input, for nodes that predate it, but
MarshalJSON always wrote it, so an event without one re-marshalled with
a made-up current_slot of 0. That is older than the confirmed slot and
indistinguishable from a real value.

Hold CurrentSlot as a pointer that is nil when the node omits it, and
omit it again on output.

* fix: stop Events appending into the service's inactive client list

Events took a copy of the s.inactiveClients slice header under the
read lock, then appended every active client that failed to subscribe
to it after releasing the lock. activateClient and deactivateClient
leave that list with spare capacity, so the append wrote into the
service's backing array unlocked, racing with their own appends and
able to retry, or record as inactive, the wrong client.

Clone the list under the lock instead.

* fix: keep retrying a deferred events subscription

A client that was not synced when Events was called is subscribed later
from a goroutine. That goroutine gave up for good after a single failure
to fetch sync state or to subscribe, yet the client could still become
the primary one. Events are now forwarded only from the primary, so the
caller would then receive none at all, with nothing reporting it.

Retry both failures at the existing interval until the subscription's
context is done, logging them as warnings since they are no longer
final.

The test of deferred-client topic ownership swapped the global zerolog
logger for one writing to an unbuffered-after-one channel, which the
multi service kept after the test restored the global. It now checks
the options the deferred client is subscribed with instead.

* refactor: dispatch HTTP events through one per-topic table

Each topic had its own handleXxxEvent function, a 27-line copy
differing only in the payload type, the decoder, the specific handler
and the log text, and was listed again in both the handler check and
the dispatch switch. A fix to event dispatch needed an edit per topic.

Replace all three with a single table of topics, each entry built by a
generic newEventTopic from its decoder and a selector for its specific
handler. The handler check and the dispatch both read the table, so a
topic is added to the HTTP client in one place.

* fix: decode the bare execution payload events directly

unmarshalVersionedEventData's comment said the spec wraps its payloads
in {version,data}, with a bare object as a client fallback. The spec
examples for execution_payload and execution_payload_gossip are bare,
so for those two topics the fallback was the spec path, and every
spec-compliant event was parsed once into the wrapper and then again.

Decode those two topics with json.Unmarshal, and name in the comment
the three topics the spec does wrap.

* test: dispatch HTTP events decoded from real payloads

The dispatch test built its event data by marshalling the library's own
zero-valued types, so a type whose MarshalJSON and UnmarshalJSON drifted
together away from what nodes send still passed. It replaced verbatim
mainnet payloads for ten topics.

Restore those mainnet payloads, and take the rest from the beacon-APIs
eventstream examples, so each topic is decoded from data a node sent
or the spec shows. Doing so showed that the library cannot yet decode
master's Gloas examples for data_column_sidecar and payload_attributes,
nor v4.0.0's Electra payload_attributes, so those topics use the latest
example it can.

Also restore the file's 2020 copyright year, which had been replaced.

* refactor: derive the supported event topics from the event decoders

SupportedEventTopics and the topic switch in Event.UnmarshalJSON were
two separately maintained lists that had to agree, with a test to catch
them drifting apart.

Keep one map from topic to a constructor for its payload type, and
derive both SupportedEventTopics and UnmarshalJSON's decoding from it.
The drift test is now vacuous and is removed.

The remaining Gloas topics test said the http tests are skipped without
HTTP_ADDRESS, which is no longer so; say why it is kept instead.

* fix: reject invalid multi events subscriptions before retrying

* fix: clear a reused fast_confirmation event's current_slot

UnmarshalJSON set CurrentSlot only when current_slot was present, so
decoding an event without it into a value that had held one kept the
earlier event's slot, contrary to the field's documented nil for an
absent value.

Reset CurrentSlot before parsing it.

* fix: stop a zero events retry interval spinning the retry loop

The retry loop for deferred clients waits eventsRetryInterval between
attempts. A Service not built by New, such as the one in
TestEventsDoesNotWriteServiceClientLists, leaves it at zero, so a client
that keeps failing to subscribe was retried without pause until the
context ended, which in that test was never.

Fall back to the default interval when the configured one is not
positive, and give that test an interval and a context that ends with
it. The test's mocks get a context of their own, as ending theirs has
them race on mock's package logger.

* fix: skip multi events clients that do not provide events

Events asserted each client to consensusclient.EventsProvider without
checking, both for active clients and in the retry goroutine for
deferred ones. A configured client that did not provide events panicked,
in the goroutine's case taking the process down.

Check both assertions, logging and skipping a client that does not
provide events.

* refactor: describe each event topic once, in typed descriptors

Each topic was still listed three times: api/v1's eventTopicData, the
decode table in http/events.go and the handler substitution in
multi/events.go. The lists disagreed on attester_slashing, decoded as
*electra.AttesterSlashing from the stream but validated as
*phase0.AttesterSlashing by Event.UnmarshalJSON, and multi's topic
strings were unchecked.

Describe each topic once in api/v1 as an EventTopic[T], carrying its
name, the type T its data decodes into and whether the spec wraps it
in {version,data}. SupportedEventTopics and Event.UnmarshalJSON are
derived from them. api binds each topic to the EventsOpts field for its
handler, generically, so that a handler not taking the topic's T does
not compile, and exposes HasTopicHandler, HandleEvent and Filtered on
EventsOpts. http validates and dispatches through those, dropping its
table and the second, redundant topic check, and multi filters its
clients' options with Filtered in place of its per-field substitution.

TestEventTopicDecodeTypes pins each topic's name and decode type, and
TestEventsOptsBindsEveryTopicHandler checks that every EventsOpts
handler field is bound to exactly one topic.

* fix: reject versioned events of a fork other than their type's

Decode unwrapped the {version,data} payloads of execution_payload_bid,
payload_attestation_message and proposer_preferences without looking at
the version, so data of a later fork would decode into the Gloas types
without error, silently dropping any fields they lack.

Record each versioned topic's fork, and reject a wrapped payload whose
version is not it.

* fix: clear a reused head event's duty dependent roots

UnmarshalJSON set the current and previous duty dependent roots only
when present, the same as fast_confirmation's current_slot, so decoding
a head event without them into a value that had held them kept the
earlier event's roots, and MarshalJSON then wrote them back out.

Clear both roots before parsing them.

* refactor: keep the event topic descriptors internal

The typed descriptors made each topic's name and decode type the single
source for http and multi, but put that plumbing in the public API: 22
apiv1 EventTopic variables, and HasTopicHandler, HandleEvent and
Filtered on api.EventsOpts. Downstream code could call or reassign
them, and each would be a compatibility commitment.

Move it into two internal packages. eventtopic holds the generic Topic[T]
and a registry by decode type; api/v1 keeps its topics in an unexported
list, derives SupportedEventTopics and Event.UnmarshalJSON from it in
their initialisers, and registers it. eventdispatch binds each
EventsOpts handler field to the topic whose type the handler takes,
looked up in the registry, so the binding list names no topics, and
provides what the EventsOpts methods did as functions.

A binding with no topic of its type now fails as eventdispatch is
initialised rather than at compile time, and the registry rejects two
topics sharing a type, which would make the lookup ambiguous.

* fix: reject event data that is null

Decode passed a wrapped payload's data to json.Unmarshal whenever it was
non-empty, and a JSON null is non-empty but decodes into a zero value
without error. {"version":"gloas","data":null} for execution_payload_bid
reached its handler as a bid with a nil Message, so a handler reading
bid.Message.Slot panicked. A bare null did the same for any topic.

Reject null data, wrapped or bare.

* fix: report dropped events by what dropped them

handleEvent checked SupportedEventTopics and then had eventdispatch
look the topic up again among its bindings, so a topic that was
supported but unbound was logged as a parse failure, with its data,
pointing debugging at the payload. The move to eventdispatch had also
dropped the debug line recording an event that no handler took.

Have handleEvent call Handle alone and tell an unsupported topic, now
ErrUnsupportedTopic, from a parse failure by the error. Restore the
debug line for an event with neither a specific nor a generic handler.

* fix: read the events retry interval through one accessor

The fallback for a zero eventsRetryInterval sat at its one read in
Events, so a second reader, or SetEventsRetryInterval(s, 0) in a test,
could reach a zero interval and bring the busy loop back.

Read the interval through retryInterval, which applies the fallback.

* fix: fail multi events when no client can provide them

Events logged and skipped a client that did not provide events, but
still returned nil, so a caller whose clients could not subscribe was
told it had succeeded and then received nothing.

Sort the clients before starting any retry: an active client must
provide events, and a deferred one must also report its sync state to
be retried. Return an error when no client is subscribed or awaiting a
retry. The retry goroutine now takes a client known to provide both,
and no longer asserts either.

* revert: drop the check for null event data

Commit ebc8047 rejected null event data on the grounds that a null
decodes into a zero value without error, handing handlers, for example,
a bid with a nil Message. That is not so: every topic's data type has an
UnmarshalJSON that rejects null with a "... missing" error, so the check
never ran for a registered topic and only changed the error text.

Remove it, keeping the null test cases with the error the data type
itself returns.

* fix: log why multi events drops a client

An active client whose Events call failed was queued for retry without
its error being logged. If it then could not be retried, because it did
not report its sync state, it was logged as not being an events and node
syncing provider, which for a client that provides events is false, and
the caller got "no client can provide events" with the cause gone.

Log the failed subscription with its error, and log a client that cannot
be retried by what it lacks.

* fix: check that HTTP clients can be deferred multi events clients

The compile-time check that a type is a deferredEventsClient named
multi.Service, not the HTTP clients a multi service is given. Were
http.Service to stop providing events or sync state, the build would
pass and every client queued for retry would be dropped at run time.

Check *http.Service as well.

* fix: validate event topics against the dispatch table

ValidateEventsOpts accepted topics from apiv1.SupportedEventTopics, an
exported map callers can change, while events were dispatched through
eventdispatch's own table. A topic added to the map passed validation,
and every event on it was then dropped as an unhandled topic.

Validate against eventdispatch.Supports, the dispatch table itself, and
document SupportedEventTopics as informational.

* fix: look up event topics registered by pointer

Register takes any Descriptor, and *Topic implements Descriptor as well
as Topic, as the package's own interface check shows, but Lookup only
recognised a topic registered by value. One registered by pointer passed
Register and then made Lookup panic, claiming no topic decodes into a
type that had one.

Have Lookup accept either form. Also state that eventdispatch's lookups
rely on its importing api/v1, which registers the topics.

* test: describe the event topics gate as the supported-topic check

Comments in TestEventsGloasTopics and TestSupportedEventTopicsGloas
still called the gate the allow-list, or said SupportedEventTopics is
what the http client gates subscriptions on. Since validation moved to
the dispatch table, that suggests the exported map still gates
subscriptions. One also named checkEventSpecificHandler, which no longer
exists.

Describe the gate as the check of the topics the dispatcher supports.

* test: let the event topic registry tests run more than once

TestRegisterRejectsSharedType and TestLookupRegisteredByPointer register
into the global registry and never remove what they add, so a second run
in the same process, as with -count, panicked on the duplicate type.

Register each test's topic only if its type is not registered yet.

* test: check that every event topic's data type rejects null

With Decode's own null check dropped, the only guard against null event
data is each data type's UnmarshalJSON, and only one type was tested for
it. A topic added with a plain struct type would decode null into a zero
value without error.

Decode null into every registered topic's data type, requiring an error.
Also restore the padded null in Decode's test, simplified by mistake.

* fix: say which clients the multi events interface check covers

The comment on the check that *http.Service is a deferredEventsClient
said a multi service is given HTTP clients. WithClients accepts any
consensusclient.Service, so the check covers only the HTTP clients
WithAddresses creates; the rest are checked when Events is called.

* fix: log each client multi events does not subscribe once, accurately

A failed active subscription was logged without saying whether it would
be retried, and one that could not be retried was logged twice, once for
the failure and again as not being a node syncing provider. That second
message, "cannot retry events subscription", was also given to clients
on the inactive list that had never been tried.

Log a failed active subscription once: as a warning that it will be
retried, or as an error that it will not, as the client does not report
its sync state. Log an inactive client lacking sync state as not being
subscribed. The test now pins the line for each of the four cases,
including a client that provides no events.

* test: check that events validation ignores SupportedEventTopics

Nothing checked that ValidateEventsOpts no longer reads the exported
SupportedEventTopics map; were it to again, every test would still pass.

Add a topic to the map and remove one from it, and require validation to
refuse the first and accept the second.

* fix: wait an interval before retrying a client whose events subscription failed

An active client that failed to subscribe, and could be retried, was
queued with the inactive clients, whose retry goroutine tries at once.
So the client was asked to subscribe twice in a row and logged "Failed
to set up events handler; will retry" twice. fc46543 said each client
was logged once, which was not true of this case: the test's exemption
for it, added to fix a race, hid the second line rather than asking why
it appeared.

A client that has just failed now waits one retry interval before its
first retry. Clients that start on the inactive list are still tried
straight away.

Whether a client can be retried, and the message saying why or why not,
is now decided in one function for both active and inactive clients, so
the two lists' wording can no longer drift apart. Failed clients go on
Events' own retry list, so the inactive list no longer needs cloning.

The log test now requires exactly one line in every case, and again
covers Events returning "no client can provide events" when its only
client fails to subscribe and cannot be retried.

* docs: note that SupportedEventTopics no longer gates subscriptions

Since 505b917, Events validates topics against the dispatcher rather than
the exported map, so a caller that deleted a topic from the map to refuse
it no longer does. The map made a poor allow-list, being editable by any
caller in the process; a caller that does not want a topic leaves it out
of EventsOpts.Topics.

* test: correct event topic test comments and derive registerOnce's type

TestEventsGloasTopics is no longer the only coverage of the topic gate,
as TestValidateEventsOptsIgnoresSupportedEventTopics covers it without a
node. The SupportedEventTopics test said a topic missing from the map is
missing from the clients, which holds only of the list the map is built
from, not of the map once callers edit it.

registerOnce took a type argument that could disagree with the topic it
registered; it now takes the type from the topic. The padded-null Decode
case tested only that encoding/json skips whitespace, so a plain null
case is restored beside it, and the padded one named for what it tests.

* feat: make the multi events retry interval configurable

fe118a9 has a client that fails to subscribe wait one retry interval
before its first retry. While that client is still the active one, no
events are forwarded, so the interval is how long a failed primary goes
quiet. It was fixed at 5s; WithEventsRetryInterval now sets it, and New
refuses an interval that is not positive.

The option replaces the test-only SetEventsRetryInterval hook.

A new test confirms that an active client that fails to subscribe is
retried after the interval and subscribed, which nothing checked before:
a retry that never ran would have left every test passing.

* refactor: build each multi events handler once, and only for providers

Events built a handler for every active client before checking that it
provides events, and built a second one for a client that failed to
subscribe when queueing its retry. The handler is now built once the
client is known to provide events, and a failed client's retry reuses
it.

The retry loop's comment said it tries the inactive clients, though it
now also retries active clients that failed. The comment explaining why
Events must not append to the service's client lists, dropped in
fe118a9, is restored beside the snapshot.

* docs: complete the SupportedEventTopics notes

The CHANGELOG said only that removing a topic from SupportedEventTopics
no longer refuses it; adding one no longer lets it through either.

TestEventsGloasTopics's comment said the node-free validation test
covers the topic gate, but that test checks only that the gate ignores
the map, not that it accepts the Gloas topics. The SupportedEventTopics
test's failure message said Events() would reject a missing topic, which
the map no longer causes.

* refactor: clarify guarded event forwarding

* docs: record the Gloas events API and behaviour changes in the CHANGELOG

Event.UnmarshalJSON's attester_slashing type and multi Events()'s new
option checks and no-client error change behaviour for existing callers,
and the Gloas handlers and http.ValidateEventsOpts are new public API.
None of them was listed.

* docs: drop the TODO on SupportedEventTopics

godox fails the lint job on it.  Whether to remove the map is a public API
question for the PR, not a code comment.

* test: add Gloas HTTP harness and fork gates

Covers the Gloas HTTP endpoints, and adds the machinery that decides when the
live tests among them may run.

testclients/forkutil.go holds the three fork probes: whether the node's config
knows the Gloas fork, whether the chain head is past it, and a diagnostic name
for the head's fork. http/gloasgate_test.go turns those into skips, or into
failures when HTTP_REQUIRE_GLOAS is set, so a suite pointed at a Gloas node can
assert the gated tests actually ran.

The endpoint coverage is split by what it needs. Live tests against
HTTP_ADDRESS exercise EPBSProposal, ExecutionPayloadEnvelope and the Gloas
SubmitProposal path. Stub tests over httptest cover the request shape of
SubmitExecutionPayloadBid and SubmitExecutionPayloadEnvelope, and the
request-consistency guards, without needing a node on the fork.

* fix: decide the head fork from the fork endpoint

OnGloas read the head fork by fetching the whole head block and reading its
version. Every caller passes a service built without custom spec support, so on
a minimal-preset devnet the SSZ block is decoded against the mainnet-preset
codec and fails. The error was swallowed, HeadVersion returned "unknown", and
every Gloas live test skipped with "chain is not on gloas (head is unknown)".

Compare the head's fork version from Fork(state: "head") against
GLOAS_FORK_VERSION instead. Both are config values, so the answer no longer
depends on the compiled-in preset, and no block is downloaded. HeadVersion is
now a diagnostic name derived from the spec's *_FORK_VERSION keys, and OnGloas
no longer routes through it.

* test: fix service selection and handler races in the Gloas harness

Several of the new live tests used the shared service, which has no custom spec
support, for SSZ work that cannot decode on the minimal preset this devnet runs:
the SSZ rows of TestEPBSProposal, which contradicted the comment above them, and
TestSubmitProposalGloas, which failed before reaching the publish path it exists
to exercise. Both now go through a custom-spec service.

disagreeingService wrote s.base after New had already handed the service to
periodicUpdateConnectionState, an unsynchronised write against a goroutine that
reads it every 30s, which go test -race reports whenever HTTP_ADDRESS is set.
It now fronts the live node with a reverse proxy and passes WithAddress to New.

Other fixes:

- TestEPBSProposal re-reads the head slot per subtest, rather than computing it
  once for four live block productions the head can advance past.
- Assertions move out of httptest handler goroutines, where a failure calls
  runtime.Goexit outside the test's goroutine, abandons the request, and reports
  as a transport error naming nothing.
- The envelope sentinel test skips with the head slot named instead of
  underflowing phase0.Slot when the head is below slot 100.
- The KnowsGloas gate reason no longer claims the fork is absent when the spec
  may simply have been unreadable.

* ci: add an optional Gloas HTTP job

The harness gates every Gloas live test on the chain being past the fork, and
HTTP_REQUIRE_GLOAS turns those skips into failures. Nothing set it, so the gates
could only ever skip and the bugs fixed in the preceding commits passed CI
unnoticed.

Add a second job that points HTTP_ADDRESS at the HTTP_ADDRESS_GLOAS repository
variable and sets HTTP_REQUIRE_GLOAS. It is skipped when that variable is empty,
so forks and branches with no devnet to point at are unaffected. The run is
scoped by name: post-Gloas block production is v4-only, so the v3 rows of the
package fail permanently against a Gloas node for reasons this job is not about.

* ci: make Gloas HTTP job advisory

* feat: submit Gloas proposer preferences

Add the validator submission endpoint and provider parity.

Encode bounded preference lists in JSON or SSZ and require HTTP 200.

Focused transport and race tests plus build and lint pass; the parent HTTP harness still lacks its test helper dependency.

* fix: bound proposer preference validation

Reject oversized preference lists before scanning entries or encoding requests.

Keep the batch rejection path constant-time for multi-client forwarding.

* style: satisfy nlreturn

* style: satisfy nlreturn

* fix: address review of proposer preference submission

Take options rather than a bare slice
-------------------------------------
ProposerPreferencesSubmitter was the only submitter in service.go taking a
bare slice; every other one carries api.CommonOpts, so a caller could not
set a per-call timeout and the implementation hard-coded &api.CommonOpts{}.
Add api.SubmitProposerPreferencesOpts and thread it through http, multi,
mock and the test clients.  A nil opts is now client.ErrNoOptions; an empty
Preferences list remains a valid submission.

Derive the submission limit unconditionally
-------------------------------------------
customSpecSupport gates dynamic SSZ codec selection, not spec availability,
so using it to choose the limit source meant that on a minimal-preset chain
without the flag the bound was mainnet's 64 where the real lookahead is 16 —
silently a no-op.  Spec() is cached, so deriving the limit costs one round
trip per service lifetime.

The limit is a client-side sanity bound and the node remains the authority
on what it accepts, so proposerPreferencesLimit can no longer fail: an
unreachable or absent spec endpoint, a missing key, or values that overflow
the arithmetic all fall back to the mainnet preset instead of blocking a
submission the node would have accepted.  Dropping the error return is what
makes deriving the limit unconditionally safe.

Do not fail over on caller-side errors
--------------------------------------
multi passed a nil errHandler, so doCall treated every error as a provider
failure.  The validation errors above are not *api.Error with a 4xx status,
so a single submission of too many preferences walked the client list and
deactivated every healthy provider in turn, leaving the multi service empty
until the next recheck.  Suppress failover for ErrInvalidOptions, and add
the multi test file that the other submit methods all have.

Reject a nil preference message
-------------------------------
The nil guard checked the element pointer but not its Message.  A nil
Message marshals to a valid all-zero 172-byte SSZ preference but to
{"message":null} in JSON, so the same input silently submitted zeroed
preferences over SSZ and was rejected by the node under WithEnforceJSON.

Also preallocate the SSZ request body from SizeSSZ() rather than growing a
nil slice once per preference.

* Migrate ePBS block production to BuilderConfig POST

Use the pinned Gloas POST request contract with generated BuilderConfig codecs, negotiated JSON or SSZ transport, and validated response commitments.

Files: API proposal and publication options; Gloas BuilderConfig types/codegen; HTTP production, integrity, and transport tests.

Validation: focused protocol tests, API/spec/mock/multi/root tests, build, code generation stability, and live Prysm JSON/SSZ decoding passed. Full suite has unrelated legacy JSON error-string failures; golangci-lint lacks its configured attgo plugin.

* Fix ePBS BuilderConfig CI checks

* Address review of the BuilderConfig POST migration

Restore the POST response-body bound. post() was changed to pass a zero
limit, which readResponseBody treats as "unbounded", so every POST
endpoint but block production went back to an unbounded io.ReadAll --
undoing 90e1f9e. maxEPBSResponseSize keeps its 64MiB value for those and
for the four ePBS fetch endpoints that share it; block production passes
its own, much larger, limits under their own names.

Accept an empty builders list. The pinned contract documents an empty
list as a valid request ("Empty means request none, so only p2p bids are
considered", and a local-preferred build is requested with an empty list
and builder_boost_factor 0), with maxItems 64 and no minimum. A nil
slice said the same thing but was refused, while an empty non-nil slice
was accepted; the underlying defect was MarshalJSON emitting
"builders":null for a nil slice, which the required array -- and this
package's own decoder -- reject.

Complete BuilderConfig YAML support. BuilderEntry,
SignedBuilderRequestAuth and BuilderRequestAuth had no MarshalYAML, so a
config with any builder in it serialised reflection-derived keys and raw
byte arrays; BuilderConfig had no UnmarshalYAML, so a round trip
silently dropped min_bid and builder_boost_factor. The URL needs an
explicitly quoted scalar: goccy emits "https://..." plain, and the
embedded colon makes the enclosing flow document re-parse it as a nested
mapping. The existing test only marshalled an empty builders list, so it
saw none of this.

Restore TestVersionedEPBSProposalProposerIndex,
TestVersionedEPBSProposalRoot and the BodyRoot BeaconBlockBodyRootUnset
subtest, which were deleted, leaving ProposerIndex() and Root()
untested. Root() is what the envelope-matches-block guard and a
proposer's signature both rest on.

Also: drop the redundant Value test that duplicated a table case and
reattach the doc comment it had orphaned, use errors.New for the
constant-string fmt.Errorf calls, and collapse the single-case
table-driven tests to direct assertions.

* Tighten ePBS block-production response and builder-auth checks

Size the block-production response limits as an allocation budget rather
than from the protocol's static maximum. readResponseBody reaches its
length check only after io.ReadAll has buffered the body in full, and
ReadAll grows geometrically, so peak allocation is close to twice the
limit: rejecting a hostile body at the old 2.06GiB bound meant first
surviving some 4GiB, and at the JSON bound some 10GiB. The process dies
before either guard can fire, and a body admitted just under one is
equally fatal. Every live preset proposes orders of magnitude below the
new 256MiB and 512MiB, and a legitimate response refused here fails
loudly with the limit in its message, so a future preset that genuinely
outgrows them can raise them deliberately.

Refuse a builder entry whose authorization is for another slot. The slot
sits inside the signed message and is verified by the builder, not the
beacon node, so nothing on the request path rejects a stale one: the node
forwards it, the builder declines to bid, and the proposal succeeds on a
p2p or local build with no error raised anywhere. A caller that caches an
auth object across slots therefore loses that builder's bid every slot,
silently, and validateBuilderConfig is the only point at which it can
learn. The contract requires the request slot; the entry uniqueness rule
is left unenforced, since violating it costs a redundant bid request that
the node absorbs rather than the slot.

The response limits get their own test rather than a value assertion:
the enforcement path is already covered, and what went unnoticed in
review was this constant being raised 33-fold, so the ceiling is what
needs pinning.

Also bring api/submitproposalopts.go's copyright year up to date. It was
modified earlier on this branch without it, which fails the
attgo_current_year check and so the whole lint gate.

* Give the live-node proposal tests the BuilderConfig the POST migration requires

TestEPBSProposal and TestSubmitProposalGloas were left behind when block
production moved to POST: neither supplied a BuilderConfig, which
validateBuilderConfig requires, so both failed client-side with "no
builder config supplied" before a request was ever sent. They failed
that way on all four devnet clients, which meant the endpoint this
branch exists to migrate had no live-node coverage at all -- only
httptest handlers.

TestEPBSProposal now covers two builder-config shapes. The four existing
rows ask for a local-preferred build, the empty builders list the
contract documents as soliciting no direct bids. Two new rows populate
one BuilderEntry, which is a different body in both encodings and is
nothing an empty list reaches. That bid cannot be won from a test --
auth.message carries a signature the slot's proposer makes -- so the
builder declines and the node falls back, but the client's encoding of a
populated entry and the node's parsing and routing of it are exercised.
The URL is a refused loopback port: devnet-8 publishes no builder API to
point at, its buildoor pairs exposing only a beacon and an EL endpoint,
and a refused port fails the node's dial at once rather than holding the
request open. The authorization slot is the request's own, as the client
now requires.

Submit the block under the point at infinity rather than a 0x010203 byte
pattern. That pattern is not a decodable compressed G2 point -- the
compression flag is clear -- so lighthouse failed in deserialisation
with "failed to deserialize into PublishBlockRequest", which is the one
outcome the assertions there exist to rule out: they check that the
node's refusal concerns the block rather than the bytes. Reproduced
with curl alone, so it was never a client defect. Under a decodable
signature the node answers InvalidSignature(ProposerSignature), having
decoded the block and carried it to verification.

Verified against the direct devnet-8 nodes. Lighthouse, the one client
serving the POST route, is green: all six proposal rows, the submit
test, and 133 of the Gloas selection. Prysm answers 405 because the
deployed commit predates its POST support, nimbus 404 for having no v4
route, and teku 400 for a historic slot; the remaining
PreGloasSlotIsNotSentinel failure is a client divergence over bare 404
bodies and predates this change.

* test: cover unknown Gloas execution values

Keep a reported zero distinct from an absent execution value and reject proposals without execution bids.

* test: assert the consensus value stays unknown beside a known zero

ZeroExecutionValueFromHeaders asserted only ExecutionValue and the total,
both of which are zero whether or not the omitted consensus header was
handled correctly. A regression that defaulted an absent component to zero,
or wrote the execution header into ConsensusValue, would have left the test
green while collapsing the very distinction it exists to pin down.

Assert ConsensusValue is nil, matching AbsentExecutionValueIsUnknown, and
record that headers are the sole source of both components.

* fix: validate ePBS execution value metadata

* fix: correct the ePBS execution-value checks

Three defects in the execution-value validation:

The expected value hard-coded value + capped execution payment, but
beacon-APIs only says "the total value of the builder bid" and Prysm
reports the bid value alone.  Every builder-API proposal carrying a
non-zero execution payment was therefore rejected with
ErrInconsistentResult and the slot missed.  Accept a value anywhere in
the bracket both readings bound, since both ends are bound to the signed
bid.

BUILDER_INDEX_SELF_BUILD was hard-coded as MaxUint64, but it is a
configurable spec constant.  On a preset that moves it, a self-built bid
was read as a builder bid and rejected whenever a minimum bid was
configured.  Read it from the node when custom spec support is on,
mirroring proposerPreferencesLimit.

The bid checks were gated on the value header being present, so a node
could skip the self-build and minimum-bid checks by omitting a header
the spec marks required.  They are statements about the block, so they
now run regardless; only the value comparison needs the header.

Also fold the always-true single-builder guard into an explicit payment
cap, and give the table test its per-case config plus the accepted-value
cases the bracket needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: fall back to the mainnet self-build index when the node omits it

BUILDER_INDEX_SELF_BUILD is a Gloas-era spec value, so a node on an
earlier release serves a spec map without it.  Reading it through a
single type assertion conflated "absent" with "malformed" and failed
every ePBS proposal under WithCustomSpecSupport against such a node --
including the minimal-preset fixture in TestEPBSProposalCustomSpec,
which is how CI caught it.

Absence now falls back to the mainnet value, which is the value such a
node is using anyway; a present-but-non-numeric value is still an error.

* test: verify Gloas envelope root under minimal preset

* feat: support head_v2 SSE events

* feat: add proposer duties v2 providers

Expose the v2 proposer-duty endpoint across HTTP, multi, mock, and test-client providers while preserving the existing duty type and response metadata. Reuse the established options contract, retain v1 behavior, and let multi-client calls skip providers without v2 support.

* fix: conform Gloas vector YAML and empty transactions

Normalize vector YAML number spelling and multiline flow syntax without relaxing SSZ byte or root comparisons. Keep inherited Altair YAML mapping in a test-only fixture; tag Gloas pending payments and round-trip empty transaction bytes as 0x in JSON and YAML.

Next iteration: the repository-wide lint baseline still has 62 pre-existing issues; changed-code lint is clean.

---------

Co-authored-by: AntiD2ta <mtpotro41@gmail.com>
Co-authored-by: Miguel Tenorio <46824157+AntiD2ta@users.noreply.github.com>
Co-authored-by: parithosh <parithosh.jayanthi@ethereum.org>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Update changelog and version.

* Apply suggestion from @AntiD2ta

Co-authored-by: Miguel Tenorio <46824157+AntiD2ta@users.noreply.github.com>

---------

Co-authored-by: Miguel Tenorio <46824157+AntiD2ta@users.noreply.github.com>
@redpandabot

This comment has been minimized.

@redpandabot

This comment has been minimized.

pk910 added 2 commits October 7, 2026 17:23
…code

The lint action's own patch fetch fails on pull requests whose diff
exceeds GitHub's 20000-line limit, after which it lints the whole repo.
Fetch full history and pass --new-from-merge-base instead so only new
lines are reported, as upstream's CI effectively does.

Drop the fork-only tagalign ordering, which upstream's gloas files do not
follow, and reformat the fork's own tags to upstream's default. Add the
interface assertions and reflect.TypeAssert the upgraded linters ask for
in the fork-agnostic packages.
- Dial the SSE transport with DialContext and ForceAttemptHTTP2 again so
  HTTP/2 streams behind reverse proxies do not hang silently.
- Reroute Grandine's post-Electra SingleAttestation published on the
  attestation topic to the single_attestation handler, now inside the
  generic dispatcher.
- Accept Builder.version as a quoted string on decode, as Lighthouse
  serves it, while keeping upstream's numeric encoding.
@redpandabot

This comment has been minimized.

- Decode ptc_window indices served as bare JSON numbers (Lighthouse) as
  well as quoted strings and Prysm's validator_indices wrapper.
- Accept head_v2 events published without the version/data wrapper and
  treat the dependent roots as optional, as only some clients send them.
- Send Eth-Execution-Payload-Blinded: false alongside
  Eth-Blob-Data-Included on envelope submission for beacon nodes that
  predate beacon-APIs#624.
- Exclude revive's http package-naming rule, which it reports on an
  arbitrary file and so flakes whenever a merge adds files there.
@redpandabot

redpandabot Bot commented Oct 7, 2026

Copy link
Copy Markdown

Summary

This is the fork's merge of upstream v0.30.0 plus two restore commits. The three findings from the previous review are all fixed in 752ade2: ptc_window again accepts Lighthouse's bare-number indices, head_v2 again accepts the bare shape and treats the dependent roots as optional, and the pre-beacon-APIs#624 Eth-Execution-Payload-Blinded: false header is sent again. One fork interop fix from the same merge is still missing: the versioned {version,data} wrapper tolerance for the execution_payload and execution_payload_gossip SSE events.

Issues

  • 🟡 api/v1/eventtopic.go:42 — execution_payload/gossip no longer tolerate the versioned SSE wrapper — These two topics are registered bare (lines 42 and 45), and neither ExecutionPayloadEvent.UnmarshalJSON nor ExecutionPayloadGossipEvent.UnmarshalJSON unwraps {"version":...,"data":{...}}. The merge removed http/events.go's unmarshalVersionedEventData, which the fork had explicitly applied to both handlers (commit ed85bf5, "tolerating the {version,data} wrapper some clients emit"), so a wrapped execution_payload event now fails with "slot missing" and is dropped at http/events.go:139. Register the topics as NewVersioned or unwrap in the types, as HeadEventV2 does.

Reviewed @ 752ade27
"Debugging is twice as hard as writing the code in the first place." — Brian Kernighan

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