From d7bff44b812f8e6b85ba89411733605b461163de Mon Sep 17 00:00:00 2001 From: Chris Berry <4602753+Bez625@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:27:29 +0100 Subject: [PATCH 1/5] Gloas compatibility for containers and new beacon-node endpoints (#351) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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 Co-authored-by: Miguel Tenorio <46824157+AntiD2ta@users.noreply.github.com> Co-authored-by: parithosh Co-authored-by: Claude Opus 5 (1M context) --- .custom-gcl.yml | 2 +- .github/workflows/golangci-lint.yml | 2 +- .github/workflows/http-tests.yml | 30 + .gitignore | 3 + .golangci.yml | 15 +- CHANGELOG.md | 8 + api/epbsproposalopts.go | 65 + api/eventsopts.go | 45 +- api/executionpayloadenvelopeopts.go | 40 + api/payloadattestationdataopts.go | 24 + api/payloadattestationpoolopts.go | 26 + api/ptcdutiesopts.go | 26 + api/signedexecutionpayloadenvelopeopts.go | 22 + api/submitexecutionpayloadbidopts.go | 28 + api/submitexecutionpayloadenvelopeopts.go | 45 + api/submitpayloadattestationmessagesopts.go | 26 + api/submitproposalopts.go | 5 +- api/submitproposerpreferencesopts.go | 29 + api/v1/event.go | 73 +- api/v1/event_test.go | 103 +- api/v1/eventtopic.go | 83 + api/v1/eventtopic_internal_test.go | 120 ++ api/v1/executionpayloadavailableevent.go | 79 + api/v1/executionpayloadavailableevent_test.go | 91 + api/v1/executionpayloadevent.go | 129 ++ api/v1/executionpayloadevent_test.go | 175 ++ api/v1/executionpayloadgossipevent.go | 69 + api/v1/fastconfirmationevent.go | 106 ++ api/v1/fastconfirmationevent_test.go | 118 ++ api/v1/gloas/blockcontents.go | 43 + api/v1/gloas/blockcontents_json.go | 75 + api/v1/gloas/blockcontents_ssz.go | 246 +++ api/v1/gloas/blockcontents_test.go | 232 +++ api/v1/gloas/blockcontents_yaml.go | 63 + api/v1/gloas/generate.go | 17 + api/v1/gloas/generate.yaml | 9 + .../signedexecutionpayloadenvelopecontents.go | 56 + ...edexecutionpayloadenvelopecontents_json.go | 55 + ...nedexecutionpayloadenvelopecontents_ssz.go | 208 +++ ...edexecutionpayloadenvelopecontents_test.go | 36 + ...edexecutionpayloadenvelopecontents_yaml.go | 47 + api/v1/headevent.go | 6 +- api/v1/headevent_test.go | 11 + api/v1/headeventv2.go | 147 ++ api/v1/headeventv2_test.go | 116 ++ api/v1/lightclientevents.go | 71 + api/v1/lightclientevents_test.go | 47 + api/v1/ptcduty.go | 115 ++ api/v1/ptcduty_test.go | 140 ++ api/versionedepbsproposal.go | 306 +++ api/versionedepbsproposal_test.go | 722 +++++++ api/versionedsignedproposal.go | 44 +- api/versionedsignedproposal_test.go | 174 ++ errors.go | 9 +- events_test.go | 196 ++ http/aggregateattestation.go | 13 +- http/aggregateattestation_internal_test.go | 59 +- http/attestationdata.go | 59 +- http/attestationdata_test.go | 163 +- http/attestationpool.go | 111 +- http/attestationpool_internal_test.go | 140 ++ http/beaconstate.go | 16 +- http/blobs_test.go | 6 + http/blobsidecars_test.go | 11 + http/epbsguardwiring_internal_test.go | 166 ++ http/epbsproposal.go | 714 +++++++ http/epbsproposal_builderconfig_test.go | 180 ++ http/epbsproposal_customspec_test.go | 229 +++ http/epbsproposal_integrity_internal_test.go | 80 + http/epbsproposal_internal_test.go | 640 +++++++ http/epbsproposal_response_internal_test.go | 259 +++ ...bsproposal_selfbuildindex_internal_test.go | 79 + http/epbsproposal_test.go | 224 +++ http/epbsproposal_validation_internal_test.go | 134 ++ http/epbsproposal_value_internal_test.go | 191 ++ http/events.go | 534 +----- http/events_internal_test.go | 421 +++-- http/events_test.go | 163 +- http/executionpayloadenvelope.go | 184 ++ .../executionpayloadenvelope_internal_test.go | 203 ++ ...npayloadenvelope_sentinel_internal_test.go | 56 + http/executionpayloadenvelope_test.go | 123 ++ http/gloasgate_test.go | 92 + http/http.go | 54 +- http/main_internal_test.go | 162 ++ http/main_test.go | 78 +- http/marshalrequestbody.go | 60 + http/marshalrequestbody_internal_test.go | 86 + http/payloadattestationdata.go | 148 ++ http/payloadattestationdata_internal_test.go | 191 ++ http/payloadattestationdata_test.go | 57 + http/payloadattestationpool.go | 140 ++ http/payloadattestationpool_internal_test.go | 182 ++ http/proposerduties.go | 22 +- http/proposerduties_test.go | 93 + http/ptcduties.go | 118 ++ http/ptcduties_internal_test.go | 94 + http/responsebody_internal_test.go | 77 + http/service_test.go | 11 +- http/signedbeaconblock.go | 18 +- http/signedbeaconblock_customspec_test.go | 102 + http/signedexecutionpayloadenvelope.go | 154 ++ ...dexecutionpayloadenvelope_internal_test.go | 135 ++ http/signedexecutionpayloadenvelope_test.go | 72 + http/ssztest_internal_test.go | 30 + http/submitaggregateattestations.go | 36 +- ...bmitaggregateattestations_internal_test.go | 223 +++ http/submitattestations.go | 15 +- http/submitattestations_internal_test.go | 70 + http/submitexecutionpayloadbid.go | 88 + http/submitexecutionpayloadbid_test.go | 117 ++ http/submitexecutionpayloadenvelope.go | 114 ++ http/submitexecutionpayloadenvelope_test.go | 121 ++ http/submitpayloadattestationmessages.go | 125 ++ ...ayloadattestationmessages_internal_test.go | 118 ++ http/submitproposal.go | 108 +- http/submitproposal_builderurl_test.go | 65 + http/submitproposal_internal_test.go | 271 +++ http/submitproposal_test.go | 83 + http/submitproposerpreferences.go | 134 ++ http/submitproposerpreferences_test.go | 401 ++++ internal/eventdispatch/eventdispatch.go | 238 +++ internal/eventdispatch/eventdispatch_test.go | 158 ++ internal/eventtopic/eventtopic.go | 154 ++ internal/eventtopic/eventtopic_test.go | 110 ++ mock/epbsproposal.go | 145 ++ mock/epbsproposal_test.go | 180 ++ mock/executionpayloadenvelope.go | 51 + mock/payloadattestationdata.go | 46 + mock/payloadattestationpool.go | 57 + mock/proposerduties.go | 17 +- mock/proposerduties_v2_test.go | 67 + mock/ptcduties.go | 47 + mock/service.go | 76 +- mock/signedexecutionpayloadenvelope.go | 59 + mock/submitexecutionpayloadbid.go | 25 + mock/submitexecutionpayloadenvelope.go | 25 + mock/submitpayloadattestationmessages.go | 27 + mock/submitproposerpreferences.go | 25 + multi/epbsproposal.go | 48 + multi/events.go | 438 ++--- multi/events_internal_test.go | 318 ++++ multi/events_test.go | 576 +++++- multi/executionpayloadenvelope.go | 49 + multi/parameters.go | 22 +- multi/payloadattestationdata.go | 49 + multi/payloadattestationpool.go | 49 + multi/proposerduties.go | 34 + multi/proposerduties_test.go | 62 + multi/ptcduties.go | 49 + multi/service.go | 20 +- multi/service_test.go | 19 + multi/signedexecutionpayloadenvelope.go | 49 + multi/submitexecutionpayloadbid.go | 37 + multi/submitexecutionpayloadenvelope.go | 37 + multi/submitpayloadattestationmessages.go | 37 + multi/submitproposerpreferences.go | 39 + multi/submitproposerpreferences_test.go | 95 + providerparity_test.go | 90 + service.go | 126 +- spec/dataversion.go | 14 +- spec/dataversion_test.go | 63 + spec/gloas/aggregateandproof.go | 137 ++ spec/gloas/aggregateandproof_ssz.go | 133 ++ spec/gloas/attestation.go | 212 +++ spec/gloas/attestation_ssz.go | 186 ++ spec/gloas/attestation_test.go | 192 ++ spec/gloas/attesterslashing.go | 107 ++ spec/gloas/attesterslashing_ssz.go | 148 ++ spec/gloas/beaconblock.go | 40 + spec/gloas/beaconblock_json.go | 69 + spec/gloas/beaconblock_ssz.go | 155 ++ spec/gloas/beaconblock_yaml.go | 53 + spec/gloas/beaconblockbody.go | 50 + spec/gloas/beaconblockbody_json.go | 115 ++ spec/gloas/beaconblockbody_ssz.go | 712 +++++++ spec/gloas/beaconblockbody_yaml.go | 61 + spec/gloas/beaconstate.go | 85 + spec/gloas/beaconstate_json.go | 478 +++++ spec/gloas/beaconstate_ssz.go | 1661 +++++++++++++++++ spec/gloas/beaconstate_test.go | 331 ++++ spec/gloas/beaconstate_yaml.go | 150 ++ spec/gloas/builder.go | 191 ++ spec/gloas/builder_ssz.go | 130 ++ spec/gloas/builder_test.go | 74 + spec/gloas/builderconfig.go | 45 + spec/gloas/builderconfig_json.go | 252 +++ spec/gloas/builderconfig_ssz.go | 192 ++ spec/gloas/builderconfig_test.go | 310 +++ spec/gloas/builderconfig_yaml.go | 165 ++ spec/gloas/builderdepositrequest.go | 39 + spec/gloas/builderdepositrequest_json.go | 110 ++ spec/gloas/builderdepositrequest_ssz.go | 126 ++ spec/gloas/builderdepositrequest_yaml.go | 55 + spec/gloas/builderentry_ssz.go | 228 +++ spec/gloas/builderexitrequest.go | 38 + spec/gloas/builderexitrequest_json.go | 55 + spec/gloas/builderexitrequest_ssz.go | 87 + spec/gloas/builderexitrequest_yaml.go | 60 + spec/gloas/builderindex.go | 61 + spec/gloas/builderpendingpayment.go | 38 + spec/gloas/builderpendingpayment_ssz.go | 117 ++ spec/gloas/builderpendingpayment_test.go | 48 + spec/gloas/builderpendingwithdrawal.go | 39 + spec/gloas/builderpendingwithdrawal_ssz.go | 100 + spec/gloas/builderrequestauth_ssz.go | 119 ++ spec/gloas/consensusspec_test.go | 394 ++++ spec/gloas/executionpayload.go | 60 + spec/gloas/executionpayload_json.go | 321 ++++ spec/gloas/executionpayload_ssz.go | 460 +++++ spec/gloas/executionpayload_test.go | 157 ++ spec/gloas/executionpayload_yaml.go | 120 ++ spec/gloas/executionpayloadbid.go | 49 + spec/gloas/executionpayloadbid_json.go | 222 +++ spec/gloas/executionpayloadbid_ssz.go | 233 +++ spec/gloas/executionpayloadbid_yaml.go | 65 + spec/gloas/executionpayloadenvelope.go | 40 + spec/gloas/executionpayloadenvelope_json.go | 98 + spec/gloas/executionpayloadenvelope_ssz.go | 181 ++ spec/gloas/executionpayloadenvelope_test.go | 59 + spec/gloas/executionpayloadenvelope_yaml.go | 53 + spec/gloas/executionrequests.go | 40 + spec/gloas/executionrequests_json.go | 103 + spec/gloas/executionrequests_ssz.go | 379 ++++ spec/gloas/executionrequests_yaml.go | 65 + spec/gloas/generate.go | 17 + spec/gloas/generate.yaml | 65 + spec/gloas/indexedattestation.go | 140 ++ spec/gloas/indexedattestation_ssz.go | 147 ++ spec/gloas/indexedpayloadattestation.go | 38 + spec/gloas/indexedpayloadattestation_json.go | 82 + spec/gloas/indexedpayloadattestation_ssz.go | 155 ++ spec/gloas/indexedpayloadattestation_yaml.go | 55 + spec/gloas/jsonlength_test.go | 357 ++++ spec/gloas/payloadattestation.go | 113 ++ spec/gloas/payloadattestation_json.go | 76 + spec/gloas/payloadattestation_ssz.go | 130 ++ spec/gloas/payloadattestation_test.go | 269 +++ spec/gloas/payloadattestation_yaml.go | 51 + spec/gloas/payloadattestationdata.go | 39 + spec/gloas/payloadattestationdata_json.go | 75 + spec/gloas/payloadattestationdata_ssz.go | 116 ++ spec/gloas/payloadattestationdata_yaml.go | 52 + spec/gloas/payloadattestationmessage.go | 38 + spec/gloas/payloadattestationmessage_json.go | 77 + spec/gloas/payloadattestationmessage_ssz.go | 117 ++ spec/gloas/payloadattestationmessage_yaml.go | 51 + spec/gloas/proposerpreferences.go | 41 + spec/gloas/proposerpreferences_json.go | 112 ++ spec/gloas/proposerpreferences_ssz.go | 120 ++ spec/gloas/proposerpreferences_yaml.go | 53 + spec/gloas/signedaggregateandproof.go | 120 ++ spec/gloas/signedaggregateandproof_ssz.go | 121 ++ spec/gloas/signedbeaconblock.go | 37 + spec/gloas/signedbeaconblock_json.go | 68 + spec/gloas/signedbeaconblock_ssz.go | 121 ++ spec/gloas/signedbeaconblock_yaml.go | 55 + spec/gloas/signedbuilderrequestauth_ssz.go | 121 ++ spec/gloas/signedexecutionpayloadbid.go | 37 + spec/gloas/signedexecutionpayloadbid_json.go | 65 + spec/gloas/signedexecutionpayloadbid_ssz.go | 121 ++ spec/gloas/signedexecutionpayloadbid_yaml.go | 50 + spec/gloas/signedexecutionpayloadenvelope.go | 37 + .../signedexecutionpayloadenvelope_json.go | 65 + .../signedexecutionpayloadenvelope_ssz.go | 121 ++ .../signedexecutionpayloadenvelope_yaml.go | 50 + spec/gloas/signedproposerpreferences.go | 37 + spec/gloas/signedproposerpreferences_json.go | 65 + spec/gloas/signedproposerpreferences_ssz.go | 104 ++ spec/gloas/signedproposerpreferences_yaml.go | 50 + spec/gloas/sszannotations_test.go | 120 ++ spec/gloas/sszroot_test.go | 276 +++ spec/version_compatibility_test.go | 44 + spec/versionedaggregateandproof.go | 31 +- spec/versionedaggregateandproof_test.go | 165 ++ spec/versionedattestation.go | 48 +- spec/versionedattesterslashing.go | 34 +- spec/versionedbeaconblock.go | 140 +- spec/versionedbeaconblockbody.go | 10 +- spec/versionedbeaconstate.go | 146 +- spec/versionedexecutionpayload.go | 114 +- spec/versionedexecutionpayloadenvelope.go | 194 ++ .../versionedexecutionpayloadenvelope_test.go | 141 ++ spec/versionedexecutionrequests.go | 182 ++ spec/versionedexecutionrequests_test.go | 136 ++ spec/versionedindexedattestation.go | 30 +- spec/versionedpayloadattestation.go | 114 ++ spec/versionedpayloadattestation_test.go | 108 ++ spec/versionedpayloadattestationdata.go | 112 ++ spec/versionedpayloadattestationdata_test.go | 108 ++ spec/versionedpayloadattestationmessage.go | 107 ++ ...versionedpayloadattestationmessage_test.go | 93 + spec/versionedsignedaggregateandproof.go | 39 +- spec/versionedsignedaggregateandproof_test.go | 146 ++ spec/versionedsignedbeaconblock.go | 197 +- spec/versionedsignedexecutionpayloadbid.go | 354 ++++ ...versionedsignedexecutionpayloadbid_test.go | 203 ++ ...versionedsignedexecutionpayloadenvelope.go | 251 +++ ...onedsignedexecutionpayloadenvelope_test.go | 209 +++ testclients/erroring.go | 176 +- testclients/erroring_test.go | 23 + testclients/forkutil.go | 103 + testclients/forkutil_test.go | 78 + testclients/sleepy.go | 154 +- testclients/sleepy_test.go | 23 + 305 files changed, 36094 insertions(+), 1227 deletions(-) create mode 100644 api/epbsproposalopts.go create mode 100644 api/executionpayloadenvelopeopts.go create mode 100644 api/payloadattestationdataopts.go create mode 100644 api/payloadattestationpoolopts.go create mode 100644 api/ptcdutiesopts.go create mode 100644 api/signedexecutionpayloadenvelopeopts.go create mode 100644 api/submitexecutionpayloadbidopts.go create mode 100644 api/submitexecutionpayloadenvelopeopts.go create mode 100644 api/submitpayloadattestationmessagesopts.go create mode 100644 api/submitproposerpreferencesopts.go create mode 100644 api/v1/eventtopic.go create mode 100644 api/v1/eventtopic_internal_test.go create mode 100644 api/v1/executionpayloadavailableevent.go create mode 100644 api/v1/executionpayloadavailableevent_test.go create mode 100644 api/v1/executionpayloadevent.go create mode 100644 api/v1/executionpayloadevent_test.go create mode 100644 api/v1/executionpayloadgossipevent.go create mode 100644 api/v1/fastconfirmationevent.go create mode 100644 api/v1/fastconfirmationevent_test.go create mode 100644 api/v1/gloas/blockcontents.go create mode 100644 api/v1/gloas/blockcontents_json.go create mode 100644 api/v1/gloas/blockcontents_ssz.go create mode 100644 api/v1/gloas/blockcontents_test.go create mode 100644 api/v1/gloas/blockcontents_yaml.go create mode 100644 api/v1/gloas/generate.go create mode 100644 api/v1/gloas/generate.yaml create mode 100644 api/v1/gloas/signedexecutionpayloadenvelopecontents.go create mode 100644 api/v1/gloas/signedexecutionpayloadenvelopecontents_json.go create mode 100644 api/v1/gloas/signedexecutionpayloadenvelopecontents_ssz.go create mode 100644 api/v1/gloas/signedexecutionpayloadenvelopecontents_test.go create mode 100644 api/v1/gloas/signedexecutionpayloadenvelopecontents_yaml.go create mode 100644 api/v1/headeventv2.go create mode 100644 api/v1/headeventv2_test.go create mode 100644 api/v1/lightclientevents.go create mode 100644 api/v1/lightclientevents_test.go create mode 100644 api/v1/ptcduty.go create mode 100644 api/v1/ptcduty_test.go create mode 100644 api/versionedepbsproposal.go create mode 100644 api/versionedepbsproposal_test.go create mode 100644 api/versionedsignedproposal_test.go create mode 100644 events_test.go create mode 100644 http/attestationpool_internal_test.go create mode 100644 http/epbsguardwiring_internal_test.go create mode 100644 http/epbsproposal.go create mode 100644 http/epbsproposal_builderconfig_test.go create mode 100644 http/epbsproposal_customspec_test.go create mode 100644 http/epbsproposal_integrity_internal_test.go create mode 100644 http/epbsproposal_internal_test.go create mode 100644 http/epbsproposal_response_internal_test.go create mode 100644 http/epbsproposal_selfbuildindex_internal_test.go create mode 100644 http/epbsproposal_test.go create mode 100644 http/epbsproposal_validation_internal_test.go create mode 100644 http/epbsproposal_value_internal_test.go create mode 100644 http/executionpayloadenvelope.go create mode 100644 http/executionpayloadenvelope_internal_test.go create mode 100644 http/executionpayloadenvelope_sentinel_internal_test.go create mode 100644 http/executionpayloadenvelope_test.go create mode 100644 http/gloasgate_test.go create mode 100644 http/main_internal_test.go create mode 100644 http/marshalrequestbody.go create mode 100644 http/marshalrequestbody_internal_test.go create mode 100644 http/payloadattestationdata.go create mode 100644 http/payloadattestationdata_internal_test.go create mode 100644 http/payloadattestationdata_test.go create mode 100644 http/payloadattestationpool.go create mode 100644 http/payloadattestationpool_internal_test.go create mode 100644 http/ptcduties.go create mode 100644 http/ptcduties_internal_test.go create mode 100644 http/responsebody_internal_test.go create mode 100644 http/signedbeaconblock_customspec_test.go create mode 100644 http/signedexecutionpayloadenvelope.go create mode 100644 http/signedexecutionpayloadenvelope_internal_test.go create mode 100644 http/signedexecutionpayloadenvelope_test.go create mode 100644 http/ssztest_internal_test.go create mode 100644 http/submitaggregateattestations_internal_test.go create mode 100644 http/submitattestations_internal_test.go create mode 100644 http/submitexecutionpayloadbid.go create mode 100644 http/submitexecutionpayloadbid_test.go create mode 100644 http/submitexecutionpayloadenvelope.go create mode 100644 http/submitexecutionpayloadenvelope_test.go create mode 100644 http/submitpayloadattestationmessages.go create mode 100644 http/submitpayloadattestationmessages_internal_test.go create mode 100644 http/submitproposal_builderurl_test.go create mode 100644 http/submitproposal_internal_test.go create mode 100644 http/submitproposerpreferences.go create mode 100644 http/submitproposerpreferences_test.go create mode 100644 internal/eventdispatch/eventdispatch.go create mode 100644 internal/eventdispatch/eventdispatch_test.go create mode 100644 internal/eventtopic/eventtopic.go create mode 100644 internal/eventtopic/eventtopic_test.go create mode 100644 mock/epbsproposal.go create mode 100644 mock/epbsproposal_test.go create mode 100644 mock/executionpayloadenvelope.go create mode 100644 mock/payloadattestationdata.go create mode 100644 mock/payloadattestationpool.go create mode 100644 mock/proposerduties_v2_test.go create mode 100644 mock/ptcduties.go create mode 100644 mock/signedexecutionpayloadenvelope.go create mode 100644 mock/submitexecutionpayloadbid.go create mode 100644 mock/submitexecutionpayloadenvelope.go create mode 100644 mock/submitpayloadattestationmessages.go create mode 100644 mock/submitproposerpreferences.go create mode 100644 multi/epbsproposal.go create mode 100644 multi/events_internal_test.go create mode 100644 multi/executionpayloadenvelope.go create mode 100644 multi/payloadattestationdata.go create mode 100644 multi/payloadattestationpool.go create mode 100644 multi/ptcduties.go create mode 100644 multi/signedexecutionpayloadenvelope.go create mode 100644 multi/submitexecutionpayloadbid.go create mode 100644 multi/submitexecutionpayloadenvelope.go create mode 100644 multi/submitpayloadattestationmessages.go create mode 100644 multi/submitproposerpreferences.go create mode 100644 multi/submitproposerpreferences_test.go create mode 100644 providerparity_test.go create mode 100644 spec/dataversion_test.go create mode 100644 spec/gloas/aggregateandproof.go create mode 100644 spec/gloas/aggregateandproof_ssz.go create mode 100644 spec/gloas/attestation.go create mode 100644 spec/gloas/attestation_ssz.go create mode 100644 spec/gloas/attestation_test.go create mode 100644 spec/gloas/attesterslashing.go create mode 100644 spec/gloas/attesterslashing_ssz.go create mode 100644 spec/gloas/beaconblock.go create mode 100644 spec/gloas/beaconblock_json.go create mode 100644 spec/gloas/beaconblock_ssz.go create mode 100644 spec/gloas/beaconblock_yaml.go create mode 100644 spec/gloas/beaconblockbody.go create mode 100644 spec/gloas/beaconblockbody_json.go create mode 100644 spec/gloas/beaconblockbody_ssz.go create mode 100644 spec/gloas/beaconblockbody_yaml.go create mode 100644 spec/gloas/beaconstate.go create mode 100644 spec/gloas/beaconstate_json.go create mode 100644 spec/gloas/beaconstate_ssz.go create mode 100644 spec/gloas/beaconstate_test.go create mode 100644 spec/gloas/beaconstate_yaml.go create mode 100644 spec/gloas/builder.go create mode 100644 spec/gloas/builder_ssz.go create mode 100644 spec/gloas/builder_test.go create mode 100644 spec/gloas/builderconfig.go create mode 100644 spec/gloas/builderconfig_json.go create mode 100644 spec/gloas/builderconfig_ssz.go create mode 100644 spec/gloas/builderconfig_test.go create mode 100644 spec/gloas/builderconfig_yaml.go create mode 100644 spec/gloas/builderdepositrequest.go create mode 100644 spec/gloas/builderdepositrequest_json.go create mode 100644 spec/gloas/builderdepositrequest_ssz.go create mode 100644 spec/gloas/builderdepositrequest_yaml.go create mode 100644 spec/gloas/builderentry_ssz.go create mode 100644 spec/gloas/builderexitrequest.go create mode 100644 spec/gloas/builderexitrequest_json.go create mode 100644 spec/gloas/builderexitrequest_ssz.go create mode 100644 spec/gloas/builderexitrequest_yaml.go create mode 100644 spec/gloas/builderindex.go create mode 100644 spec/gloas/builderpendingpayment.go create mode 100644 spec/gloas/builderpendingpayment_ssz.go create mode 100644 spec/gloas/builderpendingpayment_test.go create mode 100644 spec/gloas/builderpendingwithdrawal.go create mode 100644 spec/gloas/builderpendingwithdrawal_ssz.go create mode 100644 spec/gloas/builderrequestauth_ssz.go create mode 100644 spec/gloas/consensusspec_test.go create mode 100644 spec/gloas/executionpayload.go create mode 100644 spec/gloas/executionpayload_json.go create mode 100644 spec/gloas/executionpayload_ssz.go create mode 100644 spec/gloas/executionpayload_test.go create mode 100644 spec/gloas/executionpayload_yaml.go create mode 100644 spec/gloas/executionpayloadbid.go create mode 100644 spec/gloas/executionpayloadbid_json.go create mode 100644 spec/gloas/executionpayloadbid_ssz.go create mode 100644 spec/gloas/executionpayloadbid_yaml.go create mode 100644 spec/gloas/executionpayloadenvelope.go create mode 100644 spec/gloas/executionpayloadenvelope_json.go create mode 100644 spec/gloas/executionpayloadenvelope_ssz.go create mode 100644 spec/gloas/executionpayloadenvelope_test.go create mode 100644 spec/gloas/executionpayloadenvelope_yaml.go create mode 100644 spec/gloas/executionrequests.go create mode 100644 spec/gloas/executionrequests_json.go create mode 100644 spec/gloas/executionrequests_ssz.go create mode 100644 spec/gloas/executionrequests_yaml.go create mode 100644 spec/gloas/generate.go create mode 100644 spec/gloas/generate.yaml create mode 100644 spec/gloas/indexedattestation.go create mode 100644 spec/gloas/indexedattestation_ssz.go create mode 100644 spec/gloas/indexedpayloadattestation.go create mode 100644 spec/gloas/indexedpayloadattestation_json.go create mode 100644 spec/gloas/indexedpayloadattestation_ssz.go create mode 100644 spec/gloas/indexedpayloadattestation_yaml.go create mode 100644 spec/gloas/jsonlength_test.go create mode 100644 spec/gloas/payloadattestation.go create mode 100644 spec/gloas/payloadattestation_json.go create mode 100644 spec/gloas/payloadattestation_ssz.go create mode 100644 spec/gloas/payloadattestation_test.go create mode 100644 spec/gloas/payloadattestation_yaml.go create mode 100644 spec/gloas/payloadattestationdata.go create mode 100644 spec/gloas/payloadattestationdata_json.go create mode 100644 spec/gloas/payloadattestationdata_ssz.go create mode 100644 spec/gloas/payloadattestationdata_yaml.go create mode 100644 spec/gloas/payloadattestationmessage.go create mode 100644 spec/gloas/payloadattestationmessage_json.go create mode 100644 spec/gloas/payloadattestationmessage_ssz.go create mode 100644 spec/gloas/payloadattestationmessage_yaml.go create mode 100644 spec/gloas/proposerpreferences.go create mode 100644 spec/gloas/proposerpreferences_json.go create mode 100644 spec/gloas/proposerpreferences_ssz.go create mode 100644 spec/gloas/proposerpreferences_yaml.go create mode 100644 spec/gloas/signedaggregateandproof.go create mode 100644 spec/gloas/signedaggregateandproof_ssz.go create mode 100644 spec/gloas/signedbeaconblock.go create mode 100644 spec/gloas/signedbeaconblock_json.go create mode 100644 spec/gloas/signedbeaconblock_ssz.go create mode 100644 spec/gloas/signedbeaconblock_yaml.go create mode 100644 spec/gloas/signedbuilderrequestauth_ssz.go create mode 100644 spec/gloas/signedexecutionpayloadbid.go create mode 100644 spec/gloas/signedexecutionpayloadbid_json.go create mode 100644 spec/gloas/signedexecutionpayloadbid_ssz.go create mode 100644 spec/gloas/signedexecutionpayloadbid_yaml.go create mode 100644 spec/gloas/signedexecutionpayloadenvelope.go create mode 100644 spec/gloas/signedexecutionpayloadenvelope_json.go create mode 100644 spec/gloas/signedexecutionpayloadenvelope_ssz.go create mode 100644 spec/gloas/signedexecutionpayloadenvelope_yaml.go create mode 100644 spec/gloas/signedproposerpreferences.go create mode 100644 spec/gloas/signedproposerpreferences_json.go create mode 100644 spec/gloas/signedproposerpreferences_ssz.go create mode 100644 spec/gloas/signedproposerpreferences_yaml.go create mode 100644 spec/gloas/sszannotations_test.go create mode 100644 spec/gloas/sszroot_test.go create mode 100644 spec/version_compatibility_test.go create mode 100644 spec/versionedaggregateandproof_test.go create mode 100644 spec/versionedexecutionpayloadenvelope.go create mode 100644 spec/versionedexecutionpayloadenvelope_test.go create mode 100644 spec/versionedexecutionrequests.go create mode 100644 spec/versionedexecutionrequests_test.go create mode 100644 spec/versionedpayloadattestation.go create mode 100644 spec/versionedpayloadattestation_test.go create mode 100644 spec/versionedpayloadattestationdata.go create mode 100644 spec/versionedpayloadattestationdata_test.go create mode 100644 spec/versionedpayloadattestationmessage.go create mode 100644 spec/versionedpayloadattestationmessage_test.go create mode 100644 spec/versionedsignedaggregateandproof_test.go create mode 100644 spec/versionedsignedexecutionpayloadbid.go create mode 100644 spec/versionedsignedexecutionpayloadbid_test.go create mode 100644 spec/versionedsignedexecutionpayloadenvelope.go create mode 100644 spec/versionedsignedexecutionpayloadenvelope_test.go create mode 100644 testclients/forkutil.go create mode 100644 testclients/forkutil_test.go diff --git a/.custom-gcl.yml b/.custom-gcl.yml index bed0a74d..980500f5 100644 --- a/.custom-gcl.yml +++ b/.custom-gcl.yml @@ -1,4 +1,4 @@ -version: v2.8.0 +version: v2.13.1 name: custom-gcl plugins: diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 8a579399..28533b9a 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -33,7 +33,7 @@ jobs: - name: Build custom golangci-lint if: steps.cache-custom-gcl.outputs.cache-hit != 'true' run: | - go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.8.0 + go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.1 golangci-lint custom # Make custom binary available as golangci-lint in PATH diff --git a/.github/workflows/http-tests.yml b/.github/workflows/http-tests.yml index 26a2dc57..b542b98d 100644 --- a/.github/workflows/http-tests.yml +++ b/.github/workflows/http-tests.yml @@ -33,3 +33,33 @@ jobs: HTTP_TEST_CONCURRENCY: ${{ vars.HTTP_TEST_CONCURRENCY }} HTTP_BEARER_TOKEN: ${{ secrets.HTTP_BEARER_TOKEN }} run: go test -v -timeout=10m ./http/... + + gloas: + name: Gloas HTTP tests (advisory) + # Advisory: require a configured endpoint. Fork PRs cannot read + # HTTP_BEARER_TOKEN_GLOAS, so do not attempt an unauthenticated run. + if: ${{ vars.HTTP_ADDRESS_GLOAS != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} + continue-on-error: true + runs-on: ubuntu-24.04 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.25.2' + + - name: Run Gloas HTTP tests + env: + HTTP_ADDRESS: ${{ vars.HTTP_ADDRESS_GLOAS }} + HTTP_TEST_CONCURRENCY: ${{ vars.HTTP_TEST_CONCURRENCY }} + HTTP_BEARER_TOKEN: ${{ secrets.HTTP_BEARER_TOKEN_GLOAS }} + # Turns every Gloas fork gate from a skip into a failure. The point of + # the job is to prove the gated tests run, so a skip here means the + # endpoint is not on Gloas or the gate itself is broken. + HTTP_REQUIRE_GLOAS: 'true' + # Temporary exception to full-suite selection: v3 block-production tests + # cannot pass on Gloas nodes. Remove the filter when those tests are gated + # on node capability; new Gloas live tests must match this pattern meanwhile. + run: go test -v -timeout=10m -run 'Gloas|EPBS|ExecutionPayload' ./http/... diff --git a/.gitignore b/.gitignore index 641bc22c..c5634b15 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ # Test binary, build with `go test -c` *.test +# Custom golangci-lint binary built via `golangci-lint custom` (config: .custom-gcl.yml) +custom-gcl + # Output of the go coverage tool, specifically when used with LiteIDE *.out coverage.html diff --git a/.golangci.yml b/.golangci.yml index fa3b002c..be766439 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -41,12 +41,14 @@ linters: - err113 # Disable error 113 check - exhaustive # Disable exhaustive check - exhaustruct # Disable exhaustruct check + - exhaustruct_v5 # Disable exhaustruct check - forcetypeassert # Disable force type assert check - funlen # Disable function length check - gochecknoglobals # Disable global variable check - gochecknoinits # Disable global variable check - gocognit # Disable cognitive complexity check - goconst # Disable constant check + - gomodguard # Superseded by gomodguard_v2, which default: all enables - intrange # Disable integer range check - ireturn # Disable return check - lll # Disable line length check @@ -154,7 +156,7 @@ linters: # HIGH PRIORITY enable_no_pkg_logger: false enable_enum_iota: true - enable_current_year: false + enable_current_year: true # MEDIUM PRIORITY enable_capital_comment: false enable_func_opts: true @@ -178,6 +180,17 @@ linters: - third_party$ # Exclude third party code - builtin$ # Exclude builtin code - examples$ # Exclude example code + # Rule-based exclusions + rules: + # gloas ePBS type definitions carry long dynssz size-expression struct + # tags and require ssz-index tags for correct SSZ generation (removing + # them changes the generated SSZ). gofmt aligns these tags past the + # 132-char limit and struct tags cannot be wrapped, so exempt only the + # line-length checks for the gloas type packages. + - path: (spec|api/v1)/gloas/ + text: "line is [0-9]+ characters" + linters: + - revive # Formatters: Code formatting tools formatters: enable: diff --git a/CHANGELOG.md b/CHANGELOG.md index b4371499..a4dca405 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +dev: + - add Gloas events: execution_payload, execution_payload_gossip, execution_payload_available, execution_payload_bid, payload_attestation_message, proposer_preferences and fast_confirmation + - add http.ValidateEventsOpts, the events options check shared by the http and multi clients + - Event.UnmarshalJSON decodes attester_slashing data as electra.AttesterSlashing rather than phase0.AttesterSlashing, matching the events stream + - multi Events() validates its options as http Events() does, and returns an error if no client is subscribed or awaiting a retry + - Events() no longer checks topics against SupportedEventTopics: removing a topic from the map no longer refuses subscriptions to it, and adding one no longer lets it through + - multi Events() waits one retry interval before retrying a client that failed to subscribe, rather than retrying at once; set the interval, 5s by default, with WithEventsRetryInterval + 0.29.0: - use dynssz library for SSZ handling diff --git a/api/epbsproposalopts.go b/api/epbsproposalopts.go new file mode 100644 index 00000000..8839cc2c --- /dev/null +++ b/api/epbsproposalopts.go @@ -0,0 +1,65 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// EPBSProposalOpts are the options for obtaining ePBS block proposals. +// +// Post-Gloas there is no blinded proposal: proposers commit to an execution +// payload bid rather than to a payload, so the axis that replaced blinded versus +// unblinded is IncludePayload — whether the execution payload envelope travels +// with the block or stays cached on the producing node. +type EPBSProposalOpts struct { + Common CommonOpts + + // Slot is the slot for which the proposal should be fetched. + Slot phase0.Slot + // RandaoReveal is the RANDAO reveal for the proposal. + RandaoReveal phase0.BLSSignature + // Graffiti is the graffiti to be included in the beacon block body. + Graffiti [32]byte + // SkipRandaoVerification is true if we do not want the server to verify our RANDAO reveal. + // If this is set then the RANDAO reveal should be passed as the point at infinity (0xc0…00) + SkipRandaoVerification bool + // BuilderConfig supplies the required P2P and direct-builder bid policy. + BuilderConfig *gloas.BuilderConfig + // IncludePayload selects whether a self-built proposal carries its execution + // payload envelope and blobs. + // + // True asks a self-building node for the stateless form: everything needed to + // publish travels in the response, so any beacon node can publish the block. + // This is what multiple-beacon-node, distributed-validator and failover setups + // require. It is a request rather than a guarantee: a block built on an + // external builder's bid comes back with + // VersionedEPBSProposal.ExecutionPayloadIncluded false whatever was asked for, + // because the node does not hold the builder's payload, so those setups must + // read that field rather than assume this one describes what arrived. + // + // False is stateful: the producing node caches the envelope and blobs, so the + // block must be published via that same node, and the envelope retrieved from + // it with ExecutionPayloadEnvelope() — but only when the node self-built the + // block. An external builder's bid comes back excluded regardless of what was + // asked for, and the response alone does not say which case occurred; that is + // operational context the caller must already know. + // + // The spec marks this parameter required with no default, and the two choices + // carry materially different operational constraints, so it is a pointer: + // there is no safe value to assume on the caller's behalf, and leaving it + // unset is rejected rather than silently resolved to the constraining mode. + IncludePayload *bool +} diff --git a/api/eventsopts.go b/api/eventsopts.go index 8814ed58..d1ddd103 100644 --- a/api/eventsopts.go +++ b/api/eventsopts.go @@ -1,4 +1,4 @@ -// Copyright © 2025 Attestant Limited. +// Copyright © 2025 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -21,6 +21,7 @@ import ( "github.com/attestantio/go-eth2-client/spec/altair" "github.com/attestantio/go-eth2-client/spec/capella" "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" ) @@ -53,12 +54,30 @@ type EventsOpts struct { ContributionAndProofHandler ContributionAndProofEventHandlerFunc // DataColumnSidecarHandler is a handler for the data_column_sidecar event. DataColumnSidecarHandler DataColumnSidecarEventHandlerFunc + // ExecutionPayloadHandler is a handler for the execution_payload event + // (SignedExecutionPayloadEnvelope successfully imported on the fork-choice). + ExecutionPayloadHandler ExecutionPayloadEventHandlerFunc + // ExecutionPayloadAvailableHandler is a handler for the execution_payload_available event. + ExecutionPayloadAvailableHandler ExecutionPayloadAvailableEventHandlerFunc + // ExecutionPayloadBidHandler is a handler for the execution_payload_bid event. + ExecutionPayloadBidHandler ExecutionPayloadBidEventHandlerFunc + // ExecutionPayloadGossipHandler is a handler for the execution_payload_gossip event + // (SignedExecutionPayloadEnvelope passed gossip validation). + ExecutionPayloadGossipHandler ExecutionPayloadGossipEventHandlerFunc + // FastConfirmationHandler is a handler for the fast_confirmation event. + FastConfirmationHandler FastConfirmationEventHandlerFunc // FinalizedCheckpointHandler is a handler for the finalized_checkpoint event. FinalizedCheckpointHandler FinalizedCheckpointEventHandlerFunc // HeadHandler is a handler for the head event. HeadHandler HeadEventHandlerFunc + // HeadV2Handler is a handler for the head_v2 event. + HeadV2Handler HeadV2EventHandlerFunc + // PayloadAttestationMessageHandler is a handler for the payload_attestation_message event. + PayloadAttestationMessageHandler PayloadAttestationMessageEventHandlerFunc // PayloadAttributesHandler is a handler for the payload_attributes event. PayloadAttributesHandler PayloadAttributesEventHandlerFunc + // ProposerPreferencesHandler is a handler for the proposer_preferences event. + ProposerPreferencesHandler ProposerPreferencesEventHandlerFunc // ProposerSlashingHandler is a handler for the proposer_slashing event. ProposerSlashingHandler ProposerSlashingEventHandlerFunc // SingleAttestationHandler is a handler for the single_attestation event. @@ -100,6 +119,9 @@ type FinalizedCheckpointEventHandlerFunc func(context.Context, *apiv1.FinalizedC // HeadEventHandlerFunc is the handler for head events. type HeadEventHandlerFunc func(context.Context, *apiv1.HeadEvent) +// HeadV2EventHandlerFunc is the handler for head_v2 events. +type HeadV2EventHandlerFunc func(context.Context, *apiv1.HeadEventV2) + // PayloadAttributesEventHandlerFunc is the handler for payload_attributes events. type PayloadAttributesEventHandlerFunc func(context.Context, *apiv1.PayloadAttributesEvent) @@ -114,3 +136,24 @@ type VoluntaryExitEventHandlerFunc func(context.Context, *phase0.SignedVoluntary // DataColumnSidecarEventHandlerFunc is the handler for data_column_sidecar events. type DataColumnSidecarEventHandlerFunc func(context.Context, *apiv1.DataColumnSidecarEvent) + +// ExecutionPayloadEventHandlerFunc is the handler for execution_payload events. +type ExecutionPayloadEventHandlerFunc func(context.Context, *apiv1.ExecutionPayloadEvent) + +// ExecutionPayloadAvailableEventHandlerFunc is the handler for execution_payload_available events. +type ExecutionPayloadAvailableEventHandlerFunc func(context.Context, *apiv1.ExecutionPayloadAvailableEvent) + +// ExecutionPayloadBidEventHandlerFunc is the handler for execution_payload_bid events. +type ExecutionPayloadBidEventHandlerFunc func(context.Context, *gloas.SignedExecutionPayloadBid) + +// ExecutionPayloadGossipEventHandlerFunc is the handler for execution_payload_gossip events. +type ExecutionPayloadGossipEventHandlerFunc func(context.Context, *apiv1.ExecutionPayloadGossipEvent) + +// FastConfirmationEventHandlerFunc is the handler for fast_confirmation events. +type FastConfirmationEventHandlerFunc func(context.Context, *apiv1.FastConfirmationEvent) + +// PayloadAttestationMessageEventHandlerFunc is the handler for payload_attestation_message events. +type PayloadAttestationMessageEventHandlerFunc func(context.Context, *gloas.PayloadAttestationMessage) + +// ProposerPreferencesEventHandlerFunc is the handler for proposer_preferences events. +type ProposerPreferencesEventHandlerFunc func(context.Context, *gloas.SignedProposerPreferences) diff --git a/api/executionpayloadenvelopeopts.go b/api/executionpayloadenvelopeopts.go new file mode 100644 index 00000000..56bd01c9 --- /dev/null +++ b/api/executionpayloadenvelopeopts.go @@ -0,0 +1,40 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// ExecutionPayloadEnvelopeOpts are the options for obtaining the execution +// payload envelope a node cached while producing a block. +// +// This is the other half of asking for a proposal with the payload excluded: the +// node kept the envelope, and this is how the caller retrieves it to sign and +// publish. +type ExecutionPayloadEnvelopeOpts struct { + Common CommonOpts + + // Slot is the slot for which the envelope was built. + Slot phase0.Slot + // BeaconBlockRoot is the root of the beacon block the envelope commits to. + // + // Passing it makes the lookup re-org resistant: if a re-org happens between + // producing the block and retrieving the envelope, the caller gets nothing + // rather than an envelope committed to a block that is no longer on the + // chain. The node is expected to check the cached envelope against it, and + // the returned envelope's own root is checked against it again here, so a + // node that does not is caught rather than trusted. + BeaconBlockRoot phase0.Root +} diff --git a/api/payloadattestationdataopts.go b/api/payloadattestationdataopts.go new file mode 100644 index 00000000..bccefa51 --- /dev/null +++ b/api/payloadattestationdataopts.go @@ -0,0 +1,24 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import "github.com/attestantio/go-eth2-client/spec/phase0" + +// PayloadAttestationDataOpts are the options for obtaining payload attestation data. +type PayloadAttestationDataOpts struct { + Common CommonOpts + + // Slot is the slot for which the data is obtained. + Slot phase0.Slot +} diff --git a/api/payloadattestationpoolopts.go b/api/payloadattestationpoolopts.go new file mode 100644 index 00000000..6c0f5a61 --- /dev/null +++ b/api/payloadattestationpoolopts.go @@ -0,0 +1,26 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import "github.com/attestantio/go-eth2-client/spec/phase0" + +// PayloadAttestationPoolOpts are the options for obtaining the payload attestation pool. +type PayloadAttestationPoolOpts struct { + Common CommonOpts + + // Slot is the slot for which the data is obtained. If not present then + // data for all slots will be obtained. Note that some beacon nodes + // require a slot and will reject a request without one. + Slot *phase0.Slot +} diff --git a/api/ptcdutiesopts.go b/api/ptcdutiesopts.go new file mode 100644 index 00000000..f118e30e --- /dev/null +++ b/api/ptcdutiesopts.go @@ -0,0 +1,26 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import "github.com/attestantio/go-eth2-client/spec/phase0" + +// PTCDutiesOpts are the options for obtaining payload timeliness committee duties. +type PTCDutiesOpts struct { + Common CommonOpts + + // Epoch is the epoch for which the data is obtained. + Epoch phase0.Epoch + // Indices is a list of validators for which to obtain the duties. + Indices []phase0.ValidatorIndex +} diff --git a/api/signedexecutionpayloadenvelopeopts.go b/api/signedexecutionpayloadenvelopeopts.go new file mode 100644 index 00000000..271918ce --- /dev/null +++ b/api/signedexecutionpayloadenvelopeopts.go @@ -0,0 +1,22 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +// SignedExecutionPayloadEnvelopeOpts are the options for obtaining signed execution payload envelopes. +type SignedExecutionPayloadEnvelopeOpts struct { + Common CommonOpts + + // Block is the ID of the block which the data is obtained. + Block string +} diff --git a/api/submitexecutionpayloadbidopts.go b/api/submitexecutionpayloadbidopts.go new file mode 100644 index 00000000..3b7ff3e3 --- /dev/null +++ b/api/submitexecutionpayloadbidopts.go @@ -0,0 +1,28 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "github.com/attestantio/go-eth2-client/spec" +) + +// SubmitExecutionPayloadBidOpts are the options for submitting execution +// payload bids. +type SubmitExecutionPayloadBidOpts struct { + Common CommonOpts + + // SignedExecutionPayloadBid is the signed bid to publish. Its Version + // selects the wire schema and the consensus version header. + SignedExecutionPayloadBid *spec.VersionedSignedExecutionPayloadBid +} diff --git a/api/submitexecutionpayloadenvelopeopts.go b/api/submitexecutionpayloadenvelopeopts.go new file mode 100644 index 00000000..a6119cc0 --- /dev/null +++ b/api/submitexecutionpayloadenvelopeopts.go @@ -0,0 +1,45 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + apiv2 "github.com/attestantio/go-eth2-client/api/v2" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/deneb" +) + +// SubmitExecutionPayloadEnvelopeOpts are the options for submitting execution +// payload envelopes. The submission always uses the stateless request form +// (SignedExecutionPayloadEnvelopeContents): the signed envelope plus the +// blobs and KZG cell proofs the beacon node needs to build data column +// sidecars. The stateful/blinded form only works when the beacon node cached +// the full envelope from its own block production, so it is not offered here. +type SubmitExecutionPayloadEnvelopeOpts struct { + Common CommonOpts + + // SignedExecutionPayloadEnvelope is the signed envelope to publish. + // Its Version selects the wire schema and the consensus version header. + SignedExecutionPayloadEnvelope *spec.VersionedSignedExecutionPayloadEnvelope + + // KZGProofs are the cell KZG proofs for the blobs committed to by the + // envelope's payload. + KZGProofs []deneb.KZGProof + + // Blobs are the blobs committed to by the envelope's payload. + Blobs []deneb.Blob + + // BroadcastValidation is the validation required of the consensus node + // before broadcasting the envelope. + BroadcastValidation *apiv2.BroadcastValidation +} diff --git a/api/submitpayloadattestationmessagesopts.go b/api/submitpayloadattestationmessagesopts.go new file mode 100644 index 00000000..59cfa922 --- /dev/null +++ b/api/submitpayloadattestationmessagesopts.go @@ -0,0 +1,26 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import "github.com/attestantio/go-eth2-client/spec" + +// SubmitPayloadAttestationMessagesOpts are the options for submitting payload +// attestation messages. +type SubmitPayloadAttestationMessagesOpts struct { + Common CommonOpts + + // Messages are the payload attestation messages to submit. They must all + // be of the same version. + Messages []*spec.VersionedPayloadAttestationMessage +} diff --git a/api/submitproposalopts.go b/api/submitproposalopts.go index fafe1984..efee56cd 100644 --- a/api/submitproposalopts.go +++ b/api/submitproposalopts.go @@ -1,4 +1,4 @@ -// Copyright © 2024 Attestant Limited. +// Copyright © 2024 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -24,4 +24,7 @@ type SubmitProposalOpts struct { // BroadcastValidation is the validation required of the consensus node before broadcasting the proposal. BroadcastValidation *apiv2.BroadcastValidation + + // BuilderURL is the optional direct-builder route returned by block production. + BuilderURL string } diff --git a/api/submitproposerpreferencesopts.go b/api/submitproposerpreferencesopts.go new file mode 100644 index 00000000..8d41fd76 --- /dev/null +++ b/api/submitproposerpreferencesopts.go @@ -0,0 +1,29 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "github.com/attestantio/go-eth2-client/spec/gloas" +) + +// SubmitProposerPreferencesOpts are the options for submitting proposer +// preferences. +type SubmitProposerPreferencesOpts struct { + Common CommonOpts + + // Preferences are the signed proposer preferences to submit. Proposer + // preferences exist only from Gloas onwards, so they are unversioned. An + // empty list is a valid submission. + Preferences []*gloas.SignedProposerPreferences +} diff --git a/api/v1/event.go b/api/v1/event.go index cb271b0d..d82f458a 100644 --- a/api/v1/event.go +++ b/api/v1/event.go @@ -1,4 +1,4 @@ -// Copyright © 2020 - 2025 Attestant Limited. +// Copyright © 2020 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -18,10 +18,6 @@ import ( "fmt" "github.com/attestantio/go-eth2-client/spec" - "github.com/attestantio/go-eth2-client/spec/altair" - "github.com/attestantio/go-eth2-client/spec/capella" - "github.com/attestantio/go-eth2-client/spec/electra" - "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/pkg/errors" ) @@ -33,25 +29,6 @@ type Event struct { Data any } -// SupportedEventTopics is a map of supported event topics. -var SupportedEventTopics = map[string]bool{ - "attestation": true, - "attester_slashing": true, - "blob_sidecar": true, - "block": true, - "block_gossip": true, - "bls_to_execution_change": true, - "chain_reorg": true, - "contribution_and_proof": true, - "data_column_sidecar": true, - "finalized_checkpoint": true, - "head": true, - "payload_attributes": true, - "proposer_slashing": true, - "single_attestation": true, - "voluntary_exit": true, -} - // eventJSON is the spec representation of the struct. type eventJSON struct { Topic string `json:"topic"` @@ -71,6 +48,13 @@ func (e *Event) MarshalJSON() ([]byte, error) { return nil, errors.Wrap(err, "failed to unmarshal data") } + if topic, exists := eventTopicsByName[e.Topic]; exists && topic.Version() != spec.DataVersionUnknown { + unmarshalled = map[string]any{ + "version": topic.Version().String(), + "data": unmarshalled, + } + } + return json.Marshal(&eventJSON{ Topic: e.Topic, Data: unmarshalled, @@ -96,38 +80,8 @@ func (e *Event) UnmarshalJSON(input []byte) error { return errors.New("data missing") } - switch eventJSON.Topic { - case "attestation": - e.Data = &spec.VersionedAttestation{} - case "attester_slashing": - e.Data = &phase0.AttesterSlashing{} - case "blob_sidecar": - e.Data = &BlobSidecarEvent{} - case "block": - e.Data = &BlockEvent{} - case "block_gossip": - e.Data = &BlockGossipEvent{} - case "bls_to_execution_change": - e.Data = &capella.SignedBLSToExecutionChange{} - case "chain_reorg": - e.Data = &ChainReorgEvent{} - case "contribution_and_proof": - e.Data = &altair.SignedContributionAndProof{} - case "data_column_sidecar": - e.Data = &DataColumnSidecarEvent{} - case "finalized_checkpoint": - e.Data = &FinalizedCheckpointEvent{} - case "head": - e.Data = &HeadEvent{} - case "payload_attributes": - e.Data = &PayloadAttributesEvent{} - case "proposer_slashing": - e.Data = &phase0.ProposerSlashing{} - case "single_attestation": - e.Data = &electra.SingleAttestation{} - case "voluntary_exit": - e.Data = &phase0.SignedVoluntaryExit{} - default: + topic, exists := eventTopicsByName[eventJSON.Topic] + if !exists { return fmt.Errorf("unsupported event topic %s", eventJSON.Topic) } @@ -136,12 +90,11 @@ func (e *Event) UnmarshalJSON(input []byte) error { return errors.Wrap(err, "failed to marshal data") } - if err := json.Unmarshal(data, &e.Data); err != nil { - return errors.New("data missing") + e.Data, err = topic.DecodeData(data) + if err != nil { + return errors.Wrap(err, "invalid event data") } - e.Data = eventJSON.Data - return nil } diff --git a/api/v1/event_test.go b/api/v1/event_test.go index c225e1e4..8708e6a9 100644 --- a/api/v1/event_test.go +++ b/api/v1/event_test.go @@ -1,4 +1,4 @@ -// Copyright © 2020, 2025 Attestant Limited. +// Copyright © 2020 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -18,15 +18,17 @@ import ( "testing" api "github.com/attestantio/go-eth2-client/api/v1" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/stretchr/testify/assert" require "github.com/stretchr/testify/require" ) func TestEvent(t *testing.T) { tests := []struct { - name string - input []byte - err string + name string + input []byte + err string + normalizes bool }{ { name: "Empty", @@ -58,20 +60,23 @@ func TestEvent(t *testing.T) { err: "data missing", }, { - name: "GoodPhase0Attestation", - input: []byte(`{"topic":"attestation","data":{"aggregation_bits":"0x010203","data":{"beacon_block_root":"0x000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","index":"1","slot":"100","source":{"epoch":"1","root":"0x202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f"},"target":{"epoch":"2","root":"0x404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f"}},"signature":"0x606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf"}}`), + name: "GoodPhase0Attestation", + normalizes: true, + input: []byte(`{"topic":"attestation","data":{"aggregation_bits":"0x010203","data":{"beacon_block_root":"0x000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","index":"1","slot":"100","source":{"epoch":"1","root":"0x202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f"},"target":{"epoch":"2","root":"0x404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f"}},"signature":"0x606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf"}}`), }, { - name: "GoodElectraAttestation", - input: []byte(`{"topic":"attestation","data":{"aggregation_bits":"0xf77ffffffdfbfffffffdbfffffe5fff71f","data":{"slot":"98106","index":"0","beacon_block_root":"0xf8df02ed08b9adcb88a22cb22cd2a6074b184128ae6a240e3172109fdfacaa7b","source":{"epoch":"3064","root":"0x19ffd95e92753046cf63b4298f859e1fb1271a160ef0139ea1eb9f06d45d3b93"},"target":{"epoch":"3065","root":"0xffac9506e2262991ed19b1804bec9f7a1c4c4e61eb37444e6c8826bb362716d6"}},"signature":"0xb4f12c02e0f1a5db07999ceb8c1a4ccd41a3cb46ca15abe1c145337f1287360c49d5780fb7b44dfebeb96f3898824605008c9d458bdd2413358da3edf1b181d4e98edfe90d5fd016ac8f6aebc6646b2da83ab98722a7b4ee5264506bf6ae08e9","committee_bits":"0x0040000000000000"}}`), + name: "GoodElectraAttestation", + normalizes: true, + input: []byte(`{"topic":"attestation","data":{"aggregation_bits":"0xf77ffffffdfbfffffffdbfffffe5fff71f","data":{"slot":"98106","index":"0","beacon_block_root":"0xf8df02ed08b9adcb88a22cb22cd2a6074b184128ae6a240e3172109fdfacaa7b","source":{"epoch":"3064","root":"0x19ffd95e92753046cf63b4298f859e1fb1271a160ef0139ea1eb9f06d45d3b93"},"target":{"epoch":"3065","root":"0xffac9506e2262991ed19b1804bec9f7a1c4c4e61eb37444e6c8826bb362716d6"}},"signature":"0xb4f12c02e0f1a5db07999ceb8c1a4ccd41a3cb46ca15abe1c145337f1287360c49d5780fb7b44dfebeb96f3898824605008c9d458bdd2413358da3edf1b181d4e98edfe90d5fd016ac8f6aebc6646b2da83ab98722a7b4ee5264506bf6ae08e9","committee_bits":"0x0040000000000000"}}`), }, { name: "GoodSingleAttestation", input: []byte(`{"topic":"single_attestation","data":{"committee_index":"11","attester_index":"23784","data":{"slot":"98122","index":"0","beacon_block_root":"0x497033a5af8e64b748c554524e1e269da3c3af71515cf31f2d7bf9bab256a03c","source":{"epoch":"3065","root":"0xffac9506e2262991ed19b1804bec9f7a1c4c4e61eb37444e6c8826bb362716d6"},"target":{"epoch":"3066","root":"0x5982836668f92d786ef82f8841011a0888be5583bd09ab73760563855789d49b"}},"signature":"0xa448d3dc9520d4cb8e70094108169893a94ef7d074151ba333169ea92e2586da6f6efa622722743725c8012707f78efa02d99dd8ee094fa5bf5ca2b24066096ab0bc7671d4037521cbe69411871dd614e60d2c0eed8d2a0b2e4b77602b39d50e"}}`), }, { - name: "GoodBlock", - input: []byte(`{"topic":"block","data":{"block":"0xbe36e714a6114cf718e35dafc4ac530ce8f01e4a9a360e78098eb129772dcc39","slot":"1"}}`), + name: "GoodBlock", + normalizes: true, + input: []byte(`{"topic":"block","data":{"block":"0xbe36e714a6114cf718e35dafc4ac530ce8f01e4a9a360e78098eb129772dcc39","slot":"1"}}`), }, { name: "GoodChainReorg", @@ -85,6 +90,10 @@ func TestEvent(t *testing.T) { name: "GoodHead", input: []byte(`{"topic":"head","data":{"block":"0xbe36e714a6114cf718e35dafc4ac530ce8f01e4a9a360e78098eb129772dcc39","current_duty_dependent_root":"0x92c6b763f610d5941d2041906007bf9449d37772aacf0483a76275ac27c096b4","epoch_transition":false,"previous_duty_dependent_root":"0xa692c095bbca3eeaf99eeabada78874c028c02b176ccf691f3e8fa075d67f5c6","slot":"231192","state":"0x61099b2c1dee0104c93ce0e14e5f5fc4b6faceff4cb863278d055bdfb73b7dc7"}}`), }, + { + name: "GoodExecutionPayloadAvailable", + input: []byte(`{"topic":"execution_payload_available","data":{"slot":"10","block_root":"0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf"}}`), + }, { name: "GoodVoluntaryExit", input: []byte(`{"topic":"voluntary_exit","data":{"message":{"epoch":"1","validator_index":"2"},"signature":"0x000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f"}}`), @@ -105,9 +114,81 @@ func TestEvent(t *testing.T) { require.NoError(t, err) rt, err := json.Marshal(&res) require.NoError(t, err) - assert.JSONEq(t, string(test.input), string(rt)) + if !test.normalizes { + assert.JSONEq(t, string(test.input), string(rt)) + } assert.JSONEq(t, string(rt), res.String()) } }) } } + +func TestEventVersionedGloasRoundTrip(t *testing.T) { + tests := []struct { + name string + topic string + data any + }{ + {name: "Bid", topic: "execution_payload_bid", data: &gloas.SignedExecutionPayloadBid{Message: &gloas.ExecutionPayloadBid{}}}, + {name: "Attestation", topic: "payload_attestation_message", data: &gloas.PayloadAttestationMessage{Data: &gloas.PayloadAttestationData{}}}, + {name: "Preferences", topic: "proposer_preferences", data: &gloas.SignedProposerPreferences{Message: &gloas.ProposerPreferences{}}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + inner, err := json.Marshal(test.data) + require.NoError(t, err) + input, err := json.Marshal(map[string]any{"topic": test.topic, "data": map[string]any{"version": "gloas", "data": json.RawMessage(inner)}}) + require.NoError(t, err) + + var event api.Event + require.NoError(t, json.Unmarshal(input, &event)) + require.IsType(t, test.data, event.Data) + output, err := json.Marshal(&event) + require.NoError(t, err) + require.JSONEq(t, string(input), string(output)) + + wrongFork, err := json.Marshal(map[string]any{"topic": test.topic, "data": map[string]any{"version": "fulu", "data": json.RawMessage(inner)}}) + require.NoError(t, err) + require.ErrorContains(t, json.Unmarshal(wrongFork, &event), "unsupported version") + }) + } +} + +func TestEventUnmarshalHeadV2Typed(t *testing.T) { + input := []byte(`{"topic":"head_v2","data":{"version":"gloas","data":{"slot":"10","block":"0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf","state":"0x600e852a08c1200654ddf11025f1ceacb3c2e74bdd5c630cde0838b2591b69f9","payload_status":"empty","epoch_transition":false,"current_epoch_dependent_root":"0x5e0043f107cb57913498fbf2f99ff55e730bf1e151f02f221e977c91a90a0e91","next_epoch_dependent_root":"0x5e0043f107cb57913498fbf2f99ff55e730bf1e151f02f221e977c91a90a0e91","execution_optimistic":false}}}`) + + var event api.Event + require.NoError(t, json.Unmarshal(input, &event)) + require.IsType(t, &api.HeadEventV2{}, event.Data) + require.Equal(t, "empty", event.Data.(*api.HeadEventV2).PayloadStatus) + encoded, err := json.Marshal(&event) + require.NoError(t, err) + require.JSONEq(t, string(input), string(encoded)) +} + +// TestSupportedEventTopicsGloas confirms SupportedEventTopics lists the event +// topics introduced by the Gloas (ePBS) fork. It is built from the topic list +// the clients subscribe and dispatch from, so a topic missing from it when the +// package loads is missing from that list too. Callers can edit the map +// afterwards, which changes nothing the clients do. +// +// The http package's dispatch test also pins the list, but this keeps a guard +// beside the list itself that does not depend on the http client. +func TestSupportedEventTopicsGloas(t *testing.T) { + topics := []string{ + "execution_payload", + "execution_payload_available", + "execution_payload_bid", + "execution_payload_gossip", + "fast_confirmation", + "payload_attestation_message", + "proposer_preferences", + } + + for _, topic := range topics { + t.Run(topic, func(t *testing.T) { + require.True(t, api.SupportedEventTopics[topic], + "topic %s missing from SupportedEventTopics, so from the event topic list", topic) + }) + } +} diff --git a/api/v1/eventtopic.go b/api/v1/eventtopic.go new file mode 100644 index 00000000..18265abf --- /dev/null +++ b/api/v1/eventtopic.go @@ -0,0 +1,83 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package v1 + +import ( + "github.com/attestantio/go-eth2-client/internal/eventtopic" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// eventTopics is the single list of supported event topics, each with the type into which the +// data of its events decodes. SupportedEventTopics and Event.UnmarshalJSON are derived from it, +// and the HTTP and multi clients reach it through the eventtopic package, with which it is +// registered. The catalog is pinned to beacon-APIs commit ef98d512c03c8ca6b9d7cbdc45b9293ec2b24722: +// https://github.com/ethereum/beacon-APIs/blob/ef98d512c03c8ca6b9d7cbdc45b9293ec2b24722/apis/eventstream/index.yaml. +// blob_sidecar is accepted for compatibility but is not in that catalog. +var eventTopics = []eventtopic.Descriptor{ + eventtopic.New[spec.VersionedAttestation]("attestation"), + eventtopic.New[electra.AttesterSlashing]("attester_slashing"), + eventtopic.New[BlobSidecarEvent]("blob_sidecar"), + eventtopic.New[BlockEvent]("block"), + eventtopic.New[BlockGossipEvent]("block_gossip"), + eventtopic.New[capella.SignedBLSToExecutionChange]("bls_to_execution_change"), + eventtopic.New[ChainReorgEvent]("chain_reorg"), + eventtopic.New[altair.SignedContributionAndProof]("contribution_and_proof"), + eventtopic.New[DataColumnSidecarEvent]("data_column_sidecar"), + eventtopic.New[ExecutionPayloadEvent]("execution_payload"), + eventtopic.New[ExecutionPayloadAvailableEvent]("execution_payload_available"), + eventtopic.NewVersioned[gloas.SignedExecutionPayloadBid]("execution_payload_bid", spec.DataVersionGloas), + eventtopic.New[ExecutionPayloadGossipEvent]("execution_payload_gossip"), + eventtopic.New[FastConfirmationEvent]("fast_confirmation"), + eventtopic.New[FinalizedCheckpointEvent]("finalized_checkpoint"), + eventtopic.New[HeadEvent]("head"), + eventtopic.New[HeadEventV2]("head_v2"), + eventtopic.New[LightClientFinalityUpdateEvent]("light_client_finality_update"), + eventtopic.New[LightClientOptimisticUpdateEvent]("light_client_optimistic_update"), + eventtopic.NewVersioned[gloas.PayloadAttestationMessage]("payload_attestation_message", spec.DataVersionGloas), + eventtopic.New[PayloadAttributesEvent]("payload_attributes"), + eventtopic.NewVersioned[gloas.SignedProposerPreferences]("proposer_preferences", spec.DataVersionGloas), + eventtopic.New[phase0.ProposerSlashing]("proposer_slashing"), + eventtopic.New[electra.SingleAttestation]("single_attestation"), + eventtopic.New[phase0.SignedVoluntaryExit]("voluntary_exit"), +} + +// eventTopicsByName is eventTopics by name. +var eventTopicsByName = func() map[string]eventtopic.Descriptor { + byName := make(map[string]eventtopic.Descriptor, len(eventTopics)) + for _, topic := range eventTopics { + byName[topic.Name()] = topic + } + + return byName +}() + +// SupportedEventTopics is a map of supported event topics. It is informational and legacy: the +// clients validate Events() subscriptions against the topics themselves, so changing it has no effect. +var SupportedEventTopics = func() map[string]bool { + supported := make(map[string]bool, len(eventTopics)) + for _, topic := range eventTopics { + supported[topic.Name()] = true + } + + return supported +}() + +func init() { + eventtopic.Register(eventTopics...) +} diff --git a/api/v1/eventtopic_internal_test.go b/api/v1/eventtopic_internal_test.go new file mode 100644 index 00000000..87c18e1c --- /dev/null +++ b/api/v1/eventtopic_internal_test.go @@ -0,0 +1,120 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package v1 + +import ( + "encoding/json" + "maps" + "reflect" + "testing" + + "github.com/attestantio/go-eth2-client/internal/eventtopic" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// TestEventTopicDecodeTypes pins, for each topic, the type into which its data decodes, both by +// name, as Event.UnmarshalJSON finds it, and by type, as the HTTP and multi clients find it +// through the eventtopic registry, so that a topic given the wrong name or type fails here +// rather than handing handlers a different type than documented. +func TestEventTopicDecodeTypes(t *testing.T) { + tests := []struct { + name string + expected any + }{ + {name: "attestation", expected: &spec.VersionedAttestation{}}, + {name: "attester_slashing", expected: &electra.AttesterSlashing{}}, + {name: "blob_sidecar", expected: &BlobSidecarEvent{}}, + {name: "block", expected: &BlockEvent{}}, + {name: "block_gossip", expected: &BlockGossipEvent{}}, + {name: "bls_to_execution_change", expected: &capella.SignedBLSToExecutionChange{}}, + {name: "chain_reorg", expected: &ChainReorgEvent{}}, + {name: "contribution_and_proof", expected: &altair.SignedContributionAndProof{}}, + {name: "data_column_sidecar", expected: &DataColumnSidecarEvent{}}, + {name: "execution_payload", expected: &ExecutionPayloadEvent{}}, + {name: "execution_payload_available", expected: &ExecutionPayloadAvailableEvent{}}, + {name: "execution_payload_bid", expected: &gloas.SignedExecutionPayloadBid{}}, + {name: "execution_payload_gossip", expected: &ExecutionPayloadGossipEvent{}}, + {name: "fast_confirmation", expected: &FastConfirmationEvent{}}, + {name: "finalized_checkpoint", expected: &FinalizedCheckpointEvent{}}, + {name: "head", expected: &HeadEvent{}}, + {name: "head_v2", expected: &HeadEventV2{}}, + {name: "light_client_finality_update", expected: &LightClientFinalityUpdateEvent{}}, + {name: "light_client_optimistic_update", expected: &LightClientOptimisticUpdateEvent{}}, + {name: "payload_attestation_message", expected: &gloas.PayloadAttestationMessage{}}, + {name: "payload_attributes", expected: &PayloadAttributesEvent{}}, + {name: "proposer_preferences", expected: &gloas.SignedProposerPreferences{}}, + {name: "proposer_slashing", expected: &phase0.ProposerSlashing{}}, + {name: "single_attestation", expected: &electra.SingleAttestation{}}, + {name: "voluntary_exit", expected: &phase0.SignedVoluntaryExit{}}, + } + + names := make(map[string]bool, len(tests)) + for _, test := range tests { + require.NotContains(t, names, test.name) + names[test.name] = true + } + require.Equal(t, SupportedEventTopics, names) + require.Len(t, eventTopics, len(tests), "a topic is listed more than once") + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Contains(t, eventTopicsByName, test.name) + require.IsType(t, test.expected, eventTopicsByName[test.name].NewData()) + + registered, exists := eventtopic.LookupType(reflect.TypeOf(test.expected).Elem()) + require.True(t, exists, "no topic registered for the type") + require.Equal(t, test.name, registered.Name()) + }) + } +} + +// TestEventTopicsMatchCatalog pins the topic enum from beacon-APIs commit +// ef98d512c03c8ca6b9d7cbdc45b9293ec2b24722. +func TestEventTopicsMatchCatalog(t *testing.T) { + catalog := map[string]bool{ + "attestation": true, "attester_slashing": true, "block": true, + "block_gossip": true, "bls_to_execution_change": true, "chain_reorg": true, + "contribution_and_proof": true, "data_column_sidecar": true, + "execution_payload": true, "execution_payload_available": true, + "execution_payload_bid": true, "execution_payload_gossip": true, + "fast_confirmation": true, "finalized_checkpoint": true, + "head": true, "head_v2": true, + "light_client_finality_update": true, "light_client_optimistic_update": true, + "payload_attestation_message": true, "payload_attributes": true, + "proposer_preferences": true, "proposer_slashing": true, + "single_attestation": true, "voluntary_exit": true, + } + + registered := maps.Clone(SupportedEventTopics) + require.True(t, registered["blob_sidecar"], "legacy topic must remain supported") + delete(registered, "blob_sidecar") + require.Equal(t, catalog, registered) +} + +// TestEventTopicsRejectNullData confirms that the data type of every topic rejects null. Decode +// relies on this: a type without an UnmarshalJSON of its own would decode null into a zero value +// without error, handing handlers a value with none of its fields set. +func TestEventTopicsRejectNullData(t *testing.T) { + for _, topic := range eventTopics { + t.Run(topic.Name(), func(t *testing.T) { + require.Error(t, json.Unmarshal([]byte(`null`), topic.NewData())) + }) + } +} diff --git a/api/v1/executionpayloadavailableevent.go b/api/v1/executionpayloadavailableevent.go new file mode 100644 index 00000000..84ed88dd --- /dev/null +++ b/api/v1/executionpayloadavailableevent.go @@ -0,0 +1,79 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package v1 + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// ExecutionPayloadAvailableEvent is the data for the execution payload available event. +type ExecutionPayloadAvailableEvent struct { + BlockRoot phase0.Root + Slot phase0.Slot +} + +// executionPayloadAvailableEventJSON is the spec representation of the struct. +type executionPayloadAvailableEventJSON struct { + BlockRoot string `json:"block_root"` + Slot string `json:"slot"` +} + +// MarshalJSON implements json.Marshaler. +func (e *ExecutionPayloadAvailableEvent) MarshalJSON() ([]byte, error) { + return json.Marshal(&executionPayloadAvailableEventJSON{ + BlockRoot: fmt.Sprintf("%#x", e.BlockRoot), + Slot: fmt.Sprintf("%d", e.Slot), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (e *ExecutionPayloadAvailableEvent) UnmarshalJSON(input []byte) error { + var err error + + var executionPayloadAvailableEventJSON executionPayloadAvailableEventJSON + if err = json.Unmarshal(input, &executionPayloadAvailableEventJSON); err != nil { + return errors.Wrap(err, "invalid JSON") + } + if executionPayloadAvailableEventJSON.BlockRoot == "" { + return errors.New("block root missing") + } + if err := decodeFixedBytes(e.BlockRoot[:], executionPayloadAvailableEventJSON.BlockRoot, "block root"); err != nil { + return err + } + if executionPayloadAvailableEventJSON.Slot == "" { + return errors.New("slot missing") + } + slot, err := strconv.ParseUint(executionPayloadAvailableEventJSON.Slot, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value for slot") + } + e.Slot = phase0.Slot(slot) + + return nil +} + +// String returns a string version of the structure. +func (e *ExecutionPayloadAvailableEvent) String() string { + data, err := json.Marshal(e) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/api/v1/executionpayloadavailableevent_test.go b/api/v1/executionpayloadavailableevent_test.go new file mode 100644 index 00000000..ccfed0c5 --- /dev/null +++ b/api/v1/executionpayloadavailableevent_test.go @@ -0,0 +1,91 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package v1_test + +import ( + "encoding/json" + "testing" + + api "github.com/attestantio/go-eth2-client/api/v1" + "github.com/stretchr/testify/assert" + require "github.com/stretchr/testify/require" +) + +func TestExecutionPayloadAvailableEventJSON(t *testing.T) { + tests := []struct { + name string + input []byte + err string + }{ + { + name: "Empty", + err: "unexpected end of JSON input", + }, + { + name: "JSONBad", + input: []byte("[]"), + err: "invalid JSON: json: cannot unmarshal array into Go value of type v1.executionPayloadAvailableEventJSON", + }, + { + name: "BlockRootMissing", + input: []byte(`{"slot":"525277"}`), + err: "block root missing", + }, + { + name: "BlockRootInvalid", + input: []byte(`{"block_root":"invalid","slot":"525277"}`), + err: "invalid value for block root: encoding/hex: invalid byte: U+0069 'i'", + }, + { + name: "BlockRootShort", + input: []byte(`{"block_root":"0xe3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","slot":"525277"}`), + err: "incorrect length 31 for block root", + }, + { + name: "BlockRootLong", + input: []byte(`{"block_root":"0x9999e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","slot":"525277"}`), + err: "incorrect length 33 for block root", + }, + { + name: "SlotMissing", + input: []byte(`{"block_root":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028"}`), + err: "slot missing", + }, + { + name: "SlotInvalid", + input: []byte(`{"block_root":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","slot":"-1"}`), + err: "invalid value for slot: strconv.ParseUint: parsing \"-1\": invalid syntax", + }, + { + name: "Good", + input: []byte(`{"block_root":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","slot":"525277"}`), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var res api.ExecutionPayloadAvailableEvent + err := json.Unmarshal(test.input, &res) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + rt, err := json.Marshal(&res) + require.NoError(t, err) + assert.Equal(t, string(test.input), string(rt)) + assert.Equal(t, string(rt), res.String()) + } + }) + } +} diff --git a/api/v1/executionpayloadevent.go b/api/v1/executionpayloadevent.go new file mode 100644 index 00000000..4c85cc39 --- /dev/null +++ b/api/v1/executionpayloadevent.go @@ -0,0 +1,129 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package v1 + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// ExecutionPayloadEvent is the data for the `execution_payload` event, emitted +// when a revealed execution payload is imported into the fork-choice store. +type ExecutionPayloadEvent struct { + Slot phase0.Slot + BuilderIndex gloas.BuilderIndex + BlockHash phase0.Hash32 + BlockRoot phase0.Root + ExecutionOptimistic bool +} + +// executionPayloadEventJSON is the spec representation of the struct. +type executionPayloadEventJSON struct { + Slot string `json:"slot"` + BuilderIndex string `json:"builder_index"` + BlockHash string `json:"block_hash"` + BlockRoot string `json:"block_root"` + ExecutionOptimistic bool `json:"execution_optimistic"` +} + +// MarshalJSON implements json.Marshaler. +func (e *ExecutionPayloadEvent) MarshalJSON() ([]byte, error) { + return json.Marshal(&executionPayloadEventJSON{ + Slot: fmt.Sprintf("%d", e.Slot), + BuilderIndex: fmt.Sprintf("%d", e.BuilderIndex), + BlockHash: fmt.Sprintf("%#x", e.BlockHash), + BlockRoot: fmt.Sprintf("%#x", e.BlockRoot), + ExecutionOptimistic: e.ExecutionOptimistic, + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +// +// Unknown keys are ignored. Nimbus emits a fabricated zero state_root, which +// must not be modeled as an event field. +func (e *ExecutionPayloadEvent) UnmarshalJSON(input []byte) error { + var data executionPayloadEventJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if data.Slot == "" { + return errors.New("slot missing") + } + slot, err := strconv.ParseUint(data.Slot, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value for slot") + } + e.Slot = phase0.Slot(slot) + + if data.BlockRoot == "" { + return errors.New("block root missing") + } + if err := decodeFixedBytes(e.BlockRoot[:], data.BlockRoot, "block root"); err != nil { + return err + } + + if data.BuilderIndex == "" { + return errors.New("builder index missing") + } + builderIndex, err := strconv.ParseUint(data.BuilderIndex, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value for builder index") + } + e.BuilderIndex = gloas.BuilderIndex(builderIndex) + + if data.BlockHash == "" { + return errors.New("block hash missing") + } + if err := decodeFixedBytes(e.BlockHash[:], data.BlockHash, "block hash"); err != nil { + return err + } + + e.ExecutionOptimistic = data.ExecutionOptimistic + + return nil +} + +// String returns a string version of the structure. +func (e *ExecutionPayloadEvent) String() string { + data, err := json.Marshal(e) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} + +// decodeFixedBytes hex-decodes a value, with or without a 0x prefix, into dst, +// requiring exactly len(dst) bytes. The length comes from dst rather than from a +// parameter so the two cannot disagree. +func decodeFixedBytes(dst []byte, value string, name string) error { + decoded, err := hex.DecodeString(strings.TrimPrefix(value, "0x")) + if err != nil { + return errors.Wrapf(err, "invalid value for %s", name) + } + if len(decoded) != len(dst) { + return fmt.Errorf("incorrect length %d for %s", len(decoded), name) + } + copy(dst, decoded) + + return nil +} diff --git a/api/v1/executionpayloadevent_test.go b/api/v1/executionpayloadevent_test.go new file mode 100644 index 00000000..171017f4 --- /dev/null +++ b/api/v1/executionpayloadevent_test.go @@ -0,0 +1,175 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package v1_test + +import ( + "encoding/json" + "math" + "reflect" + "testing" + + "github.com/attestantio/go-eth2-client/spec/gloas" + + api "github.com/attestantio/go-eth2-client/api/v1" + "github.com/stretchr/testify/assert" + require "github.com/stretchr/testify/require" +) + +func TestExecutionPayloadEventJSON(t *testing.T) { + tests := []struct { + name string + input []byte + err string + }{ + { + name: "Empty", + err: "unexpected end of JSON input", + }, + { + name: "JSONBad", + input: []byte("[]"), + err: "invalid JSON: json: cannot unmarshal array into Go value of type v1.executionPayloadEventJSON", + }, + { + name: "SlotMissing", + input: []byte(`{"builder_index":"12","block_hash":"0x1c3981b7439cd2dc53dca1a99122e1cacb36a13796d426d4c8a03ba745cb0c8b","block_root":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","execution_optimistic":false}`), + err: "slot missing", + }, + { + name: "SlotInvalid", + input: []byte(`{"slot":"-1","block_root":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028"}`), + err: "invalid value for slot: strconv.ParseUint: parsing \"-1\": invalid syntax", + }, + { + name: "BlockRootMissing", + input: []byte(`{"slot":"4095940"}`), + err: "block root missing", + }, + { + name: "BlockRootInvalid", + input: []byte(`{"slot":"4095940","block_root":"invalid"}`), + err: "invalid value for block root: encoding/hex: invalid byte: U+0069 'i'", + }, + { + name: "BlockRootShort", + input: []byte(`{"slot":"4095940","block_root":"0xe3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028"}`), + err: "incorrect length 31 for block root", + }, + { + name: "BlockRootLong", + input: []byte(`{"slot":"4095940","block_root":"0x9999e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028"}`), + err: "incorrect length 33 for block root", + }, + { + name: "BuilderIndexInvalid", + input: []byte(`{"slot":"4095940","block_root":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","builder_index":"-1"}`), + err: "invalid value for builder index: strconv.ParseUint: parsing \"-1\": invalid syntax", + }, + { + name: "BlockHashInvalid", + input: []byte(`{"slot":"4095940","block_root":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","builder_index":"12","block_hash":"invalid"}`), + err: "invalid value for block hash: encoding/hex: invalid byte: U+0069 'i'", + }, + { + name: "BlockHashShort", + input: []byte(`{"slot":"4095940","block_root":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","builder_index":"12","block_hash":"0xe3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028"}`), + err: "incorrect length 31 for block hash", + }, + { + name: "Good", + input: []byte(`{"slot":"4095940","builder_index":"12","block_hash":"0x1c3981b7439cd2dc53dca1a99122e1cacb36a13796d426d4c8a03ba745cb0c8b","block_root":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","execution_optimistic":true}`), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var res api.ExecutionPayloadEvent + err := json.Unmarshal(test.input, &res) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + rt, err := json.Marshal(&res) + require.NoError(t, err) + assert.Equal(t, string(test.input), string(rt)) + assert.Equal(t, string(rt), res.String()) + } + }) + } + +} + +func TestExecutionPayloadEventRequiredFields(t *testing.T) { + tests := []struct { + name string + input string + err string + }{ + {name: "BuilderIndexMissing", input: `{"slot":"10","block_hash":"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef","block_root":"0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf"}`, err: "builder index missing"}, + {name: "BlockHashMissing", input: `{"slot":"10","builder_index":"42","block_root":"0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf"}`, err: "block hash missing"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var event api.ExecutionPayloadEvent + require.EqualError(t, json.Unmarshal([]byte(test.input), &event), test.err) + }) + } +} + +func TestExecutionPayloadGossipEventRequiredFields(t *testing.T) { + tests := []struct { + name string + input string + err string + }{ + {name: "BuilderIndexMissing", input: `{"slot":"10","block_hash":"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef","block_root":"0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf"}`, err: "builder index missing"}, + {name: "BlockHashMissing", input: `{"slot":"10","builder_index":"42","block_root":"0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf"}`, err: "block hash missing"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var event api.ExecutionPayloadGossipEvent + require.EqualError(t, json.Unmarshal([]byte(test.input), &event), test.err) + }) + } +} + +func TestExecutionPayloadEventBuilderIndexType(t *testing.T) { + for _, event := range []any{api.ExecutionPayloadEvent{}, api.ExecutionPayloadGossipEvent{}} { + field, ok := reflect.TypeOf(event).FieldByName("BuilderIndex") + require.True(t, ok) + require.Equal(t, reflect.TypeOf(gloas.BuilderIndex(0)), field.Type) + } +} + +func TestExecutionPayloadEventSelfBuild(t *testing.T) { + const input = `{"slot":"16342","builder_index":"18446744073709551615","block_hash":"0x3e901234567890abcdef1234567890abcdef1234567890abcdef1234567890ab","block_root":"0x9ac61234567890abcdef1234567890abcdef1234567890abcdef1234567890ab","execution_optimistic":false}` + for _, event := range []json.Unmarshaler{&api.ExecutionPayloadEvent{}, &api.ExecutionPayloadGossipEvent{}} { + require.NoError(t, event.UnmarshalJSON([]byte(input))) + field := reflect.ValueOf(event).Elem().FieldByName("BuilderIndex") + require.Equal(t, gloas.BuilderIndexSelfBuild, field.Interface()) + require.Equal(t, uint64(math.MaxUint64), uint64(gloas.BuilderIndexSelfBuild)) + } +} + +func TestExecutionPayloadEventsIgnoreNimbusStateRoot(t *testing.T) { + const input = `{"slot":"10","builder_index":"42","block_hash":"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef","block_root":"0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf","state_root":"0x0000000000000000000000000000000000000000000000000000000000000000"}` + for _, event := range []json.Unmarshaler{&api.ExecutionPayloadEvent{}, &api.ExecutionPayloadGossipEvent{}} { + require.NoError(t, event.UnmarshalJSON([]byte(input))) + _, exists := reflect.TypeOf(event).Elem().FieldByName("StateRoot") + require.False(t, exists) + encoded, err := json.Marshal(event) + require.NoError(t, err) + require.NotContains(t, string(encoded), "state_root") + } +} diff --git a/api/v1/executionpayloadgossipevent.go b/api/v1/executionpayloadgossipevent.go new file mode 100644 index 00000000..2b1c57d4 --- /dev/null +++ b/api/v1/executionpayloadgossipevent.go @@ -0,0 +1,69 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package v1 + +import ( + "encoding/json" + "fmt" + + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// ExecutionPayloadGossipEvent is a payload that passed gossip validation, not fork-choice import. +type ExecutionPayloadGossipEvent struct { + Slot phase0.Slot + BuilderIndex gloas.BuilderIndex + BlockHash phase0.Hash32 + BlockRoot phase0.Root +} + +// MarshalJSON implements json.Marshaler. +func (e *ExecutionPayloadGossipEvent) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + Slot string `json:"slot"` + BuilderIndex string `json:"builder_index"` + BlockHash string `json:"block_hash"` + BlockRoot string `json:"block_root"` + }{ + Slot: fmt.Sprintf("%d", e.Slot), + BuilderIndex: fmt.Sprintf("%d", e.BuilderIndex), + BlockHash: fmt.Sprintf("%#x", e.BlockHash), + BlockRoot: fmt.Sprintf("%#x", e.BlockRoot), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (e *ExecutionPayloadGossipEvent) UnmarshalJSON(input []byte) error { + var imported ExecutionPayloadEvent + if err := json.Unmarshal(input, &imported); err != nil { + return err + } + e.Slot = imported.Slot + e.BuilderIndex = imported.BuilderIndex + e.BlockHash = imported.BlockHash + e.BlockRoot = imported.BlockRoot + + return nil +} + +// String returns a string version of the structure. +func (e *ExecutionPayloadGossipEvent) String() string { + data, err := json.Marshal(e) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/api/v1/fastconfirmationevent.go b/api/v1/fastconfirmationevent.go new file mode 100644 index 00000000..510e8323 --- /dev/null +++ b/api/v1/fastconfirmationevent.go @@ -0,0 +1,106 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package v1 + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// FastConfirmationEvent is the data for the fast confirmation event. Slot and +// Block identify the most recent confirmed block; CurrentSlot is the wall-clock +// slot at which the fast-confirmation algorithm was executed, or nil if the node +// did not supply it. +type FastConfirmationEvent struct { + Slot phase0.Slot + Block phase0.Root + CurrentSlot *phase0.Slot +} + +// fastConfirmationEventJSON is the spec representation of the struct. +type fastConfirmationEventJSON struct { + Slot string `json:"slot"` + Block string `json:"block"` + CurrentSlot string `json:"current_slot,omitempty"` +} + +// MarshalJSON implements json.Marshaler. +func (e *FastConfirmationEvent) MarshalJSON() ([]byte, error) { + data := &fastConfirmationEventJSON{ + Slot: fmt.Sprintf("%d", e.Slot), + Block: fmt.Sprintf("%#x", e.Block), + } + if e.CurrentSlot != nil { + data.CurrentSlot = fmt.Sprintf("%d", *e.CurrentSlot) + } + + return json.Marshal(data) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (e *FastConfirmationEvent) UnmarshalJSON(input []byte) error { + var data fastConfirmationEventJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if data.Slot == "" { + return errors.New("slot missing") + } + + slot, err := strconv.ParseUint(data.Slot, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value for slot") + } + + e.Slot = phase0.Slot(slot) + + if data.Block == "" { + return errors.New("block missing") + } + + if err := decodeFixedBytes(e.Block[:], data.Block, "block"); err != nil { + return err + } + + // current_slot was added to the spec after slot/block; parse it when + // present but tolerate clients that do not yet emit it, leaving it nil + // rather than 0 so that it is not re-marshalled with a made-up value. + e.CurrentSlot = nil + if data.CurrentSlot != "" { + currentSlot, err := strconv.ParseUint(data.CurrentSlot, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value for current slot") + } + + value := phase0.Slot(currentSlot) + e.CurrentSlot = &value + } + + return nil +} + +// String returns a string version of the structure. +func (e *FastConfirmationEvent) String() string { + data, err := json.Marshal(e) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/api/v1/fastconfirmationevent_test.go b/api/v1/fastconfirmationevent_test.go new file mode 100644 index 00000000..28c6ddb2 --- /dev/null +++ b/api/v1/fastconfirmationevent_test.go @@ -0,0 +1,118 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package v1_test + +import ( + "encoding/json" + "testing" + + api "github.com/attestantio/go-eth2-client/api/v1" + "github.com/stretchr/testify/assert" + require "github.com/stretchr/testify/require" +) + +func TestFastConfirmationEventJSON(t *testing.T) { + tests := []struct { + name string + input []byte + err string + }{ + { + name: "Empty", + err: "unexpected end of JSON input", + }, + { + name: "JSONBad", + input: []byte("[]"), + err: "invalid JSON: json: cannot unmarshal array into Go value of type v1.fastConfirmationEventJSON", + }, + { + name: "SlotMissing", + input: []byte(`{"block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","current_slot":"525278"}`), + err: "slot missing", + }, + { + name: "SlotInvalid", + input: []byte(`{"slot":"-1","block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","current_slot":"525278"}`), + err: "invalid value for slot: strconv.ParseUint: parsing \"-1\": invalid syntax", + }, + { + name: "BlockMissing", + input: []byte(`{"slot":"525277","current_slot":"525278"}`), + err: "block missing", + }, + { + name: "BlockInvalid", + input: []byte(`{"slot":"525277","block":"invalid","current_slot":"525278"}`), + err: "invalid value for block: encoding/hex: invalid byte: U+0069 'i'", + }, + { + name: "BlockShort", + input: []byte(`{"slot":"525277","block":"0xe3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","current_slot":"525278"}`), + err: "incorrect length 31 for block", + }, + { + name: "BlockLong", + input: []byte(`{"slot":"525277","block":"0x9999e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","current_slot":"525278"}`), + err: "incorrect length 33 for block", + }, + { + name: "CurrentSlotInvalid", + input: []byte(`{"slot":"525277","block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","current_slot":"-1"}`), + err: "invalid value for current slot: strconv.ParseUint: parsing \"-1\": invalid syntax", + }, + { + name: "Good", + input: []byte(`{"slot":"525277","block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","current_slot":"525278"}`), + }, + { + // current_slot was added to the spec after slot/block, so clients that do not emit + // it must still parse cleanly, and re-marshal without inventing a value for it. + name: "GoodNoCurrentSlot", + input: []byte(`{"slot":"525277","block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028"}`), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var res api.FastConfirmationEvent + err := json.Unmarshal(test.input, &res) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + rt, err := json.Marshal(&res) + require.NoError(t, err) + assert.Equal(t, string(test.input), string(rt)) + assert.Equal(t, string(rt), res.String()) + } + }) + } + + t.Run("CurrentSlotAbsent", func(t *testing.T) { + var res api.FastConfirmationEvent + err := json.Unmarshal([]byte(`{"slot":"525277","block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028"}`), &res) + require.NoError(t, err) + require.Nil(t, res.CurrentSlot) + }) + + t.Run("CurrentSlotAbsentOnReuse", func(t *testing.T) { + var res api.FastConfirmationEvent + require.NoError(t, json.Unmarshal([]byte(`{"slot":"525277","block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","current_slot":"525278"}`), &res)) + require.NotNil(t, res.CurrentSlot) + + require.NoError(t, json.Unmarshal([]byte(`{"slot":"525279","block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028"}`), &res)) + require.Nil(t, res.CurrentSlot, "current_slot kept from the previous event") + }) +} diff --git a/api/v1/gloas/blockcontents.go b/api/v1/gloas/blockcontents.go new file mode 100644 index 00000000..287359d5 --- /dev/null +++ b/api/v1/gloas/blockcontents.go @@ -0,0 +1,43 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/goccy/go-yaml" +) + +// BlockContents is the block-production response body for a self-built Gloas +// block: the beacon block together with the execution payload envelope, blobs +// and KZG proofs that the caller needs to publish it. +type BlockContents struct { + Block *gloas.BeaconBlock + ExecutionPayloadEnvelope *gloas.ExecutionPayloadEnvelope + + KZGProofs []deneb.KZGProof `dynssz-max:"FIELD_ELEMENTS_PER_EXT_BLOB*MAX_BLOB_COMMITMENTS_PER_BLOCK" ssz-max:"33554432" ssz-size:"?,48"` + Blobs []deneb.Blob `dynssz-max:"MAX_BLOB_COMMITMENTS_PER_BLOCK" ssz-max:"4096" ssz-size:"?,131072"` +} + +// String returns a string version of the structure. +func (b *BlockContents) String() string { + data, err := yaml.Marshal(b) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/api/v1/gloas/blockcontents_json.go b/api/v1/gloas/blockcontents_json.go new file mode 100644 index 00000000..1881993f --- /dev/null +++ b/api/v1/gloas/blockcontents_json.go @@ -0,0 +1,75 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/json" + + "github.com/attestantio/go-eth2-client/codecs" + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/pkg/errors" +) + +// blockContentsJSON is the spec representation of the struct. +type blockContentsJSON struct { + Block *gloas.BeaconBlock `json:"block"` + ExecutionPayloadEnvelope *gloas.ExecutionPayloadEnvelope `json:"execution_payload_envelope"` + KZGProofs []deneb.KZGProof `json:"kzg_proofs"` + Blobs []deneb.Blob `json:"blobs"` +} + +// MarshalJSON implements json.Marshaler. +func (b *BlockContents) MarshalJSON() ([]byte, error) { + return json.Marshal(&blockContentsJSON{ + Block: b.Block, + ExecutionPayloadEnvelope: b.ExecutionPayloadEnvelope, + KZGProofs: b.KZGProofs, + Blobs: b.Blobs, + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (b *BlockContents) UnmarshalJSON(input []byte) error { + raw, err := codecs.RawJSON(&blockContentsJSON{}, input) + if err != nil { + return err + } + + if err := json.Unmarshal(raw["block"], &b.Block); err != nil { + return errors.Wrap(err, "block") + } + + if err := json.Unmarshal(raw["execution_payload_envelope"], &b.ExecutionPayloadEnvelope); err != nil { + return errors.Wrap(err, "execution_payload_envelope") + } + + if string(raw["kzg_proofs"]) == "null" { + return errors.New("kzg_proofs: null") + } + + if err := json.Unmarshal(raw["kzg_proofs"], &b.KZGProofs); err != nil { + return errors.Wrap(err, "kzg_proofs") + } + + if string(raw["blobs"]) == "null" { + return errors.New("blobs: null") + } + + if err := json.Unmarshal(raw["blobs"], &b.Blobs); err != nil { + return errors.Wrap(err, "blobs") + } + + return nil +} diff --git a/api/v1/gloas/blockcontents_ssz.go b/api/v1/gloas/blockcontents_ssz.go new file mode 100644 index 00000000..1b90ef8f --- /dev/null +++ b/api/v1/gloas/blockcontents_ssz.go @@ -0,0 +1,246 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 08983a4ebad0aa31525d28e8b9f233363a30a7d5b699b3ccbadd08ea7228d3d6 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/gloas" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[BlockContents](`ssz-static:"false"`) + +// MarshalSSZ marshals the *BlockContents to SSZ-encoded bytes. +func (t *BlockContents) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *BlockContents to SSZ-encoded bytes, appending to the provided buffer. +func (t *BlockContents) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + zeroBytes := sszutils.ZeroBytes() + if t == nil { + t = new(BlockContents) + } + dstlen := len(dst) + // Offset Field #0 'Block' + // Offset Field #1 'ExecutionPayloadEnvelope' + // Offset Field #2 'KZGProofs' + // Offset Field #3 'Blobs' + dst = append(dst, zeroBytes[:16]...) + { // Dynamic Field #0 'Block' + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(len(dst)-dstlen)) + t := t.Block + if t == nil { + t = new(gloas.BeaconBlock) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Block") + } + } + { // Dynamic Field #1 'ExecutionPayloadEnvelope' + binary.LittleEndian.PutUint32(dst[dstlen+4:], uint32(len(dst)-dstlen)) + t := t.ExecutionPayloadEnvelope + if t == nil { + t = new(gloas.ExecutionPayloadEnvelope) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "ExecutionPayloadEnvelope") + } + } + { // Dynamic Field #2 'KZGProofs' + binary.LittleEndian.PutUint32(dst[dstlen+8:], uint32(len(dst)-dstlen)) + t := t.KZGProofs + vlen := len(t) + if vlen > 33554432 { + return nil, sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 33554432), "KZGProofs") + } + dst = sszutils.MarshalFixedBytesSlice(dst, t[:vlen]) + } + { // Dynamic Field #3 'Blobs' + binary.LittleEndian.PutUint32(dst[dstlen+12:], uint32(len(dst)-dstlen)) + t := t.Blobs + vlen := len(t) + if vlen > 4096 { + return nil, sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 4096), "Blobs") + } + dst = sszutils.MarshalFixedBytesSlice(dst, t[:vlen]) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *BlockContents from SSZ-encoded bytes. +func (t *BlockContents) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 16 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 16) + } + // Field #0 'Block' (offset) + offset0 := int(binary.LittleEndian.Uint32(buf[0:4])) + if offset0 != 16 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 16), "Block:o") + } + // Field #1 'ExecutionPayloadEnvelope' (offset) + offset1 := int(binary.LittleEndian.Uint32(buf[4:8])) + if offset1 < offset0 || offset1 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset1, offset0, buflen), "ExecutionPayloadEnvelope:o") + } + // Field #2 'KZGProofs' (offset) + offset2 := int(binary.LittleEndian.Uint32(buf[8:12])) + if offset2 < offset1 || offset2 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset2, offset1, buflen), "KZGProofs:o") + } + // Field #3 'Blobs' (offset) + offset3 := int(binary.LittleEndian.Uint32(buf[12:16])) + if offset3 < offset2 || offset3 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset3, offset2, buflen), "Blobs:o") + } + { // Field #0 'Block' (dynamic) + buf := buf[offset0:offset1] + if t.Block == nil { + t.Block = new(gloas.BeaconBlock) + } + if err = t.Block.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Block") + } + } + { // Field #1 'ExecutionPayloadEnvelope' (dynamic) + buf := buf[offset1:offset2] + if t.ExecutionPayloadEnvelope == nil { + t.ExecutionPayloadEnvelope = new(gloas.ExecutionPayloadEnvelope) + } + if err = t.ExecutionPayloadEnvelope.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "ExecutionPayloadEnvelope") + } + } + { // Field #2 'KZGProofs' (dynamic) + buf := buf[offset2:offset3] + val1 := t.KZGProofs + itemCount := len(buf) / 48 + if len(buf)%48 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 48), "KZGProofs") + } + if itemCount > 33554432 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(itemCount, 33554432), "KZGProofs") + } + val1 = sszutils.ExpandSlice(val1, itemCount) + sszutils.UnmarshalFixedBytesSlice(val1[:itemCount], buf) + t.KZGProofs = val1 + } + { // Field #3 'Blobs' (dynamic) + buf := buf[offset3:] + val2 := t.Blobs + itemCount := len(buf) / 131072 + if len(buf)%131072 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 131072), "Blobs") + } + if itemCount > 4096 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(itemCount, 4096), "Blobs") + } + val2 = sszutils.ExpandSlice(val2, itemCount) + sszutils.UnmarshalFixedBytesSlice(val2[:itemCount], buf) + t.Blobs = val2 + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *BlockContents. +func (t *BlockContents) SizeSSZ() (size int) { + if t == nil { + t = new(BlockContents) + } + // Field #0 'Block' offset (4 bytes) + // Field #1 'ExecutionPayloadEnvelope' offset (4 bytes) + // Field #2 'KZGProofs' offset (4 bytes) + // Field #3 'Blobs' offset (4 bytes) + size += 16 + { // Dynamic field #0 'Block' + size += t.Block.SizeSSZ() + } + { // Dynamic field #1 'ExecutionPayloadEnvelope' + size += t.ExecutionPayloadEnvelope.SizeSSZ() + } + { // Dynamic field #2 'KZGProofs' + size += len(t.KZGProofs) * 48 + } + { // Dynamic field #3 'Blobs' + size += len(t.Blobs) * 131072 + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *BlockContents. +func (t *BlockContents) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *BlockContents using the given hash walker. +func (t *BlockContents) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(BlockContents) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Block' + t := t.Block + if t == nil { + t = new(gloas.BeaconBlock) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Block") + } + } + { // Field #1 'ExecutionPayloadEnvelope' + t := t.ExecutionPayloadEnvelope + if t == nil { + t = new(gloas.ExecutionPayloadEnvelope) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "ExecutionPayloadEnvelope") + } + } + { // Field #2 'KZGProofs' + t := t.KZGProofs + vlen := uint64(len(t)) + if vlen > 33554432 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 33554432), "KZGProofs") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + for idx1 := range int(vlen) { + hh.PutBytes(t[idx1][:48]) + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(33554432, vlen, 32)) + } + { // Field #3 'Blobs' + t := t.Blobs + vlen := uint64(len(t)) + if vlen > 4096 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 4096), "Blobs") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + for idx1 := range int(vlen) { + hh.PutBytes(t[idx1][:131072]) + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(4096, vlen, 32)) + } + hh.Merkleize(idx) + return nil +} diff --git a/api/v1/gloas/blockcontents_test.go b/api/v1/gloas/blockcontents_test.go new file mode 100644 index 00000000..31882665 --- /dev/null +++ b/api/v1/gloas/blockcontents_test.go @@ -0,0 +1,232 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas_test + +import ( + "encoding/json" + "testing" + + bitfield "github.com/OffchainLabs/go-bitfield" + apiv1gloas "github.com/attestantio/go-eth2-client/api/v1/gloas" + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/holiman/uint256" + require "github.com/stretchr/testify/require" +) + +// validBeaconBlock returns a block populated just far enough for its own JSON +// marshaler to run: every pointer the marshaler dereferences is non-nil. +func validBeaconBlock() *gloas.BeaconBlock { + return &gloas.BeaconBlock{ + Body: &gloas.BeaconBlockBody{ + ETH1Data: &phase0.ETH1Data{BlockHash: make([]byte, phase0.Hash32Length)}, + SyncAggregate: &altair.SyncAggregate{SyncCommitteeBits: bitfield.NewBitvector512()}, + SignedExecutionPayloadBid: &gloas.SignedExecutionPayloadBid{Message: &gloas.ExecutionPayloadBid{}}, + ParentExecutionRequests: &gloas.ExecutionRequests{}, + }, + } +} + +// validExecutionPayloadEnvelope returns an envelope whose payload carries a +// non-nil BaseFeePerGas, which the payload's own marshaler dereferences. +func validExecutionPayloadEnvelope() *gloas.ExecutionPayloadEnvelope { + return &gloas.ExecutionPayloadEnvelope{ + Payload: &gloas.ExecutionPayload{BaseFeePerGas: uint256.NewInt(0)}, + ExecutionRequests: &gloas.ExecutionRequests{}, + } +} + +// validBlockContents returns block contents carrying one KZG proof and no +// blobs. Blobs are left empty deliberately: deneb.Blob is a 128KiB array, so a +// populated one turns any require.Equal failure into a 256KiB hex diff. The +// element path for blobs is covered separately by +// TestBlockContentsJSONPreservesBlobs. +func validBlockContents() *apiv1gloas.BlockContents { + return &apiv1gloas.BlockContents{ + Block: validBeaconBlock(), + ExecutionPayloadEnvelope: validExecutionPayloadEnvelope(), + KZGProofs: []deneb.KZGProof{{0x01, 0x02, 0x03}}, + Blobs: []deneb.Blob{}, + } +} + +// TestBlockContentsJSON verifies that BlockContents marshals to the four field +// names the beacon-APIs Gloas.BlockContents schema requires, and that a +// populated value survives a JSON round trip. The schema names all four in +// snake_case; Go's default marshaler would emit the exported field names +// instead, so the key assertions fail against an uncodec'd struct. +func TestBlockContentsJSON(t *testing.T) { + contents := validBlockContents() + + data, err := json.Marshal(contents) + require.NoError(t, err) + + for _, key := range []string{ + `"block":`, + `"execution_payload_envelope":`, + `"kzg_proofs":`, + `"blobs":`, + } { + require.Contains(t, string(data), key) + } + + var decoded apiv1gloas.BlockContents + require.NoError(t, json.Unmarshal(data, &decoded)) + + // Compare on the wire rather than with require.Equal on the structs: the + // nested gloas.ExecutionPayload codec normalises a nil Transactions, + // ExtraData or BlockAccessList to an empty slice on the way back in, so + // struct equality would assert that package's conventions rather than this + // codec's behaviour. Byte equality still catches the failure that matters — + // drop any one field from UnmarshalJSON and its key re-marshals as null. + reencoded, err := json.Marshal(&decoded) + require.NoError(t, err) + require.Equal(t, string(data), string(reencoded)) + + // The fields this type owns directly must survive intact. + require.Equal(t, contents.KZGProofs, decoded.KZGProofs) + require.Equal(t, contents.Blobs, decoded.Blobs) +} + +// TestBlockContentsSSZ verifies that BlockContents round-trips through SSZ. The +// generated codec is what the block-production endpoint uses when a node answers +// with application/octet-stream, so an absent or misordered codec would surface +// only as an undecodable proposal. +func TestBlockContentsSSZ(t *testing.T) { + contents := validBlockContents() + + data, err := contents.MarshalSSZ() + require.NoError(t, err) + require.NotEmpty(t, data) + + var decoded apiv1gloas.BlockContents + require.NoError(t, decoded.UnmarshalSSZ(data)) + + // SSZ carries no field names, so a wrongly ordered or wrongly sized codec + // shows up as a differing hash tree root rather than a decode error. + want, err := contents.HashTreeRoot() + require.NoError(t, err) + got, err := decoded.HashTreeRoot() + require.NoError(t, err) + require.Equal(t, want, got) + + require.Equal(t, contents.KZGProofs, decoded.KZGProofs) +} + +// TestBlockContentsString verifies that stringifying block contents renders the +// spec's field names. goccy/go-yaml would otherwise lower-case the Go field +// names, emitting executionpayloadenvelope rather than the spec's +// execution_payload_envelope, so this pins the hand-written YAML marshaler. +func TestBlockContentsString(t *testing.T) { + var str string + require.NotPanics(t, func() { + str = validBlockContents().String() + }) + + require.Contains(t, str, "execution_payload_envelope:") + require.Contains(t, str, "kzg_proofs:") +} + +// TestBlockContentsJSONMissingField verifies that every one of the four fields +// the schema marks required is rejected when absent. encoding/json ignores +// unknown keys and leaves absent ones at their zero value, so without the +// presence check a body missing its blobs would decode as success and hand the +// caller contents it cannot publish. +func TestBlockContentsJSONMissingField(t *testing.T) { + tests := []struct { + name string + omitKey string + err string + }{ + {name: "Block", omitKey: "block", err: "block: missing"}, + { + name: "ExecutionPayloadEnvelope", + omitKey: "execution_payload_envelope", + err: "execution_payload_envelope: missing", + }, + {name: "KZGProofs", omitKey: "kzg_proofs", err: "kzg_proofs: missing"}, + {name: "Blobs", omitKey: "blobs", err: "blobs: missing"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + data, err := json.Marshal(validBlockContents()) + require.NoError(t, err) + + var generic map[string]json.RawMessage + require.NoError(t, json.Unmarshal(data, &generic)) + delete(generic, test.omitKey) + + pruned, err := json.Marshal(generic) + require.NoError(t, err) + + var decoded apiv1gloas.BlockContents + require.EqualError(t, decoded.UnmarshalJSON(pruned), test.err) + }) + } +} + +func TestBlockContentsJSONNullRequiredField(t *testing.T) { + tests := []struct { + name string + key string + err string + }{ + {name: "KZGProofs", key: "kzg_proofs", err: "kzg_proofs: null"}, + {name: "Blobs", key: "blobs", err: "blobs: null"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + data, err := json.Marshal(validBlockContents()) + require.NoError(t, err) + + var generic map[string]json.RawMessage + require.NoError(t, json.Unmarshal(data, &generic)) + generic[test.key] = json.RawMessage("null") + + invalid, err := json.Marshal(generic) + require.NoError(t, err) + + var decoded apiv1gloas.BlockContents + require.EqualError(t, decoded.UnmarshalJSON(invalid), test.err) + }) + } +} + +// TestBlockContentsJSONPreservesBlobs verifies that a populated blob survives a +// JSON round trip end to end. A blob is a 128KiB fixed-size array, so the +// assertions check its first and last byte rather than comparing whole values: +// that catches a truncating or zero-padding copy without producing a 256KiB +// diff on failure. +func TestBlockContentsJSONPreservesBlobs(t *testing.T) { + contents := validBlockContents() + + var blob deneb.Blob + blob[0] = 0xaa + blob[len(blob)-1] = 0xbb + contents.Blobs = []deneb.Blob{blob} + + data, err := json.Marshal(contents) + require.NoError(t, err) + + var decoded apiv1gloas.BlockContents + require.NoError(t, json.Unmarshal(data, &decoded)) + + require.Len(t, decoded.Blobs, 1) + require.Equal(t, byte(0xaa), decoded.Blobs[0][0]) + require.Equal(t, byte(0xbb), decoded.Blobs[0][len(decoded.Blobs[0])-1]) +} diff --git a/api/v1/gloas/blockcontents_yaml.go b/api/v1/gloas/blockcontents_yaml.go new file mode 100644 index 00000000..bdfb524f --- /dev/null +++ b/api/v1/gloas/blockcontents_yaml.go @@ -0,0 +1,63 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// blockContentsYAML is the spec representation of the struct. +type blockContentsYAML struct { + Block *gloas.BeaconBlock `yaml:"block"` + ExecutionPayloadEnvelope *gloas.ExecutionPayloadEnvelope `yaml:"execution_payload_envelope"` + KZGProofs []deneb.KZGProof `yaml:"kzg_proofs"` + Blobs []deneb.Blob `yaml:"blobs"` +} + +// MarshalYAML implements yaml.Marshaler. +func (b *BlockContents) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&blockContentsYAML{ + Block: b.Block, + ExecutionPayloadEnvelope: b.ExecutionPayloadEnvelope, + KZGProofs: b.KZGProofs, + Blobs: b.Blobs, + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (b *BlockContents) UnmarshalYAML(input []byte) error { + // We unmarshal to the JSON struct to save on duplicate code. + var unmarshaled blockContentsJSON + if err := yaml.Unmarshal(input, &unmarshaled); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + + marshaled, err := json.Marshal(unmarshaled) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return b.UnmarshalJSON(marshaled) +} diff --git a/api/v1/gloas/generate.go b/api/v1/gloas/generate.go new file mode 100644 index 00000000..33ecb95d --- /dev/null +++ b/api/v1/gloas/generate.go @@ -0,0 +1,17 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +//go:generate rm -f signedexecutionpayloadenvelopecontents_ssz.go +//go:generate go tool dynssz-gen -config generate.yaml diff --git a/api/v1/gloas/generate.yaml b/api/v1/gloas/generate.yaml new file mode 100644 index 00000000..b37fd9a9 --- /dev/null +++ b/api/v1/gloas/generate.yaml @@ -0,0 +1,9 @@ +package: . +legacy: true +without-dynamic-expressions: true + +types: + - name: BlockContents + output: blockcontents_ssz.go + - name: SignedExecutionPayloadEnvelopeContents + output: signedexecutionpayloadenvelopecontents_ssz.go diff --git a/api/v1/gloas/signedexecutionpayloadenvelopecontents.go b/api/v1/gloas/signedexecutionpayloadenvelopecontents.go new file mode 100644 index 00000000..5b46c5b8 --- /dev/null +++ b/api/v1/gloas/signedexecutionpayloadenvelopecontents.go @@ -0,0 +1,56 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/goccy/go-yaml" +) + +// SignedExecutionPayloadEnvelopeContents is the stateless request body for +// publishing an execution payload envelope: the signed envelope together with +// the blobs and KZG cell proofs the beacon node needs to build data column +// sidecars when it has none cached from its own block production. +type SignedExecutionPayloadEnvelopeContents struct { + SignedExecutionPayloadEnvelope *gloas.SignedExecutionPayloadEnvelope + + KZGProofs []deneb.KZGProof `dynssz-max:"FIELD_ELEMENTS_PER_EXT_BLOB*MAX_BLOB_COMMITMENTS_PER_BLOCK" ssz-max:"33554432" ssz-size:"?,48"` + Blobs []deneb.Blob `dynssz-max:"MAX_BLOB_COMMITMENTS_PER_BLOCK" ssz-max:"4096" ssz-size:"?,131072"` +} + +// signedExecutionPayloadEnvelopeContents is the wire representation of the struct. +type signedExecutionPayloadEnvelopeContents struct { + SignedExecutionPayloadEnvelope *gloas.SignedExecutionPayloadEnvelope `json:"signed_execution_payload_envelope" yaml:"signed_execution_payload_envelope"` + KZGProofs []deneb.KZGProof `json:"kzg_proofs" yaml:"kzg_proofs"` + Blobs []deneb.Blob `json:"blobs" yaml:"blobs"` +} + +func (s *SignedExecutionPayloadEnvelopeContents) unpack(value *signedExecutionPayloadEnvelopeContents) { + s.SignedExecutionPayloadEnvelope = value.SignedExecutionPayloadEnvelope + s.KZGProofs = value.KZGProofs + s.Blobs = value.Blobs +} + +// String returns a string version of the structure. +func (s *SignedExecutionPayloadEnvelopeContents) String() string { + data, err := yaml.Marshal(s) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/api/v1/gloas/signedexecutionpayloadenvelopecontents_json.go b/api/v1/gloas/signedexecutionpayloadenvelopecontents_json.go new file mode 100644 index 00000000..c7201989 --- /dev/null +++ b/api/v1/gloas/signedexecutionpayloadenvelopecontents_json.go @@ -0,0 +1,55 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/json" + + "github.com/attestantio/go-eth2-client/codecs" + "github.com/pkg/errors" +) + +// MarshalJSON implements json.Marshaler. +func (s *SignedExecutionPayloadEnvelopeContents) MarshalJSON() ([]byte, error) { + return json.Marshal(&signedExecutionPayloadEnvelopeContents{ + SignedExecutionPayloadEnvelope: s.SignedExecutionPayloadEnvelope, + KZGProofs: s.KZGProofs, + Blobs: s.Blobs, + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (s *SignedExecutionPayloadEnvelopeContents) UnmarshalJSON(input []byte) error { + raw, err := codecs.RawJSON(&signedExecutionPayloadEnvelopeContents{}, input) + if err != nil { + return err + } + + var unmarshaled signedExecutionPayloadEnvelopeContents + if err := json.Unmarshal(raw["signed_execution_payload_envelope"], &unmarshaled.SignedExecutionPayloadEnvelope); err != nil { + return errors.Wrap(err, "signed_execution_payload_envelope") + } + + if err := json.Unmarshal(raw["kzg_proofs"], &unmarshaled.KZGProofs); err != nil { + return errors.Wrap(err, "kzg_proofs") + } + + if err := json.Unmarshal(raw["blobs"], &unmarshaled.Blobs); err != nil { + return errors.Wrap(err, "blobs") + } + + s.unpack(&unmarshaled) + + return nil +} diff --git a/api/v1/gloas/signedexecutionpayloadenvelopecontents_ssz.go b/api/v1/gloas/signedexecutionpayloadenvelopecontents_ssz.go new file mode 100644 index 00000000..e7b17384 --- /dev/null +++ b/api/v1/gloas/signedexecutionpayloadenvelopecontents_ssz.go @@ -0,0 +1,208 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 771e09a081cfdc07ad7cb8c1f0425aa4c536ac0e215d4df6d99dc827920ef840 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/gloas" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[SignedExecutionPayloadEnvelopeContents](`ssz-static:"false"`) + +// MarshalSSZ marshals the *SignedExecutionPayloadEnvelopeContents to SSZ-encoded bytes. +func (t *SignedExecutionPayloadEnvelopeContents) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *SignedExecutionPayloadEnvelopeContents to SSZ-encoded bytes, appending to the provided buffer. +func (t *SignedExecutionPayloadEnvelopeContents) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + zeroBytes := sszutils.ZeroBytes() + if t == nil { + t = new(SignedExecutionPayloadEnvelopeContents) + } + dstlen := len(dst) + // Offset Field #0 'SignedExecutionPayloadEnvelope' + // Offset Field #1 'KZGProofs' + // Offset Field #2 'Blobs' + dst = append(dst, zeroBytes[:12]...) + { // Dynamic Field #0 'SignedExecutionPayloadEnvelope' + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(len(dst)-dstlen)) + t := t.SignedExecutionPayloadEnvelope + if t == nil { + t = new(gloas.SignedExecutionPayloadEnvelope) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "SignedExecutionPayloadEnvelope") + } + } + { // Dynamic Field #1 'KZGProofs' + binary.LittleEndian.PutUint32(dst[dstlen+4:], uint32(len(dst)-dstlen)) + t := t.KZGProofs + vlen := len(t) + if vlen > 33554432 { + return nil, sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 33554432), "KZGProofs") + } + dst = sszutils.MarshalFixedBytesSlice(dst, t[:vlen]) + } + { // Dynamic Field #2 'Blobs' + binary.LittleEndian.PutUint32(dst[dstlen+8:], uint32(len(dst)-dstlen)) + t := t.Blobs + vlen := len(t) + if vlen > 4096 { + return nil, sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 4096), "Blobs") + } + dst = sszutils.MarshalFixedBytesSlice(dst, t[:vlen]) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *SignedExecutionPayloadEnvelopeContents from SSZ-encoded bytes. +func (t *SignedExecutionPayloadEnvelopeContents) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 12 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 12) + } + // Field #0 'SignedExecutionPayloadEnvelope' (offset) + offset0 := int(binary.LittleEndian.Uint32(buf[0:4])) + if offset0 != 12 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 12), "SignedExecutionPayloadEnvelope:o") + } + // Field #1 'KZGProofs' (offset) + offset1 := int(binary.LittleEndian.Uint32(buf[4:8])) + if offset1 < offset0 || offset1 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset1, offset0, buflen), "KZGProofs:o") + } + // Field #2 'Blobs' (offset) + offset2 := int(binary.LittleEndian.Uint32(buf[8:12])) + if offset2 < offset1 || offset2 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset2, offset1, buflen), "Blobs:o") + } + { // Field #0 'SignedExecutionPayloadEnvelope' (dynamic) + buf := buf[offset0:offset1] + if t.SignedExecutionPayloadEnvelope == nil { + t.SignedExecutionPayloadEnvelope = new(gloas.SignedExecutionPayloadEnvelope) + } + if err = t.SignedExecutionPayloadEnvelope.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "SignedExecutionPayloadEnvelope") + } + } + { // Field #1 'KZGProofs' (dynamic) + buf := buf[offset1:offset2] + val1 := t.KZGProofs + itemCount := len(buf) / 48 + if len(buf)%48 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 48), "KZGProofs") + } + if itemCount > 33554432 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(itemCount, 33554432), "KZGProofs") + } + val1 = sszutils.ExpandSlice(val1, itemCount) + sszutils.UnmarshalFixedBytesSlice(val1[:itemCount], buf) + t.KZGProofs = val1 + } + { // Field #2 'Blobs' (dynamic) + buf := buf[offset2:] + val2 := t.Blobs + itemCount := len(buf) / 131072 + if len(buf)%131072 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 131072), "Blobs") + } + if itemCount > 4096 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(itemCount, 4096), "Blobs") + } + val2 = sszutils.ExpandSlice(val2, itemCount) + sszutils.UnmarshalFixedBytesSlice(val2[:itemCount], buf) + t.Blobs = val2 + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *SignedExecutionPayloadEnvelopeContents. +func (t *SignedExecutionPayloadEnvelopeContents) SizeSSZ() (size int) { + if t == nil { + t = new(SignedExecutionPayloadEnvelopeContents) + } + // Field #0 'SignedExecutionPayloadEnvelope' offset (4 bytes) + // Field #1 'KZGProofs' offset (4 bytes) + // Field #2 'Blobs' offset (4 bytes) + size += 12 + { // Dynamic field #0 'SignedExecutionPayloadEnvelope' + size += t.SignedExecutionPayloadEnvelope.SizeSSZ() + } + { // Dynamic field #1 'KZGProofs' + size += len(t.KZGProofs) * 48 + } + { // Dynamic field #2 'Blobs' + size += len(t.Blobs) * 131072 + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *SignedExecutionPayloadEnvelopeContents. +func (t *SignedExecutionPayloadEnvelopeContents) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *SignedExecutionPayloadEnvelopeContents using the given hash walker. +func (t *SignedExecutionPayloadEnvelopeContents) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(SignedExecutionPayloadEnvelopeContents) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'SignedExecutionPayloadEnvelope' + t := t.SignedExecutionPayloadEnvelope + if t == nil { + t = new(gloas.SignedExecutionPayloadEnvelope) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "SignedExecutionPayloadEnvelope") + } + } + { // Field #1 'KZGProofs' + t := t.KZGProofs + vlen := uint64(len(t)) + if vlen > 33554432 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 33554432), "KZGProofs") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + for idx1 := range int(vlen) { + hh.PutBytes(t[idx1][:48]) + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(33554432, vlen, 32)) + } + { // Field #2 'Blobs' + t := t.Blobs + vlen := uint64(len(t)) + if vlen > 4096 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 4096), "Blobs") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + for idx1 := range int(vlen) { + hh.PutBytes(t[idx1][:131072]) + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(4096, vlen, 32)) + } + hh.Merkleize(idx) + return nil +} diff --git a/api/v1/gloas/signedexecutionpayloadenvelopecontents_test.go b/api/v1/gloas/signedexecutionpayloadenvelopecontents_test.go new file mode 100644 index 00000000..ef282b53 --- /dev/null +++ b/api/v1/gloas/signedexecutionpayloadenvelopecontents_test.go @@ -0,0 +1,36 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas_test + +import ( + "testing" + + apiGloas "github.com/attestantio/go-eth2-client/api/v1/gloas" + "github.com/attestantio/go-eth2-client/spec/deneb" + require "github.com/stretchr/testify/require" +) + +func TestSignedExecutionPayloadEnvelopeContentsYAML(t *testing.T) { + input := &apiGloas.SignedExecutionPayloadEnvelopeContents{ + KZGProofs: []deneb.KZGProof{{0x01}}, + Blobs: []deneb.Blob{{0x02}}, + } + + marshaled, err := input.MarshalYAML() + require.NoError(t, err) + + var obtained apiGloas.SignedExecutionPayloadEnvelopeContents + require.NoError(t, obtained.UnmarshalYAML(marshaled)) + require.Equal(t, input, &obtained) +} diff --git a/api/v1/gloas/signedexecutionpayloadenvelopecontents_yaml.go b/api/v1/gloas/signedexecutionpayloadenvelopecontents_yaml.go new file mode 100644 index 00000000..59fb7563 --- /dev/null +++ b/api/v1/gloas/signedexecutionpayloadenvelopecontents_yaml.go @@ -0,0 +1,47 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (s *SignedExecutionPayloadEnvelopeContents) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&signedExecutionPayloadEnvelopeContents{ + SignedExecutionPayloadEnvelope: s.SignedExecutionPayloadEnvelope, + KZGProofs: s.KZGProofs, + Blobs: s.Blobs, + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (s *SignedExecutionPayloadEnvelopeContents) UnmarshalYAML(input []byte) error { + var unmarshaled signedExecutionPayloadEnvelopeContents + if err := yaml.Unmarshal(input, &unmarshaled); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + + s.unpack(&unmarshaled) + + return nil +} diff --git a/api/v1/headevent.go b/api/v1/headevent.go index 8d5894ed..d4fcaed8 100644 --- a/api/v1/headevent.go +++ b/api/v1/headevent.go @@ -1,4 +1,4 @@ -// Copyright © 2020, 2021 Attestant Limited. +// Copyright © 2020 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -116,6 +116,10 @@ func (e *HeadEvent) UnmarshalJSON(input []byte) error { copy(e.State[:], state) e.EpochTransition = headEventJSON.EpochTransition + // The dependent roots only have partial coverage so do not complain if not present, but + // clear them so that a value reused for an event without them does not keep an earlier one. + e.CurrentDutyDependentRoot = phase0.Root{} + e.PreviousDutyDependentRoot = phase0.Root{} // CurrentDutyDependentRoot only has partial coverage so do not complain if not present. if headEventJSON.CurrentDutyDependentRoot != "" { currentDutyDependentRoot, err := hex.DecodeString(strings.TrimPrefix(headEventJSON.CurrentDutyDependentRoot, "0x")) diff --git a/api/v1/headevent_test.go b/api/v1/headevent_test.go index 208d4f01..9ddb026b 100644 --- a/api/v1/headevent_test.go +++ b/api/v1/headevent_test.go @@ -176,4 +176,15 @@ func TestHeadEventJSON(t *testing.T) { } }) } + + t.Run("DependentRootsAbsentOnReuse", func(t *testing.T) { + var res api.HeadEvent + require.NoError(t, json.Unmarshal([]byte(`{"slot":"525277","block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","state":"0x749a95b1355828b758864ea601c007e69aabed7b34a0f2084c43c26242f77e28","epoch_transition":false,"current_duty_dependent_root":"0x907a3462a2905e3df2624869aa7f9a8635eb35bdcf9ce68a26fab691f9dada61","previous_duty_dependent_root":"0x935569bdc1aaad65dbeb532a125390d039058924ea81799238ed53e4e4639a11"}`), &res)) + + input := []byte(`{"slot":"525278","block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","state":"0x749a95b1355828b758864ea601c007e69aabed7b34a0f2084c43c26242f77e28","epoch_transition":false}`) + require.NoError(t, json.Unmarshal(input, &res)) + rt, err := json.Marshal(&res) + require.NoError(t, err) + assert.Equal(t, string(input), string(rt), "dependent roots kept from the previous event") + }) } diff --git a/api/v1/headeventv2.go b/api/v1/headeventv2.go new file mode 100644 index 00000000..28272ede --- /dev/null +++ b/api/v1/headeventv2.go @@ -0,0 +1,147 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package v1 + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// HeadEventV2 is the data for the head_v2 event. +type HeadEventV2 struct { + Version spec.DataVersion + Slot phase0.Slot + Block phase0.Root + State phase0.Root + PayloadStatus string + EpochTransition bool + CurrentEpochDependentRoot phase0.Root + NextEpochDependentRoot phase0.Root + ExecutionOptimistic bool +} + +// headEventV2JSON is the spec representation of the versioned event. +type headEventV2JSON struct { + Version spec.DataVersion `json:"version"` + Data *headEventV2DataJSON `json:"data"` +} + +// headEventV2DataJSON is the spec representation of the event data. +type headEventV2DataJSON struct { + Slot string `json:"slot"` + Block string `json:"block"` + State string `json:"state"` + PayloadStatus string `json:"payload_status"` + EpochTransition bool `json:"epoch_transition"` + CurrentEpochDependentRoot string `json:"current_epoch_dependent_root"` + NextEpochDependentRoot string `json:"next_epoch_dependent_root"` + ExecutionOptimistic bool `json:"execution_optimistic"` +} + +// MarshalJSON implements json.Marshaler. +func (e *HeadEventV2) MarshalJSON() ([]byte, error) { + return json.Marshal(&headEventV2JSON{ + Version: e.Version, + Data: &headEventV2DataJSON{ + Slot: fmt.Sprintf("%d", e.Slot), + Block: fmt.Sprintf("%#x", e.Block), + State: fmt.Sprintf("%#x", e.State), + PayloadStatus: e.PayloadStatus, + EpochTransition: e.EpochTransition, + CurrentEpochDependentRoot: fmt.Sprintf("%#x", e.CurrentEpochDependentRoot), + NextEpochDependentRoot: fmt.Sprintf("%#x", e.NextEpochDependentRoot), + ExecutionOptimistic: e.ExecutionOptimistic, + }, + }) +} + +// String returns a string version of the structure. +func (e *HeadEventV2) String() string { + data, err := json.Marshal(e) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (e *HeadEventV2) UnmarshalJSON(input []byte) error { + var event headEventV2JSON + if err := json.Unmarshal(input, &event); err != nil { + return errors.Wrap(err, "invalid JSON") + } + if event.Version == spec.DataVersionUnknown { + return errors.New("version missing") + } + if event.Data == nil { + return errors.New("data missing") + } + if event.Data.PayloadStatus == "" { + return errors.New("payload status missing") + } + if event.Data.Slot == "" { + return errors.New("slot missing") + } + + slot, err := strconv.ParseUint(event.Data.Slot, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value for slot") + } + if event.Data.Block == "" { + return errors.New("block missing") + } + if err := decodeFixedBytes(e.Block[:], event.Data.Block, "block"); err != nil { + return err + } + if event.Data.State == "" { + return errors.New("state missing") + } + if err := decodeFixedBytes(e.State[:], event.Data.State, "state"); err != nil { + return err + } + if event.Data.CurrentEpochDependentRoot == "" { + return errors.New("current epoch dependent root missing") + } + if err := decodeFixedBytes( + e.CurrentEpochDependentRoot[:], + event.Data.CurrentEpochDependentRoot, + "current epoch dependent root", + ); err != nil { + return err + } + if event.Data.NextEpochDependentRoot == "" { + return errors.New("next epoch dependent root missing") + } + if err := decodeFixedBytes( + e.NextEpochDependentRoot[:], + event.Data.NextEpochDependentRoot, + "next epoch dependent root", + ); err != nil { + return err + } + + e.Version = event.Version + e.Slot = phase0.Slot(slot) + e.PayloadStatus = event.Data.PayloadStatus + e.EpochTransition = event.Data.EpochTransition + e.ExecutionOptimistic = event.Data.ExecutionOptimistic + + return nil +} diff --git a/api/v1/headeventv2_test.go b/api/v1/headeventv2_test.go new file mode 100644 index 00000000..0e4b70fd --- /dev/null +++ b/api/v1/headeventv2_test.go @@ -0,0 +1,116 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package v1_test + +import ( + "encoding/json" + "testing" + + api "github.com/attestantio/go-eth2-client/api/v1" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/phase0" + require "github.com/stretchr/testify/require" +) + +func TestHeadEventV2JSONErrors(t *testing.T) { + tests := []struct { + name string + input []byte + err string + }{ + { + name: "InvalidJSON", + input: []byte(`[]`), + err: "invalid JSON: json: cannot unmarshal array into Go value of type v1.headEventV2JSON", + }, + { + name: "VersionMissing", + input: []byte(`{"data":{}}`), + err: "version missing", + }, + { + name: "DataMissing", + input: []byte(`{"version":"gloas"}`), + err: "data missing", + }, + { + name: "PayloadStatusMissing", + input: []byte(`{"version":"gloas","data":{"slot":"10","block":"0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf","state":"0x600e852a08c1200654ddf11025f1ceacb3c2e74bdd5c630cde0838b2591b69f9","current_epoch_dependent_root":"0x5e0043f107cb57913498fbf2f99ff55e730bf1e151f02f221e977c91a90a0e91","next_epoch_dependent_root":"0x7f1154a218dc77addb1fe144266e83c04f67c8dfd03aacf955df05308cd8b27c"}}`), + err: "payload status missing", + }, + { + name: "SlotMissing", + input: []byte(`{"version":"gloas","data":{"payload_status":"empty"}}`), + err: "slot missing", + }, + { + name: "SlotInvalid", + input: []byte(`{"version":"gloas","data":{"slot":"invalid","payload_status":"empty"}}`), + err: "invalid value for slot: strconv.ParseUint: parsing \"invalid\": invalid syntax", + }, + { + name: "BlockMissing", + input: []byte(`{"version":"gloas","data":{"slot":"10","payload_status":"empty"}}`), + err: "block missing", + }, + { + name: "BlockInvalid", + input: []byte(`{"version":"gloas","data":{"slot":"10","block":"0x01","payload_status":"empty"}}`), + err: "incorrect length 1 for block", + }, + { + name: "StateMissing", + input: []byte(`{"version":"gloas","data":{"slot":"10","block":"0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf","payload_status":"empty"}}`), + err: "state missing", + }, + { + name: "CurrentEpochDependentRootMissing", + input: []byte(`{"version":"gloas","data":{"slot":"10","block":"0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf","state":"0x600e852a08c1200654ddf11025f1ceacb3c2e74bdd5c630cde0838b2591b69f9","payload_status":"empty"}}`), + err: "current epoch dependent root missing", + }, + { + name: "NextEpochDependentRootMissing", + input: []byte(`{"version":"gloas","data":{"slot":"10","block":"0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf","state":"0x600e852a08c1200654ddf11025f1ceacb3c2e74bdd5c630cde0838b2591b69f9","payload_status":"empty","current_epoch_dependent_root":"0x5e0043f107cb57913498fbf2f99ff55e730bf1e151f02f221e977c91a90a0e91"}}`), + err: "next epoch dependent root missing", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var event api.HeadEventV2 + require.EqualError(t, json.Unmarshal(test.input, &event), test.err) + }) + } +} + +func TestHeadEventV2JSON(t *testing.T) { + input := []byte(`{"version":"gloas","data":{"slot":"10","block":"0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf","state":"0x600e852a08c1200654ddf11025f1ceacb3c2e74bdd5c630cde0838b2591b69f9","payload_status":"empty","epoch_transition":false,"current_epoch_dependent_root":"0x5e0043f107cb57913498fbf2f99ff55e730bf1e151f02f221e977c91a90a0e91","next_epoch_dependent_root":"0x7f1154a218dc77addb1fe144266e83c04f67c8dfd03aacf955df05308cd8b27c","execution_optimistic":true}}`) + + var actual api.HeadEventV2 + require.NoError(t, json.Unmarshal(input, &actual)) + require.Equal(t, spec.DataVersionGloas, actual.Version) + require.Equal(t, phase0.Slot(10), actual.Slot) + require.Equal(t, phase0.Root{0x9a, 0x2f, 0xef, 0xd2, 0xfd, 0xb5, 0x7f, 0x74, 0x99, 0x3c, 0x77, 0x80, 0xea, 0x5b, 0x90, 0x30, 0xd2, 0x89, 0x7b, 0x61, 0x5b, 0x89, 0xf8, 0x08, 0x01, 0x1c, 0xa5, 0xae, 0xbe, 0xd5, 0x4e, 0xaf}, actual.Block) + require.Equal(t, phase0.Root{0x60, 0x0e, 0x85, 0x2a, 0x08, 0xc1, 0x20, 0x06, 0x54, 0xdd, 0xf1, 0x10, 0x25, 0xf1, 0xce, 0xac, 0xb3, 0xc2, 0xe7, 0x4b, 0xdd, 0x5c, 0x63, 0x0c, 0xde, 0x08, 0x38, 0xb2, 0x59, 0x1b, 0x69, 0xf9}, actual.State) + require.Equal(t, "empty", actual.PayloadStatus) + require.False(t, actual.EpochTransition) + require.Equal(t, phase0.Root{0x5e, 0x00, 0x43, 0xf1, 0x07, 0xcb, 0x57, 0x91, 0x34, 0x98, 0xfb, 0xf2, 0xf9, 0x9f, 0xf5, 0x5e, 0x73, 0x0b, 0xf1, 0xe1, 0x51, 0xf0, 0x2f, 0x22, 0x1e, 0x97, 0x7c, 0x91, 0xa9, 0x0a, 0x0e, 0x91}, actual.CurrentEpochDependentRoot) + require.Equal(t, phase0.Root{0x7f, 0x11, 0x54, 0xa2, 0x18, 0xdc, 0x77, 0xad, 0xdb, 0x1f, 0xe1, 0x44, 0x26, 0x6e, 0x83, 0xc0, 0x4f, 0x67, 0xc8, 0xdf, 0xd0, 0x3a, 0xac, 0xf9, 0x55, 0xdf, 0x05, 0x30, 0x8c, 0xd8, 0xb2, 0x7c}, actual.NextEpochDependentRoot) + require.True(t, actual.ExecutionOptimistic) + + output, err := json.Marshal(&actual) + require.NoError(t, err) + require.JSONEq(t, string(input), string(output)) + require.JSONEq(t, string(input), actual.String()) +} diff --git a/api/v1/lightclientevents.go b/api/v1/lightclientevents.go new file mode 100644 index 00000000..5e097152 --- /dev/null +++ b/api/v1/lightclientevents.go @@ -0,0 +1,71 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package v1 + +import ( + "encoding/json" + "errors" + + "github.com/attestantio/go-eth2-client/spec" +) + +// LightClientFinalityUpdateEvent is a versioned light client finality update event. +type LightClientFinalityUpdateEvent struct { + Version spec.DataVersion `json:"version"` + Data json.RawMessage `json:"data"` +} + +// LightClientOptimisticUpdateEvent is a versioned light client optimistic update event. +type LightClientOptimisticUpdateEvent struct { + Version spec.DataVersion `json:"version"` + Data json.RawMessage `json:"data"` +} + +func (e *LightClientFinalityUpdateEvent) UnmarshalJSON(input []byte) error { + version, data, err := decodeLightClientEvent(input) + if err != nil { + return err + } + e.Version, e.Data = version, data + + return nil +} + +func (e *LightClientOptimisticUpdateEvent) UnmarshalJSON(input []byte) error { + version, data, err := decodeLightClientEvent(input) + if err != nil { + return err + } + e.Version, e.Data = version, data + + return nil +} + +func decodeLightClientEvent(input []byte) (spec.DataVersion, json.RawMessage, error) { + var event struct { + Version spec.DataVersion `json:"version"` + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(input, &event); err != nil { + return 0, nil, err + } + if event.Version == spec.DataVersionUnknown { + return 0, nil, errors.New("version missing") + } + if len(event.Data) == 0 || string(event.Data) == "null" { + return 0, nil, errors.New("data missing") + } + + return event.Version, event.Data, nil +} diff --git a/api/v1/lightclientevents_test.go b/api/v1/lightclientevents_test.go new file mode 100644 index 00000000..24d32370 --- /dev/null +++ b/api/v1/lightclientevents_test.go @@ -0,0 +1,47 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package v1_test + +import ( + "encoding/json" + "testing" + + api "github.com/attestantio/go-eth2-client/api/v1" + "github.com/attestantio/go-eth2-client/spec" + "github.com/stretchr/testify/require" +) + +func TestLightClientEventsJSON(t *testing.T) { + tests := []struct { + name string + newEvent func() any + }{ + {name: "Finality", newEvent: func() any { return &api.LightClientFinalityUpdateEvent{} }}, + {name: "Optimistic", newEvent: func() any { return &api.LightClientOptimisticUpdateEvent{} }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + for _, input := range []string{`null`, `{"version":"altair"}`, `{"version":"altair","data":null}`, `{"data":{}}`} { + t.Run(input, func(t *testing.T) { + require.Error(t, json.Unmarshal([]byte(input), test.newEvent())) + }) + } + }) + } + + var event api.LightClientFinalityUpdateEvent + require.NoError(t, json.Unmarshal([]byte(`{"version":"altair","data":{"signature_slot":"1"}}`), &event)) + require.Equal(t, spec.DataVersionAltair, event.Version) + require.JSONEq(t, `{"signature_slot":"1"}`, string(event.Data)) +} diff --git a/api/v1/ptcduty.go b/api/v1/ptcduty.go new file mode 100644 index 00000000..5b1c657b --- /dev/null +++ b/api/v1/ptcduty.go @@ -0,0 +1,115 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package v1 + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// PTCDuty is the data regarding which validators have the duty to attest to +// payload timeliness in a slot. +// +// The payload timeliness committee is a flat, per-slot committee, so unlike +// AttesterDuty this carries no committee index, committee length or +// validator-committee index. +type PTCDuty struct { + // PubKey is the public key of the validator that should attest. + PubKey phase0.BLSPubKey + // Slot is the slot in which the validator should attest. + Slot phase0.Slot + // ValidatorIndex is the index of the validator that should attest. + ValidatorIndex phase0.ValidatorIndex +} + +// ptcDutyJSON is the spec representation of the struct. +type ptcDutyJSON struct { + PubKey string `json:"pubkey"` + ValidatorIndex string `json:"validator_index"` + Slot string `json:"slot"` +} + +// MarshalJSON implements json.Marshaler. +func (p *PTCDuty) MarshalJSON() ([]byte, error) { + return json.Marshal(&ptcDutyJSON{ + PubKey: fmt.Sprintf("%#x", p.PubKey), + ValidatorIndex: fmt.Sprintf("%d", p.ValidatorIndex), + Slot: fmt.Sprintf("%d", p.Slot), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (p *PTCDuty) UnmarshalJSON(input []byte) error { + var err error + + var ptcDutyJSON ptcDutyJSON + if err = json.Unmarshal(input, &ptcDutyJSON); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if ptcDutyJSON.PubKey == "" { + return errors.New("public key missing") + } + + pubKey, err := hex.DecodeString(strings.TrimPrefix(ptcDutyJSON.PubKey, "0x")) + if err != nil { + return errors.Wrap(err, "invalid value for public key") + } + + if len(pubKey) != publicKeyLength { + return errors.New("incorrect length for public key") + } + + copy(p.PubKey[:], pubKey) + + if ptcDutyJSON.ValidatorIndex == "" { + return errors.New("validator index missing") + } + + validatorIndex, err := strconv.ParseUint(ptcDutyJSON.ValidatorIndex, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value for validator index") + } + + p.ValidatorIndex = phase0.ValidatorIndex(validatorIndex) + + if ptcDutyJSON.Slot == "" { + return errors.New("slot missing") + } + + slot, err := strconv.ParseUint(ptcDutyJSON.Slot, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value for slot") + } + + p.Slot = phase0.Slot(slot) + + return nil +} + +// String returns a string version of the structure. +func (p *PTCDuty) String() string { + data, err := json.Marshal(p) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/api/v1/ptcduty_test.go b/api/v1/ptcduty_test.go new file mode 100644 index 00000000..6a1fd15c --- /dev/null +++ b/api/v1/ptcduty_test.go @@ -0,0 +1,140 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package v1_test + +import ( + "encoding/json" + "testing" + + api "github.com/attestantio/go-eth2-client/api/v1" + require "github.com/stretchr/testify/require" + + "github.com/stretchr/testify/assert" +) + +// TestPTCDutyJSON exercises the PTC duty codec. +// +// The Good case is a duty captured verbatim from a live Glamsterdam devnet-7 +// Lighthouse node (POST /eth/v1/validator/duties/ptc/7537), so the field set +// and the string-encoded integers are the real wire shape rather than an +// inference from the schema. +func TestPTCDutyJSON(t *testing.T) { + tests := []struct { + name string + input []byte + err string + }{ + { + name: "Empty", + err: "unexpected end of JSON input", + }, + { + name: "JSONBad", + input: []byte("[]"), + err: "invalid JSON: json: cannot unmarshal array into Go value of type v1.ptcDutyJSON", + }, + { + name: "PubKeyMissing", + input: []byte(`{"validator_index":"8","slot":"60300"}`), + err: "public key missing", + }, + { + name: "PubKeyWrongType", + input: []byte(`{"pubkey":true,"validator_index":"8","slot":"60300"}`), + err: "invalid JSON: json: cannot unmarshal bool into Go struct field ptcDutyJSON.pubkey of type string", + }, + { + name: "PubKeyInvalid", + input: []byte(`{"pubkey":"invalid","validator_index":"8","slot":"60300"}`), + err: "invalid value for public key: encoding/hex: invalid byte: U+0069 'i'", + }, + { + name: "PubKeyShort", + input: []byte(`{"pubkey":"0x2cb106b7bc1ecae219e0ae1830a509ed18a042b56a2779f4033419de69ba8ae8017090caed1f5377bfa68506157360","validator_index":"8","slot":"60300"}`), + err: "incorrect length for public key", + }, + { + name: "PubKeyLong", + input: []byte(`{"pubkey":"0xb7b72cb106b7bc1ecae219e0ae1830a509ed18a042b56a2779f4033419de69ba8ae8017090caed1f5377bfa68506157360","validator_index":"8","slot":"60300"}`), + err: "incorrect length for public key", + }, + { + name: "ValidatorIndexMissing", + input: []byte(`{"pubkey":"0xb72cb106b7bc1ecae219e0ae1830a509ed18a042b56a2779f4033419de69ba8ae8017090caed1f5377bfa68506157360","slot":"60300"}`), + err: "validator index missing", + }, + { + name: "ValidatorIndexWrongType", + input: []byte(`{"pubkey":"0xb72cb106b7bc1ecae219e0ae1830a509ed18a042b56a2779f4033419de69ba8ae8017090caed1f5377bfa68506157360","validator_index":8,"slot":"60300"}`), + err: "invalid JSON: json: cannot unmarshal number into Go struct field ptcDutyJSON.validator_index of type string", + }, + { + name: "ValidatorIndexInvalid", + input: []byte(`{"pubkey":"0xb72cb106b7bc1ecae219e0ae1830a509ed18a042b56a2779f4033419de69ba8ae8017090caed1f5377bfa68506157360","validator_index":"-1","slot":"60300"}`), + err: "invalid value for validator index: strconv.ParseUint: parsing \"-1\": invalid syntax", + }, + { + name: "SlotMissing", + input: []byte(`{"pubkey":"0xb72cb106b7bc1ecae219e0ae1830a509ed18a042b56a2779f4033419de69ba8ae8017090caed1f5377bfa68506157360","validator_index":"8"}`), + err: "slot missing", + }, + { + name: "SlotWrongType", + input: []byte(`{"pubkey":"0xb72cb106b7bc1ecae219e0ae1830a509ed18a042b56a2779f4033419de69ba8ae8017090caed1f5377bfa68506157360","validator_index":"8","slot":true}`), + err: "invalid JSON: json: cannot unmarshal bool into Go struct field ptcDutyJSON.slot of type string", + }, + { + name: "SlotInvalid", + input: []byte(`{"pubkey":"0xb72cb106b7bc1ecae219e0ae1830a509ed18a042b56a2779f4033419de69ba8ae8017090caed1f5377bfa68506157360","validator_index":"8","slot":"-1"}`), + err: "invalid value for slot: strconv.ParseUint: parsing \"-1\": invalid syntax", + }, + { + name: "Good", + input: []byte(`{"pubkey":"0xb72cb106b7bc1ecae219e0ae1830a509ed18a042b56a2779f4033419de69ba8ae8017090caed1f5377bfa68506157360","validator_index":"8","slot":"60300"}`), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var res api.PTCDuty + err := json.Unmarshal(test.input, &res) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + rt, err := json.Marshal(&res) + require.NoError(t, err) + assert.Equal(t, string(test.input), string(rt)) + assert.Equal(t, string(rt), res.String()) + } + }) + } +} + +// TestPTCDutyFields confirms the decoded values land in the right fields, and +// that a PTC duty carries no committee information. A round-trip test alone +// would pass even if two same-typed fields were transposed. +func TestPTCDutyFields(t *testing.T) { + input := []byte(`{"pubkey":"0xb72cb106b7bc1ecae219e0ae1830a509ed18a042b56a2779f4033419de69ba8ae8017090caed1f5377bfa68506157360","validator_index":"8","slot":"60300"}`) + + var duty api.PTCDuty + require.NoError(t, json.Unmarshal(input, &duty)) + + require.Equal(t, + "0xb72cb106b7bc1ecae219e0ae1830a509ed18a042b56a2779f4033419de69ba8ae8017090caed1f5377bfa68506157360", + duty.PubKey.String(), + ) + require.Equal(t, uint64(8), uint64(duty.ValidatorIndex)) + require.Equal(t, uint64(60300), uint64(duty.Slot)) +} diff --git a/api/versionedepbsproposal.go b/api/versionedepbsproposal.go new file mode 100644 index 00000000..03494baa --- /dev/null +++ b/api/versionedepbsproposal.go @@ -0,0 +1,306 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "errors" + "fmt" + "math/big" + + apiv1gloas "github.com/attestantio/go-eth2-client/api/v1/gloas" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// VersionedEPBSProposal contains a versioned ePBS block proposal, as produced by +// the gloas-onwards block production endpoint. +type VersionedEPBSProposal struct { + Version spec.DataVersion + // ExecutionPayloadIncluded selects which arm below carries the proposal. + ExecutionPayloadIncluded bool + BuilderIndex *gloas.BuilderIndex + ConsensusValue *big.Int + ExecutionValue *big.Int + // Gloas is the proposal when the execution payload is not included. + Gloas *gloas.BeaconBlock + // GloasContents is the proposal when the execution payload is included. + GloasContents *apiv1gloas.BlockContents + // BeaconBlockBodyRoot is the hash tree root of the proposal's beacon block + // body, calculated with the codec that decoded the proposal -- the one + // aware of whatever preset the connected node actually runs. The + // generated HashTreeRoot methods on the gloas types inline mainnet preset + // sizes (for example a 512-bit sync committee bitvector) as Go literals + // and never consult that codec, so they compute the wrong root on any + // other preset. A beacon block's own root has no preset-dependent field + // of its own: it merkleizes slot, proposer index, parent root, state root + // and this body root, exactly like phase0.BeaconBlockHeader. The body + // root is therefore the only preset-sensitive quantity in a block, and + // whatever obtains the proposal is responsible for setting this field + // with the spec-aware root. BodyRoot errors when it is unset rather than + // falling back to the generated, potentially wrong, Body.HashTreeRoot(). + BeaconBlockBodyRoot *phase0.Root +} + +// Slot returns the slot of the proposal. +func (v *VersionedEPBSProposal) Slot() (phase0.Slot, error) { + block, err := v.block() + if err != nil { + return 0, err + } + + return block.Slot, nil +} + +// ProposerIndex returns the proposer index of the proposal. +func (v *VersionedEPBSProposal) ProposerIndex() (phase0.ValidatorIndex, error) { + block, err := v.block() + if err != nil { + return 0, err + } + + return block.ProposerIndex, nil +} + +// RandaoReveal returns the RANDAO reveal of the proposal. +func (v *VersionedEPBSProposal) RandaoReveal() (phase0.BLSSignature, error) { + block, err := v.block() + if err != nil { + return phase0.BLSSignature{}, err + } + + if block.Body == nil { + return phase0.BLSSignature{}, errors.New("no gloas beacon block body") + } + + return block.Body.RANDAOReveal, nil +} + +// ParentRoot returns the parent root of the proposal. +func (v *VersionedEPBSProposal) ParentRoot() (phase0.Root, error) { + block, err := v.block() + if err != nil { + return phase0.Root{}, err + } + + return block.ParentRoot, nil +} + +// StateRoot returns the state root of the proposal. +func (v *VersionedEPBSProposal) StateRoot() (phase0.Root, error) { + block, err := v.block() + if err != nil { + return phase0.Root{}, err + } + + return block.StateRoot, nil +} + +// BodyRoot returns the hash tree root of the proposal's beacon block body, +// the value a proposer signs over. +// +// It returns the retained BeaconBlockBodyRoot rather than recomputing it: the +// generated Body.HashTreeRoot() inlines mainnet preset sizes, so recomputing +// here would silently sign the wrong root on any other preset. There is +// deliberately no fallback to that computation. +func (v *VersionedEPBSProposal) BodyRoot() (phase0.Root, error) { + block, err := v.block() + if err != nil { + return phase0.Root{}, err + } + + if block.Body == nil { + return phase0.Root{}, errors.New("no gloas beacon block body") + } + + if v.BeaconBlockBodyRoot == nil { + return phase0.Root{}, errors.New("no beacon block body root") + } + + return *v.BeaconBlockBodyRoot, nil +} + +// Root returns the hash tree root of the proposal's beacon block. +// +// It composes a phase0.BeaconBlockHeader from the block's own fields plus the +// retained BodyRoot, rather than calling block.HashTreeRoot(): the generated +// block hasher recurses into the body and inherits the same mainnet-baked +// sizes that make Body.HashTreeRoot() wrong on other presets. The header's +// fields are all preset-independent, so composing it this way can never +// disagree with BodyRoot() -- do not "simplify" this back to +// block.HashTreeRoot(), that reintroduces the bug BeaconBlockBodyRoot exists +// to avoid. +func (v *VersionedEPBSProposal) Root() (phase0.Root, error) { + block, err := v.block() + if err != nil { + return phase0.Root{}, err + } + + bodyRoot, err := v.BodyRoot() + if err != nil { + return phase0.Root{}, err + } + + root, err := (&phase0.BeaconBlockHeader{ + Slot: block.Slot, + ProposerIndex: block.ProposerIndex, + ParentRoot: block.ParentRoot, + StateRoot: block.StateRoot, + BodyRoot: bodyRoot, + }).HashTreeRoot() + if err != nil { + return phase0.Root{}, err + } + + return phase0.Root(root), nil +} + +// ExecutionPayloadEnvelope returns the execution payload envelope that travelled +// with the block. It is an error to ask for it when the execution payload was +// not included: the caller must fetch the envelope from the producing node. +func (v *VersionedEPBSProposal) ExecutionPayloadEnvelope() (*gloas.ExecutionPayloadEnvelope, error) { + contents, err := v.contents() + if err != nil { + return nil, err + } + + if contents.ExecutionPayloadEnvelope == nil { + return nil, errors.New("no gloas execution payload envelope") + } + + return contents.ExecutionPayloadEnvelope, nil +} + +// KZGProofs returns the KZG proofs that travelled with the block. +func (v *VersionedEPBSProposal) KZGProofs() ([]deneb.KZGProof, error) { + contents, err := v.contents() + if err != nil { + return nil, err + } + + return contents.KZGProofs, nil +} + +// Blobs returns the blobs that travelled with the block. +func (v *VersionedEPBSProposal) Blobs() ([]deneb.Blob, error) { + contents, err := v.contents() + if err != nil { + return nil, err + } + + return contents.Blobs, nil +} + +// Value returns the total value of the proposal, or nil when its execution +// value was not supplied by the beacon node. +func (v *VersionedEPBSProposal) Value() *big.Int { + if v.ExecutionValue == nil { + return nil + } + + value := big.NewInt(0) + if v.ConsensusValue != nil { + value.Add(value, v.ConsensusValue) + } + + return value.Add(value, v.ExecutionValue) +} + +// IsEmpty returns true if no proposal is populated. +func (v *VersionedEPBSProposal) IsEmpty() bool { + return v.Gloas == nil && v.GloasContents == nil +} + +// String returns a string version of the structure. +func (v *VersionedEPBSProposal) String() string { + switch v.Version { + case spec.DataVersionPhase0, spec.DataVersionAltair, spec.DataVersionBellatrix, + spec.DataVersionCapella, spec.DataVersionDeneb, spec.DataVersionElectra, + spec.DataVersionFulu: + return "" + case spec.DataVersionGloas: + if v.ExecutionPayloadIncluded { + if v.GloasContents == nil { + return "" + } + + return v.GloasContents.String() + } + + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() + default: + return "unknown version" + } +} + +// block returns the beacon block the proposal carries, or an error explaining +// why there is none. Every accessor that reads the block funnels through here, +// so the version, arm-selection and nil checks cannot drift between them. +func (v *VersionedEPBSProposal) block() (*gloas.BeaconBlock, error) { + switch v.Version { + case spec.DataVersionPhase0, spec.DataVersionAltair, spec.DataVersionBellatrix, + spec.DataVersionCapella, spec.DataVersionDeneb, spec.DataVersionElectra, + spec.DataVersionFulu: + return nil, fmt.Errorf("no epbs proposal in %s", v.Version) + case spec.DataVersionGloas: + if v.ExecutionPayloadIncluded { + contents, err := v.contents() + if err != nil { + return nil, err + } + + if contents.Block == nil { + return nil, errors.New("no gloas beacon block") + } + + return contents.Block, nil + } + + if v.Gloas == nil { + return nil, errors.New("no gloas beacon block") + } + + return v.Gloas, nil + default: + return nil, ErrUnsupportedVersion + } +} + +// contents returns the block contents the proposal carries when the execution +// payload is included, or an error explaining why there are none. +func (v *VersionedEPBSProposal) contents() (*apiv1gloas.BlockContents, error) { + switch v.Version { + case spec.DataVersionPhase0, spec.DataVersionAltair, spec.DataVersionBellatrix, + spec.DataVersionCapella, spec.DataVersionDeneb, spec.DataVersionElectra, + spec.DataVersionFulu: + return nil, fmt.Errorf("no epbs proposal in %s", v.Version) + case spec.DataVersionGloas: + if !v.ExecutionPayloadIncluded { + return nil, errors.New("no block contents; the execution payload was not included") + } + + if v.GloasContents == nil { + return nil, errors.New("no gloas block contents") + } + + return v.GloasContents, nil + default: + return nil, ErrUnsupportedVersion + } +} diff --git a/api/versionedepbsproposal_test.go b/api/versionedepbsproposal_test.go new file mode 100644 index 00000000..0674c247 --- /dev/null +++ b/api/versionedepbsproposal_test.go @@ -0,0 +1,722 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api_test + +import ( + "math/big" + "testing" + + "github.com/attestantio/go-eth2-client/api" + apiv1gloas "github.com/attestantio/go-eth2-client/api/v1/gloas" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + require "github.com/stretchr/testify/require" +) + +// TestVersionedEPBSProposalSlot verifies that the slot is read from whichever +// arm the execution_payload_included flag selects, and that every way of +// arriving without a block is an error rather than slot 0. Reporting slot 0 for +// an absent block would let a caller sign a proposal for the wrong slot. +func TestVersionedEPBSProposalSlot(t *testing.T) { + tests := []struct { + name string + proposal *api.VersionedEPBSProposal + expected phase0.Slot + err string + }{ + { + name: "PayloadExcluded", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + Gloas: &gloas.BeaconBlock{Slot: 42}, + }, + expected: 42, + }, + { + name: "PayloadIncluded", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + GloasContents: &apiv1gloas.BlockContents{ + Block: &gloas.BeaconBlock{Slot: 43}, + }, + }, + expected: 43, + }, + { + name: "PayloadExcludedNilBlock", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + }, + err: "no gloas beacon block", + }, + { + name: "PayloadIncludedNilContents", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + }, + err: "no gloas block contents", + }, + { + // Contents present but its block absent: the flag says the payload + // travelled with the block, so a missing block is still an error. + name: "PayloadIncludedNilBlock", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + GloasContents: &apiv1gloas.BlockContents{}, + }, + err: "no gloas beacon block", + }, + { + name: "PreGloas", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionFulu, + }, + err: "no epbs proposal in fulu", + }, + { + name: "UnknownVersion", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersion(99), + }, + err: "unsupported version", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + slot, err := test.proposal.Slot() + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + require.Equal(t, test.expected, slot) + } + }) + } +} + +// TestVersionedEPBSProposalProposerIndex verifies the proposer index is read +// from whichever arm the execution_payload_included flag selects. The +// proposer signs the proposal together with this index, so returning the zero +// value for an absent block would let a caller sign with the wrong validator. +func TestVersionedEPBSProposalProposerIndex(t *testing.T) { + tests := []struct { + name string + proposal *api.VersionedEPBSProposal + expected phase0.ValidatorIndex + err string + }{ + { + name: "PayloadExcluded", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + Gloas: &gloas.BeaconBlock{ProposerIndex: 42}, + }, + expected: 42, + }, + { + name: "PayloadIncluded", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + GloasContents: &apiv1gloas.BlockContents{ + Block: &gloas.BeaconBlock{ProposerIndex: 43}, + }, + }, + expected: 43, + }, + { + name: "PayloadExcludedNilBlock", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + }, + err: "no gloas beacon block", + }, + { + name: "PayloadIncludedNilContents", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + }, + err: "no gloas block contents", + }, + { + name: "PayloadIncludedNilBlock", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + GloasContents: &apiv1gloas.BlockContents{}, + }, + err: "no gloas beacon block", + }, + { + name: "PreGloas", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionFulu, + }, + err: "no epbs proposal in fulu", + }, + { + name: "UnknownVersion", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersion(99), + }, + err: "unsupported version", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + index, err := test.proposal.ProposerIndex() + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + require.Equal(t, test.expected, index) + } + }) + } +} + +// TestVersionedEPBSProposalRandaoReveal verifies the RANDAO reveal is read +// through the same arm selection as the slot. The block production endpoint +// compares this against the reveal it sent, so reading it from the wrong arm +// would make every proposal look inconsistent. +func TestVersionedEPBSProposalRandaoReveal(t *testing.T) { + reveal := phase0.BLSSignature{0x01, 0x02} + + proposal := &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + GloasContents: &apiv1gloas.BlockContents{ + Block: &gloas.BeaconBlock{ + Body: &gloas.BeaconBlockBody{RANDAOReveal: reveal}, + }, + }, + } + + got, err := proposal.RandaoReveal() + require.NoError(t, err) + require.Equal(t, reveal, got) + + // A block with no body cannot yield a reveal, and must not report the zero + // signature as if it were one. + _, err = (&api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + Gloas: &gloas.BeaconBlock{}, + }).RandaoReveal() + require.EqualError(t, err, "no gloas beacon block body") +} + +// TestVersionedEPBSProposalParentRoot verifies the parent root is read from +// whichever arm the execution_payload_included flag selects, and that every +// way of arriving without a block is an error. Each fixture also carries a +// distinct state root, so a fixed accessor that read the wrong field would +// fail rather than pass by coincidence. +func TestVersionedEPBSProposalParentRoot(t *testing.T) { + tests := []struct { + name string + proposal *api.VersionedEPBSProposal + expected phase0.Root + err string + }{ + { + name: "PayloadExcluded", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + Gloas: &gloas.BeaconBlock{ParentRoot: phase0.Root{0x01}, StateRoot: phase0.Root{0xaa}}, + }, + expected: phase0.Root{0x01}, + }, + { + name: "PayloadIncluded", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + GloasContents: &apiv1gloas.BlockContents{ + Block: &gloas.BeaconBlock{ParentRoot: phase0.Root{0x02}, StateRoot: phase0.Root{0xbb}}, + }, + }, + expected: phase0.Root{0x02}, + }, + { + name: "PayloadExcludedNilBlock", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + }, + err: "no gloas beacon block", + }, + { + name: "PayloadIncludedNilContents", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + }, + err: "no gloas block contents", + }, + { + name: "PayloadIncludedNilBlock", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + GloasContents: &apiv1gloas.BlockContents{}, + }, + err: "no gloas beacon block", + }, + { + name: "PreGloas", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionFulu, + }, + err: "no epbs proposal in fulu", + }, + { + name: "UnknownVersion", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersion(99), + }, + err: "unsupported version", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root, err := test.proposal.ParentRoot() + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + require.Equal(t, test.expected, root) + } + }) + } +} + +// TestVersionedEPBSProposalStateRoot mirrors TestVersionedEPBSProposalParentRoot +// for the state root, with distinct fixture values so a parent/state field +// swap in the accessor would fail rather than pass by coincidence. +func TestVersionedEPBSProposalStateRoot(t *testing.T) { + tests := []struct { + name string + proposal *api.VersionedEPBSProposal + expected phase0.Root + err string + }{ + { + name: "PayloadExcluded", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + Gloas: &gloas.BeaconBlock{StateRoot: phase0.Root{0x03}, ParentRoot: phase0.Root{0xcc}}, + }, + expected: phase0.Root{0x03}, + }, + { + name: "PayloadIncluded", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + GloasContents: &apiv1gloas.BlockContents{ + Block: &gloas.BeaconBlock{StateRoot: phase0.Root{0x04}, ParentRoot: phase0.Root{0xdd}}, + }, + }, + expected: phase0.Root{0x04}, + }, + { + name: "PayloadExcludedNilBlock", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + }, + err: "no gloas beacon block", + }, + { + name: "PayloadIncludedNilContents", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + }, + err: "no gloas block contents", + }, + { + name: "PayloadIncludedNilBlock", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + GloasContents: &apiv1gloas.BlockContents{}, + }, + err: "no gloas beacon block", + }, + { + name: "PreGloas", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionFulu, + }, + err: "no epbs proposal in fulu", + }, + { + name: "UnknownVersion", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersion(99), + }, + err: "unsupported version", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root, err := test.proposal.StateRoot() + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + require.Equal(t, test.expected, root) + } + }) + } +} + +// TestVersionedEPBSProposalBodyRoot verifies BodyRoot returns the hash tree +// root of the block's body, not the block itself: dirk's proposer domain +// signs over the body root, so returning the block root instead would sign +// gibberish. The happy-path cases assert the result matches an +// independently-computed body HTR and differs from the block's own HTR, +// rather than merely asserting no error. +func TestVersionedEPBSProposalBodyRoot(t *testing.T) { + newBlock := func(reveal byte) *gloas.BeaconBlock { + return &gloas.BeaconBlock{ + Slot: 1, + Body: &gloas.BeaconBlockBody{RANDAOReveal: phase0.BLSSignature{reveal}}, + } + } + + t.Run("PayloadExcluded", func(t *testing.T) { + block := newBlock(0x01) + wantBodyRoot, err := block.Body.HashTreeRoot() + require.NoError(t, err) + blockRoot, err := block.HashTreeRoot() + require.NoError(t, err) + require.NotEqual(t, phase0.Root(wantBodyRoot), phase0.Root(blockRoot)) + + root := phase0.Root(wantBodyRoot) + got, err := (&api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + Gloas: block, + BeaconBlockBodyRoot: &root, + }).BodyRoot() + require.NoError(t, err) + require.NotEqual(t, phase0.Root{}, got) + require.Equal(t, phase0.Root(wantBodyRoot), got) + require.NotEqual(t, phase0.Root(blockRoot), got) + }) + + t.Run("PayloadIncluded", func(t *testing.T) { + block := newBlock(0x02) + wantBodyRoot, err := block.Body.HashTreeRoot() + require.NoError(t, err) + blockRoot, err := block.HashTreeRoot() + require.NoError(t, err) + require.NotEqual(t, phase0.Root(wantBodyRoot), phase0.Root(blockRoot)) + + root := phase0.Root(wantBodyRoot) + got, err := (&api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + GloasContents: &apiv1gloas.BlockContents{Block: block}, + BeaconBlockBodyRoot: &root, + }).BodyRoot() + require.NoError(t, err) + require.NotEqual(t, phase0.Root{}, got) + require.Equal(t, phase0.Root(wantBodyRoot), got) + require.NotEqual(t, phase0.Root(blockRoot), got) + }) + + // A block with no body cannot yield a body root, and must not report the + // zero root as if it were one. + t.Run("NilBody", func(t *testing.T) { + _, err := (&api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + Gloas: &gloas.BeaconBlock{}, + }).BodyRoot() + require.EqualError(t, err, "no gloas beacon block body") + }) + + // Generated HashTreeRoot methods inline mainnet preset sizes, so a body + // present without a retained BeaconBlockBodyRoot must not fall back to + // computing one: that fallback is the exact bug this field exists to + // avoid. + t.Run("BeaconBlockBodyRootUnset", func(t *testing.T) { + _, err := (&api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + Gloas: newBlock(0x03), + }).BodyRoot() + require.EqualError(t, err, "no beacon block body root") + }) + + errTests := []struct { + name string + proposal *api.VersionedEPBSProposal + err string + }{ + { + name: "PayloadExcludedNilBlock", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + }, + err: "no gloas beacon block", + }, + { + name: "PayloadIncludedNilContents", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + }, + err: "no gloas block contents", + }, + { + name: "PayloadIncludedNilBlock", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + GloasContents: &apiv1gloas.BlockContents{}, + }, + err: "no gloas beacon block", + }, + { + name: "PreGloas", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionFulu, + }, + err: "no epbs proposal in fulu", + }, + { + name: "UnknownVersion", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersion(99), + }, + err: "unsupported version", + }, + } + + for _, test := range errTests { + t.Run(test.name, func(t *testing.T) { + _, err := test.proposal.BodyRoot() + require.EqualError(t, err, test.err) + }) + } +} + +// TestVersionedEPBSProposalRoot verifies Root derives the block root by +// composing a phase0.BeaconBlockHeader from the block's own fields and the +// retained BeaconBlockBodyRoot, rather than by calling the block's own +// generated HashTreeRoot: that generated method recurses into the body and +// inherits the same mainnet-baked sizes that make Body.HashTreeRoot() wrong +// on other presets, so it could disagree with BodyRoot() on a custom preset. +// Each case's expected root is composed independently of the code under +// test, so a Root that mixed up a field would fail rather than pass by +// coincidence. +func TestVersionedEPBSProposalRoot(t *testing.T) { + newBlock := func(reveal byte) *gloas.BeaconBlock { + return &gloas.BeaconBlock{ + Slot: 10, + ProposerIndex: 5, + ParentRoot: phase0.Root{0x11}, + StateRoot: phase0.Root{0x22}, + Body: &gloas.BeaconBlockBody{RANDAOReveal: phase0.BLSSignature{reveal}}, + } + } + + wantRootFor := func(t *testing.T, block *gloas.BeaconBlock, bodyRoot phase0.Root) phase0.Root { + t.Helper() + + root, err := (&phase0.BeaconBlockHeader{ + Slot: block.Slot, + ProposerIndex: block.ProposerIndex, + ParentRoot: block.ParentRoot, + StateRoot: block.StateRoot, + BodyRoot: bodyRoot, + }).HashTreeRoot() + require.NoError(t, err) + + return phase0.Root(root) + } + + t.Run("PayloadExcluded", func(t *testing.T) { + block := newBlock(0x07) + htr, err := block.Body.HashTreeRoot() + require.NoError(t, err) + bodyRoot := phase0.Root(htr) + + got, err := (&api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + Gloas: block, + BeaconBlockBodyRoot: &bodyRoot, + }).Root() + require.NoError(t, err) + require.NotEqual(t, phase0.Root{}, got) + require.Equal(t, wantRootFor(t, block, bodyRoot), got) + }) + + t.Run("PayloadIncluded", func(t *testing.T) { + block := newBlock(0x08) + htr, err := block.Body.HashTreeRoot() + require.NoError(t, err) + bodyRoot := phase0.Root(htr) + + got, err := (&api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + GloasContents: &apiv1gloas.BlockContents{Block: block}, + BeaconBlockBodyRoot: &bodyRoot, + }).Root() + require.NoError(t, err) + require.NotEqual(t, phase0.Root{}, got) + require.Equal(t, wantRootFor(t, block, bodyRoot), got) + }) + + // Root must propagate BodyRoot's error rather than deriving a root from a + // zero body root: a proposal missing its retained root is not a proposal + // with an all-zero body. + t.Run("BeaconBlockBodyRootUnset", func(t *testing.T) { + _, err := (&api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + Gloas: newBlock(0x09), + }).Root() + require.EqualError(t, err, "no beacon block body root") + }) + + t.Run("NoBlock", func(t *testing.T) { + _, err := (&api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + }).Root() + require.EqualError(t, err, "no gloas beacon block") + }) +} + +// TestVersionedEPBSProposalContents verifies that the three publish-side +// accessors are reachable only when the execution payload actually travelled +// with the block. When it did not, the caller must fetch the envelope from the +// producing node instead, so returning empty values here would silently produce +// an unpublishable proposal. +func TestVersionedEPBSProposalContents(t *testing.T) { + envelope := &gloas.ExecutionPayloadEnvelope{BuilderIndex: 7} + included := &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + GloasContents: &apiv1gloas.BlockContents{ + Block: &gloas.BeaconBlock{Slot: 1}, + ExecutionPayloadEnvelope: envelope, + KZGProofs: []deneb.KZGProof{{0x01}}, + Blobs: []deneb.Blob{{}}, + }, + } + + excluded := &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + Gloas: &gloas.BeaconBlock{Slot: 1}, + } + + const excludedErr = "no block contents; the execution payload was not included" + + t.Run("EnvelopeIncluded", func(t *testing.T) { + got, err := included.ExecutionPayloadEnvelope() + require.NoError(t, err) + require.Equal(t, envelope, got) + }) + + t.Run("EnvelopeExcluded", func(t *testing.T) { + _, err := excluded.ExecutionPayloadEnvelope() + require.EqualError(t, err, excludedErr) + }) + + t.Run("KZGProofsIncluded", func(t *testing.T) { + got, err := included.KZGProofs() + require.NoError(t, err) + require.Equal(t, []deneb.KZGProof{{0x01}}, got) + }) + + t.Run("KZGProofsExcluded", func(t *testing.T) { + _, err := excluded.KZGProofs() + require.EqualError(t, err, excludedErr) + }) + + t.Run("BlobsIncluded", func(t *testing.T) { + got, err := included.Blobs() + require.NoError(t, err) + // Assert the length rather than emptiness: require.Empty is satisfied by + // a nil return, so an accessor that read nothing would pass. + require.Len(t, got, 1) + }) + + t.Run("BlobsExcluded", func(t *testing.T) { + _, err := excluded.Blobs() + require.EqualError(t, err, excludedErr) + }) + + t.Run("EnvelopeIncludedButAbsent", func(t *testing.T) { + _, err := (&api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + GloasContents: &apiv1gloas.BlockContents{Block: &gloas.BeaconBlock{}}, + }).ExecutionPayloadEnvelope() + require.EqualError(t, err, "no gloas execution payload envelope") + }) +} + +// TestVersionedEPBSProposalValue verifies the total is unknown -- nil -- unless +// the execution value arrived, and that a missing consensus value counts as +// zero rather than poisoning a known execution value. Both components are +// populated from response headers that a node may omit. +func TestVersionedEPBSProposalValue(t *testing.T) { + tests := []struct { + name string + consensus *big.Int + execution *big.Int + expected *big.Int + }{ + {name: "Both", consensus: big.NewInt(3), execution: big.NewInt(4), expected: big.NewInt(7)}, + {name: "NeitherSet"}, + {name: "ConsensusOnly", consensus: big.NewInt(5)}, + {name: "ExecutionZero", execution: big.NewInt(0), expected: big.NewInt(0)}, + {name: "ExecutionOnly", execution: big.NewInt(6), expected: big.NewInt(6)}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + proposal := &api.VersionedEPBSProposal{ + ConsensusValue: test.consensus, + ExecutionValue: test.execution, + } + + require.Equal(t, test.expected, proposal.Value()) + }) + } +} + +// TestVersionedEPBSProposalIsEmpty verifies emptiness tracks the arms rather +// than the flag: either arm populated means the proposal carries data. +func TestVersionedEPBSProposalIsEmpty(t *testing.T) { + require.True(t, (&api.VersionedEPBSProposal{Version: spec.DataVersionGloas}).IsEmpty()) + require.False(t, (&api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + Gloas: &gloas.BeaconBlock{}, + }).IsEmpty()) + require.False(t, (&api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + GloasContents: &apiv1gloas.BlockContents{}, + }).IsEmpty()) +} diff --git a/api/versionedsignedproposal.go b/api/versionedsignedproposal.go index 96d97be1..1d2ecce8 100644 --- a/api/versionedsignedproposal.go +++ b/api/versionedsignedproposal.go @@ -1,4 +1,4 @@ -// Copyright © 2023, 2024 Attestant Limited. +// Copyright © 2023 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -26,6 +26,7 @@ import ( "github.com/attestantio/go-eth2-client/spec/altair" "github.com/attestantio/go-eth2-client/spec/bellatrix" "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" ) @@ -47,10 +48,13 @@ type VersionedSignedProposal struct { ElectraBlinded *apiv1electra.SignedBlindedBeaconBlock Fulu *apiv1fulu.SignedBlockContents FuluBlinded *apiv1electra.SignedBlindedBeaconBlock + Gloas *gloas.SignedBeaconBlock } // AssertPresent throws an error if the expected proposal // given the version and blinded fields is not present. +// +//nolint:gocyclo // one arm per fork, and per blinded variant within it func (v *VersionedSignedProposal) AssertPresent() error { switch v.Version { case spec.DataVersionPhase0: @@ -101,6 +105,14 @@ func (v *VersionedSignedProposal) AssertPresent() error { if v.FuluBlinded == nil && v.Blinded { return errors.New("blinded fulu proposal not present") } + case spec.DataVersionGloas: + if v.Blinded { + return errors.New("gloas proposals are never blinded") + } + + if v.Gloas == nil { + return errors.New("gloas proposal not present") + } default: return errors.New("unsupported version") } @@ -150,6 +162,8 @@ func (v *VersionedSignedProposal) Slot() (phase0.Slot, error) { } return v.Fulu.SignedBlock.Message.Slot, nil + case spec.DataVersionGloas: + return v.Gloas.Message.Slot, nil default: return 0, ErrUnsupportedVersion } @@ -196,6 +210,8 @@ func (v *VersionedSignedProposal) ProposerIndex() (phase0.ValidatorIndex, error) } return v.Fulu.SignedBlock.Message.ProposerIndex, nil + case spec.DataVersionGloas: + return v.Gloas.Message.ProposerIndex, nil default: return 0, ErrUnsupportedVersion } @@ -238,6 +254,11 @@ func (v *VersionedSignedProposal) ExecutionBlockHash() (phase0.Hash32, error) { } return v.Fulu.SignedBlock.Message.Body.ExecutionPayload.BlockHash, nil + case spec.DataVersionGloas: + // No blinded arm: there is no blinded proposal post-Gloas. The bid the + // proposer committed to stands in for the payload and the payload header + // the earlier arms read. + return v.Gloas.Message.Body.SignedExecutionPayloadBid.Message.BlockHash, nil default: return phase0.Hash32{}, ErrUnsupportedVersion } @@ -328,6 +349,12 @@ func (v *VersionedSignedProposal) String() string { } return v.Fulu.String() + case spec.DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() default: return "unsupported version" } @@ -402,6 +429,11 @@ func (v *VersionedSignedProposal) assertMessagePresent() error { return ErrDataMissing } } + case spec.DataVersionGloas: + if v.Gloas == nil || + v.Gloas.Message == nil { + return ErrDataMissing + } default: return ErrUnsupportedVersion } @@ -496,6 +528,16 @@ func (v *VersionedSignedProposal) assertExecutionPayloadPresent() error { return ErrDataMissing } } + case spec.DataVersionGloas: + // The chain runs one field deeper than assertMessagePresent's Gloas arm + // because the execution block hash lives in the bid. + if v.Gloas == nil || + v.Gloas.Message == nil || + v.Gloas.Message.Body == nil || + v.Gloas.Message.Body.SignedExecutionPayloadBid == nil || + v.Gloas.Message.Body.SignedExecutionPayloadBid.Message == nil { + return ErrDataMissing + } default: return ErrUnsupportedVersion } diff --git a/api/versionedsignedproposal_test.go b/api/versionedsignedproposal_test.go new file mode 100644 index 00000000..65254141 --- /dev/null +++ b/api/versionedsignedproposal_test.go @@ -0,0 +1,174 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api_test + +import ( + "testing" + + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// TestVersionedSignedProposalExecutionBlockHash verifies that a Gloas proposal +// reports the execution block hash its proposer committed to. Post-Gloas that +// hash lives in the execution payload bid rather than in a payload or a payload +// header, so a wrapper that recognised every earlier fork but not this one would +// refuse every single post-ePBS proposal. +// +// The absent-data cases matter for a subtler reason: they must fail as missing +// data, not as an unsupported version. A caller distinguishes "this node gave +// me an incomplete proposal" from "this library cannot read this fork" by that +// error alone, and the second answer is wrong for a fork the wrapper carries a +// field for. +func TestVersionedSignedProposalExecutionBlockHash(t *testing.T) { + blockHash := phase0.Hash32{ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, + } + + tests := []struct { + name string + proposal *api.VersionedSignedProposal + expected phase0.Hash32 + err error + }{ + { + name: "Gloas", + proposal: &api.VersionedSignedProposal{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedBeaconBlock{ + Message: &gloas.BeaconBlock{ + Body: &gloas.BeaconBlockBody{ + SignedExecutionPayloadBid: &gloas.SignedExecutionPayloadBid{ + Message: &gloas.ExecutionPayloadBid{ + BlockHash: blockHash, + }, + }, + }, + }, + }, + }, + expected: blockHash, + }, + { + // The bid is the only place a Gloas proposal carries an execution + // block hash, so its absence is the case that must not report the + // zero hash as a successful answer. + name: "GloasNoBid", + proposal: &api.VersionedSignedProposal{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedBeaconBlock{ + Message: &gloas.BeaconBlock{ + Body: &gloas.BeaconBlockBody{}, + }, + }, + }, + err: api.ErrDataMissing, + }, + { + // A signed bid whose message is absent carries a signature over + // nothing; the hash has to be read from the message, not the wrapper. + name: "GloasNoBidMessage", + proposal: &api.VersionedSignedProposal{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedBeaconBlock{ + Message: &gloas.BeaconBlock{ + Body: &gloas.BeaconBlockBody{ + SignedExecutionPayloadBid: &gloas.SignedExecutionPayloadBid{}, + }, + }, + }, + }, + err: api.ErrDataMissing, + }, + { + name: "GloasNoBody", + proposal: &api.VersionedSignedProposal{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedBeaconBlock{ + Message: &gloas.BeaconBlock{}, + }, + }, + err: api.ErrDataMissing, + }, + { + name: "GloasNoMessage", + proposal: &api.VersionedSignedProposal{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedBeaconBlock{}, + }, + err: api.ErrDataMissing, + }, + { + name: "GloasNoBlock", + proposal: &api.VersionedSignedProposal{ + Version: spec.DataVersionGloas, + }, + err: api.ErrDataMissing, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := test.proposal.ExecutionBlockHash() + if test.err != nil { + require.ErrorIs(t, err, test.err) + } else { + require.NoError(t, err) + require.Equal(t, test.expected, got) + } + }) + } +} + +func TestVersionedSignedProposalGloasBlindedState(t *testing.T) { + tests := []struct { + name string + proposal *api.VersionedSignedProposal + err string + }{ + { + name: "Unblinded", + proposal: &api.VersionedSignedProposal{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedBeaconBlock{}, + }, + }, + { + name: "Blinded", + proposal: &api.VersionedSignedProposal{ + Version: spec.DataVersionGloas, + Blinded: true, + Gloas: &gloas.SignedBeaconBlock{}, + }, + err: "gloas proposals are never blinded", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.proposal.AssertPresent() + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/errors.go b/errors.go index 8cb6bb1f..62794848 100644 --- a/errors.go +++ b/errors.go @@ -1,4 +1,4 @@ -// Copyright © 2024 Attestant Limited. +// Copyright © 2024 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -26,4 +26,11 @@ var ( ErrInvalidOptions = errors.New("invalid options") // ErrInconsistentResult is returned when a request returns with data at odds to that requested. ErrInconsistentResult = errors.New("inconsistent result") + // ErrNoExecutionPayloadEnvelope is returned when the node holds no cached execution payload + // envelope for the requested slot and block root. A node caches only the envelope it built + // for the slot it is proposing, so this is the normal answer for any other slot, for a block + // the node did not build, and for a block that a re-org has moved off the chain. + ErrNoExecutionPayloadEnvelope = errors.New("no execution payload envelope available") + // ErrNoPayloadAttestationData is returned when the node has no payload attestation data for a slot. + ErrNoPayloadAttestationData = errors.New("no payload attestation data available") ) diff --git a/events_test.go b/events_test.go new file mode 100644 index 00000000..02c18a48 --- /dev/null +++ b/events_test.go @@ -0,0 +1,196 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package client_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + apiv1 "github.com/attestantio/go-eth2-client/api/v1" + eth2http "github.com/attestantio/go-eth2-client/http" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/rs/zerolog" + require "github.com/stretchr/testify/require" +) + +func TestEventsLightClientUpdates(t *testing.T) { + server := newEventsTestServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprint(w, "event: light_client_finality_update\ndata: {\"version\":\"altair\",\"data\":{\"signature_slot\":\"1\"}}\n\n") + _, _ = fmt.Fprint(w, "event: light_client_optimistic_update\ndata: {\"version\":\"altair\",\"data\":{\"signature_slot\":\"2\"}}\n\n") + w.(http.Flusher).Flush() + })) + defer server.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + service, err := eth2http.New(ctx, eth2http.WithAddress(server.URL), eth2http.WithLogLevel(zerolog.Disabled)) + require.NoError(t, err) + + received := make(chan *apiv1.Event, 2) + err = service.(client.EventsProvider).Events(ctx, &api.EventsOpts{ + Topics: []string{"light_client_finality_update", "light_client_optimistic_update"}, + Handler: func(event *apiv1.Event) { + select { + case received <- event: + default: + } + }, + }) + require.NoError(t, err) + + events := map[string]*apiv1.Event{} + for len(events) < 2 { + select { + case event := <-received: + events[event.Topic] = event + case <-ctx.Done(): + t.Fatal("timed out waiting for light client events") + } + } + cancel() + + finality, ok := events["light_client_finality_update"].Data.(*apiv1.LightClientFinalityUpdateEvent) + require.True(t, ok) + require.Equal(t, spec.DataVersionAltair, finality.Version) + require.JSONEq(t, `{"signature_slot":"1"}`, string(finality.Data)) + + optimistic, ok := events["light_client_optimistic_update"].Data.(*apiv1.LightClientOptimisticUpdateEvent) + require.True(t, ok) + require.Equal(t, spec.DataVersionAltair, optimistic.Version) + require.JSONEq(t, `{"signature_slot":"2"}`, string(optimistic.Data)) +} + +func TestEventsTopicValidation(t *testing.T) { + server := newEventsTestServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + service, err := eth2http.New(ctx, eth2http.WithAddress(server.URL), eth2http.WithLogLevel(zerolog.Disabled)) + require.NoError(t, err) + provider := service.(client.EventsProvider) + + require.NoError(t, provider.Events(ctx, &api.EventsOpts{ + Topics: []string{"blob_sidecar"}, + Handler: func(*apiv1.Event) {}, + })) + require.EqualError(t, provider.Events(ctx, &api.EventsOpts{ + Topics: []string{"light_client_finality_update"}, + }), "no handler for light_client_finality_update event: invalid options") + require.EqualError(t, provider.Events(ctx, &api.EventsOpts{ + Topics: []string{"light_client_optimistic_update"}, + }), "no handler for light_client_optimistic_update event: invalid options") +} + +func TestEventsHeadV2(t *testing.T) { + fixture := `{"version":"gloas","data":{"slot":"10","block":"0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf","state":"0x600e852a08c1200654ddf11025f1ceacb3c2e74bdd5c630cde0838b2591b69f9","payload_status":"empty","epoch_transition":false,"current_epoch_dependent_root":"0x5e0043f107cb57913498fbf2f99ff55e730bf1e151f02f221e977c91a90a0e91","next_epoch_dependent_root":"0x7f1154a218dc77addb1fe144266e83c04f67c8dfd03aacf955df05308cd8b27c","execution_optimistic":true}}` + + server := newEventsTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("topics") != "head_v2" { + http.Error(w, "incorrect topic", http.StatusBadRequest) + + return + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprintf(w, "event: head_v2\ndata: %s\n\n", fixture) + w.(http.Flusher).Flush() + })) + defer server.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + service, err := eth2http.New(ctx, eth2http.WithAddress(server.URL), eth2http.WithLogLevel(zerolog.Disabled)) + require.NoError(t, err) + + received := make(chan *apiv1.HeadEventV2, 1) + err = service.(client.EventsProvider).Events(ctx, &api.EventsOpts{ + Topics: []string{"head_v2"}, + HeadV2Handler: func(_ context.Context, event *apiv1.HeadEventV2) { + select { + case received <- event: + default: + } + }, + }) + require.NoError(t, err) + + select { + case event := <-received: + cancel() + require.Equal(t, spec.DataVersionGloas, event.Version) + require.Equal(t, phase0.Slot(10), event.Slot) + require.Equal(t, phase0.Root{0x9a, 0x2f, 0xef, 0xd2, 0xfd, 0xb5, 0x7f, 0x74, 0x99, 0x3c, 0x77, 0x80, 0xea, 0x5b, 0x90, 0x30, 0xd2, 0x89, 0x7b, 0x61, 0x5b, 0x89, 0xf8, 0x08, 0x01, 0x1c, 0xa5, 0xae, 0xbe, 0xd5, 0x4e, 0xaf}, event.Block) + require.Equal(t, phase0.Root{0x60, 0x0e, 0x85, 0x2a, 0x08, 0xc1, 0x20, 0x06, 0x54, 0xdd, 0xf1, 0x10, 0x25, 0xf1, 0xce, 0xac, 0xb3, 0xc2, 0xe7, 0x4b, 0xdd, 0x5c, 0x63, 0x0c, 0xde, 0x08, 0x38, 0xb2, 0x59, 0x1b, 0x69, 0xf9}, event.State) + require.Equal(t, "empty", event.PayloadStatus) + require.False(t, event.EpochTransition) + require.Equal(t, phase0.Root{0x5e, 0x00, 0x43, 0xf1, 0x07, 0xcb, 0x57, 0x91, 0x34, 0x98, 0xfb, 0xf2, 0xf9, 0x9f, 0xf5, 0x5e, 0x73, 0x0b, 0xf1, 0xe1, 0x51, 0xf0, 0x2f, 0x22, 0x1e, 0x97, 0x7c, 0x91, 0xa9, 0x0a, 0x0e, 0x91}, event.CurrentEpochDependentRoot) + require.Equal(t, phase0.Root{0x7f, 0x11, 0x54, 0xa2, 0x18, 0xdc, 0x77, 0xad, 0xdb, 0x1f, 0xe1, 0x44, 0x26, 0x6e, 0x83, 0xc0, 0x4f, 0x67, 0xc8, 0xdf, 0xd0, 0x3a, 0xac, 0xf9, 0x55, 0xdf, 0x05, 0x30, 0x8c, 0xd8, 0xb2, 0x7c}, event.NextEpochDependentRoot) + require.True(t, event.ExecutionOptimistic) + case <-ctx.Done(): + t.Fatal("timed out waiting for head_v2 event") + } +} + +func TestEventsHeadV2Generic(t *testing.T) { + server := newEventsTestServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprint(w, "event: head_v2\ndata: {\"version\":\"gloas\",\"data\":{\"slot\":\"10\",\"block\":\"0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf\",\"state\":\"0x600e852a08c1200654ddf11025f1ceacb3c2e74bdd5c630cde0838b2591b69f9\",\"payload_status\":\"full\",\"current_epoch_dependent_root\":\"0x5e0043f107cb57913498fbf2f99ff55e730bf1e151f02f221e977c91a90a0e91\",\"next_epoch_dependent_root\":\"0x5e0043f107cb57913498fbf2f99ff55e730bf1e151f02f221e977c91a90a0e91\"}}\n\n") + w.(http.Flusher).Flush() + })) + defer server.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + service, err := eth2http.New(ctx, eth2http.WithAddress(server.URL), eth2http.WithLogLevel(zerolog.Disabled)) + require.NoError(t, err) + + received := make(chan *apiv1.Event, 1) + require.NoError(t, service.(client.EventsProvider).Events(ctx, &api.EventsOpts{ + Topics: []string{"head_v2"}, + Handler: func(event *apiv1.Event) { received <- event }, + })) + select { + case event := <-received: + require.Equal(t, "head_v2", event.Topic) + head, ok := event.Data.(*apiv1.HeadEventV2) + require.True(t, ok) + require.Equal(t, "full", head.PayloadStatus) + case <-ctx.Done(): + t.Fatal("timed out waiting for generic head_v2 event") + } +} + +func newEventsTestServer(eventsHandler http.Handler) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/eth/v1/node/syncing": + _, _ = fmt.Fprint(w, `{"data":{"head_slot":"0","sync_distance":"0","is_syncing":false,"is_optimistic":false}}`) + case "/eth/v1/node/version": + _, _ = fmt.Fprint(w, `{"data":{"version":"test/v1"}}`) + case "/eth/v1/events": + eventsHandler.ServeHTTP(w, r) + } + })) +} diff --git a/http/aggregateattestation.go b/http/aggregateattestation.go index a1e43507..fa4bdbfe 100644 --- a/http/aggregateattestation.go +++ b/http/aggregateattestation.go @@ -1,4 +1,4 @@ -// Copyright © 2020 - 2024 Attestant Limited. +// Copyright © 2020 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -23,6 +23,7 @@ import ( "github.com/attestantio/go-eth2-client/api" "github.com/attestantio/go-eth2-client/spec" "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" ) @@ -172,6 +173,16 @@ func decodeAggregateAttestation(httpResponse *httpResponse) (*spec.VersionedAtte return &spec.VersionedAttestation{}, nil, decodeErr } + return data, metadata, nil + case spec.DataVersionGloas: + gloasData, gloasMetadata, decodeErr := decodeJSONResponse(bytes.NewReader(httpResponse.body), &gloas.Attestation{}) + metadata = gloasMetadata + data.Gloas = gloasData + + if decodeErr != nil { + return &spec.VersionedAttestation{}, nil, decodeErr + } + return data, metadata, nil default: return &spec.VersionedAttestation{}, nil, errors.New("unknown consensus version") diff --git a/http/aggregateattestation_internal_test.go b/http/aggregateattestation_internal_test.go index 2fac89ae..5c2761c2 100644 --- a/http/aggregateattestation_internal_test.go +++ b/http/aggregateattestation_internal_test.go @@ -1,4 +1,4 @@ -// Copyright © 2025 Attestant Limited. +// Copyright © 2025 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -17,30 +17,33 @@ import ( "testing" "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/stretchr/testify/require" ) +const gloasAttestationJSON = `{ + "aggregation_bits": "0x97aff9afffedbbfefedbdffdfbf5ffaebfffffffecfd03", + "data": { + "slot": "84434", + "index": "0", + "beacon_block_root": "0xaa95c9d1a4f380b4331378e92ba88f4c757c6d252e29d43e6c8ac804caccca9a", + "source": { + "epoch": "2637", + "root": "0x22aa73e2e76e27404e4bf259d27012faa9f5a2e6e7c611fdfe32510b66470b82" + }, + "target": { + "epoch": "2638", + "root": "0x4f6545fcd8b1e24daeb6872dfe42898ba0e4be917f459b9c42f6fbb56715699c" + } + }, + "signature": "0xad0ea974c685ff2c8d8971e456569c9b5f8ba830f86154172bcfc77142f65ce64d866cf1b15adbb2dbb8f2958bb952bb061b66aaacbb2e764193d2fee666a1d2fefe34c41932fbf07522be456c1b768ceb8899ba5bd107a1a3700f58d6414d54", + "committee_bits": "0x0100000000000000" +}` + func TestAggregateAttestationDecode(t *testing.T) { responseJson := `{ "version": "Electra", - "data": { - "aggregation_bits": "0x97aff9afffedbbfefedbdffdfbf5ffaebfffffffecfd03", - "data": { - "slot": "84434", - "index": "0", - "beacon_block_root": "0xaa95c9d1a4f380b4331378e92ba88f4c757c6d252e29d43e6c8ac804caccca9a", - "source": { - "epoch": "2637", - "root": "0x22aa73e2e76e27404e4bf259d27012faa9f5a2e6e7c611fdfe32510b66470b82" - }, - "target": { - "epoch": "2638", - "root": "0x4f6545fcd8b1e24daeb6872dfe42898ba0e4be917f459b9c42f6fbb56715699c" - } - }, - "signature": "0xad0ea974c685ff2c8d8971e456569c9b5f8ba830f86154172bcfc77142f65ce64d866cf1b15adbb2dbb8f2958bb952bb061b66aaacbb2e764193d2fee666a1d2fefe34c41932fbf07522be456c1b768ceb8899ba5bd107a1a3700f58d6414d54", - "committee_bits": "0x0100000000000000" - } + "data": ` + gloasAttestationJSON + ` }` t.Run("ElectraAttestationAggregate", func(t *testing.T) { @@ -56,4 +59,22 @@ func TestAggregateAttestationDecode(t *testing.T) { require.True(t, ok) require.Equal(t, versionKey, "Electra") }) + + // A Gloas beacon node reports Eth-Consensus-Version: gloas for the aggregate attestation + // endpoint, which decodeAggregateAttestation reads from httpResponse.consensusVersion (not the + // body). The attestation wire format is unchanged from Electra, so the same body decodes into + // the gloas.Attestation field once the version arm is present. + t.Run("GloasAttestationAggregate", func(t *testing.T) { + response := httpResponse{ + consensusVersion: spec.DataVersionGloas, + body: []byte(responseJson), + } + data, _, err := decodeAggregateAttestation(&response) + require.NoError(t, err) + + require.Equal(t, spec.DataVersionGloas, data.Version) + require.NotNil(t, data.Gloas) + require.Nil(t, data.Fulu) + require.Equal(t, phase0.Slot(84434), data.Gloas.Data.Slot) + }) } diff --git a/http/attestationdata.go b/http/attestationdata.go index dd77ac2f..f52971bf 100644 --- a/http/attestationdata.go +++ b/http/attestationdata.go @@ -1,4 +1,4 @@ -// Copyright © 2020 - 2024 Attestant Limited. +// Copyright © 2020 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -85,6 +85,27 @@ func (s *Service) verifyAttestationData(ctx context.Context, opts *api.Attestati ) } + onGloas, err := s.isGloasSlot(ctx, opts.Slot) + if err != nil { + return errors.Join(errors.New("failed to determine whether the slot is in the gloas era"), err) + } + + // Gloas repurposes data.Index as a one-bit vote on the availability of the attested + // block's execution payload: 0 for a payload the attester does not see in the + // canonical chain, 1 for one it does. Holding it to the electra-era value of 0 would + // discard every payload-FULL answer a conformant node gives, so bound it the way + // process_attestation does rather than pinning it. + if onGloas { + if data.Index > 1 { + return errors.Join( + fmt.Errorf("attestation data payload availability vote %d; expected 0 or 1", data.Index), + client.ErrInconsistentResult, + ) + } + + return nil + } + electraSlot, err := s.calculateElectraSlot(ctx) if err != nil { return errors.Join(errors.New("failed to calculate electra slot"), err) @@ -106,6 +127,42 @@ func (s *Service) verifyAttestationData(ctx context.Context, opts *api.Attestati return nil } +// isGloasSlot reports whether the given slot is at or after the node's gloas fork. +// +// A node that does not know the fork answers false rather than failing, unlike its +// electra counterpart's treatment of a missing key: a client publishes GLOAS_FORK_EPOCH +// only once it has one, so on every node that predates the fork the key is simply +// missing, and reporting that as a failure would break the endpoint everywhere. +// +// A predicate on a slot rather than the fork's first slot, because the two facts that +// answer would rest on -- the epoch, and whether there is one at all -- are only +// meaningful together. A caller handed both separately can compare against the slot +// without consulting the flag, and an unknown fork has no first slot to return, so that +// mistake would admit every slot rather than none. +func (s *Service) isGloasSlot(ctx context.Context, slot phase0.Slot) (bool, error) { + response, err := s.Spec(ctx, &api.SpecOpts{}) + if err != nil { + return false, err + } + + value, exists := response.Data["GLOAS_FORK_EPOCH"] + if !exists { + return false, nil + } + + gloasEpoch, isCorrectType := value.(uint64) + if !isCorrectType { + return false, ErrIncorrectType + } + + slotsPerEpoch, isCorrectType := response.Data["SLOTS_PER_EPOCH"].(uint64) + if !isCorrectType { + return false, ErrIncorrectType + } + + return slot >= phase0.Slot(slotsPerEpoch*gloasEpoch), nil +} + func (s *Service) calculateElectraSlot(ctx context.Context) (phase0.Slot, error) { response, err := s.Spec(ctx, &api.SpecOpts{}) if err != nil { diff --git a/http/attestationdata_test.go b/http/attestationdata_test.go index 41eb38c6..5a58113f 100644 --- a/http/attestationdata_test.go +++ b/http/attestationdata_test.go @@ -1,4 +1,4 @@ -// Copyright © 2020 - 2023 Attestant Limited. +// Copyright © 2020 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -15,17 +15,178 @@ package http_test import ( "context" + "encoding/json" "errors" + "fmt" + nethttp "net/http" + "net/http/httptest" "strings" "testing" "time" client "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/http" "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/stretchr/testify/require" ) +// attestationDataService returns a service whose every request is answered from +// memory: the two endpoints a service needs to come up, the configuration given by +// specEntries, and data as the answer to every attestation-data request. +// +// No live node can stand in for it. Which vote a conformant node returns is decided by +// its own fork choice, so a caller cannot ask for the payload-FULL one: eight +// consecutive requests to a Gloas devnet answered 0 every time, and a request that is +// answered 0 exercises the same branch as every pre-Gloas answer. The other half of the +// gate cannot be asked of that node at all, since a chain is on one side of a fork or +// the other. Synthesising the node makes both sides reachable on demand, and reachable +// on an endpoint that has never heard of Gloas. +func attestationDataService(ctx context.Context, + t *testing.T, + specEntries string, + data *phase0.AttestationData, +) client.Service { + t.Helper() + + // Marshalled here rather than in the handler because a failure must be reported + // from the test's own goroutine. + encoded, err := json.Marshal(data) + require.NoError(t, err) + + server := httptest.NewServer(nethttp.HandlerFunc(func(w nethttp.ResponseWriter, r *nethttp.Request) { + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Path { + // Both of these, or New leaves the service inactive and every call below + // returns ErrNotActive without ever reaching the wire. + case "/eth/v1/node/version": + fmt.Fprint(w, `{"data":{"version":"stub"}}`) + case "/eth/v1/node/syncing": + fmt.Fprint(w, `{"data":{"head_slot":"1","sync_distance":"0","is_syncing":false,"is_optimistic":false,"el_offline":false}}`) + case "/eth/v1/config/spec": + fmt.Fprintf(w, `{"data":{%s}}`, specEntries) + case "/eth/v1/validator/attestation_data": + fmt.Fprintf(w, `{"data":%s}`, encoded) + default: + w.WriteHeader(nethttp.StatusNotFound) + } + })) + t.Cleanup(server.Close) + + service, err := http.New(ctx, http.WithAddress(server.URL)) + require.NoError(t, err) + + return service +} + +// TestAttestationDataGloasPayloadVote covers the fork gate on the index assertion. +// +// Gloas repurposes AttestationData.index as a payload-availability vote, so the +// Electra-era expectation that it is 0 turns a conformant node's payload-FULL answer +// into ErrInconsistentResult and leaves the validator unable to attest on those +// slots. What has to hold is that the two eras are told apart: bounded to {0, 1} at +// or after the fork, pinned to 0 before it. +func TestAttestationDataGloasPayloadVote(t *testing.T) { + ctx := context.Background() + + const slotsPerEpoch = 32 + // Gloas at epoch 100, so slot 3200 is the fork's first slot and 3199 its last + // pre-fork one. + const gloasForkEpoch = "100" + const gloasSlot = phase0.Slot(3200) + + tests := []struct { + name string + // Absent from the configuration when empty, as it is on every node that + // does not know the fork. + gloasForkEpoch string + slot phase0.Slot + index phase0.CommitteeIndex + err string + }{ + { + // The payload-EMPTY vote, which is also every pre-Gloas value, so this + // is the case the electra-era expectation already admitted. + name: "PayloadEmptyVoteAccepted", + gloasForkEpoch: gloasForkEpoch, + slot: gloasSlot, + index: 0, + }, + { + // The payload-FULL vote: the case that was discarded, leaving a + // validator client unable to attest on any slot where its node + // legitimately answered 1. + name: "PayloadFullVoteAccepted", + gloasForkEpoch: gloasForkEpoch, + slot: gloasSlot, + index: 1, + }, + { + // Two is not a payload status. The bound stays asserted, so widening + // the vote does not amount to no longer checking the field. + name: "VoteAboveOneRejected", + gloasForkEpoch: gloasForkEpoch, + slot: gloasSlot, + index: 2, + err: "attestation data payload availability vote 2; expected 0 or 1", + }, + { + // One slot earlier the field is still a committee index that electra + // zeroed, so the same answer is still inconsistent. + name: "LastPreGloasSlotStillClamped", + gloasForkEpoch: gloasForkEpoch, + slot: gloasSlot - 1, + index: 1, + err: "attestation data for committee index 1; expected 0", + }, + { + // A node that has never heard of the fork cannot be voting on payloads, + // whatever the slot number, so its answer is held to electra's rule. + name: "NodeThatDoesNotKnowGloasStillClamped", + slot: gloasSlot, + index: 1, + err: "attestation data for committee index 1; expected 0", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // Electra at epoch 0 so that the pre-Gloas expectation in force is + // always the Electra-era clamp to 0, which is what the Gloas arm has + // to displace. + specEntries := fmt.Sprintf(`"SLOTS_PER_EPOCH":"%d","ELECTRA_FORK_EPOCH":"0"`, slotsPerEpoch) + if test.gloasForkEpoch != "" { + specEntries += fmt.Sprintf(`,"GLOAS_FORK_EPOCH":"%s"`, test.gloasForkEpoch) + } + + // Slot and Index are the only fields the guard under test reads; the + // checkpoints are present because the response would not decode + // without them, not because their contents matter. + service := attestationDataService(ctx, t, specEntries, &phase0.AttestationData{ + Slot: test.slot, + Index: test.index, + Source: &phase0.Checkpoint{}, + Target: &phase0.Checkpoint{}, + }) + + response, err := service.(client.AttestationDataProvider).AttestationData(ctx, &api.AttestationDataOpts{ + Slot: test.slot, + }) + + if test.err != "" { + require.ErrorIs(t, err, client.ErrInconsistentResult) + require.ErrorContains(t, err, test.err) + + return + } + + require.NoError(t, err) + require.Equal(t, test.index, response.Data.Index) + }) + } +} + func TestAttestationData(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/http/attestationpool.go b/http/attestationpool.go index 1e9b809f..18f501d4 100644 --- a/http/attestationpool.go +++ b/http/attestationpool.go @@ -1,4 +1,4 @@ -// Copyright © 2021 - 2025 Attestant Limited. +// Copyright © 2021 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -20,10 +20,12 @@ import ( "fmt" "strings" + bitfield "github.com/OffchainLabs/go-bitfield" client "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/api" "github.com/attestantio/go-eth2-client/spec" "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" ) @@ -73,7 +75,72 @@ func (*Service) attestationPoolFromJSON(_ context.Context, *api.Response[[]*spec.VersionedAttestation], error, ) { - data, metadata, err := decodeJSONResponse(bytes.NewReader(httpResponse.body), []*spec.VersionedAttestation{}) + var ( + data []*spec.VersionedAttestation + metadata map[string]any + err error + ) + + switch httpResponse.consensusVersion { + case spec.DataVersionPhase0: + var decoded []*phase0.Attestation + decoded, metadata, err = decodeJSONResponse(bytes.NewReader(httpResponse.body), decoded) + data = make([]*spec.VersionedAttestation, len(decoded)) + for i := range decoded { + data[i] = &spec.VersionedAttestation{Version: httpResponse.consensusVersion, Phase0: decoded[i]} + } + case spec.DataVersionAltair: + var decoded []*phase0.Attestation + decoded, metadata, err = decodeJSONResponse(bytes.NewReader(httpResponse.body), decoded) + data = make([]*spec.VersionedAttestation, len(decoded)) + for i := range decoded { + data[i] = &spec.VersionedAttestation{Version: httpResponse.consensusVersion, Altair: decoded[i]} + } + case spec.DataVersionBellatrix: + var decoded []*phase0.Attestation + decoded, metadata, err = decodeJSONResponse(bytes.NewReader(httpResponse.body), decoded) + data = make([]*spec.VersionedAttestation, len(decoded)) + for i := range decoded { + data[i] = &spec.VersionedAttestation{Version: httpResponse.consensusVersion, Bellatrix: decoded[i]} + } + case spec.DataVersionCapella: + var decoded []*phase0.Attestation + decoded, metadata, err = decodeJSONResponse(bytes.NewReader(httpResponse.body), decoded) + data = make([]*spec.VersionedAttestation, len(decoded)) + for i := range decoded { + data[i] = &spec.VersionedAttestation{Version: httpResponse.consensusVersion, Capella: decoded[i]} + } + case spec.DataVersionDeneb: + var decoded []*phase0.Attestation + decoded, metadata, err = decodeJSONResponse(bytes.NewReader(httpResponse.body), decoded) + data = make([]*spec.VersionedAttestation, len(decoded)) + for i := range decoded { + data[i] = &spec.VersionedAttestation{Version: httpResponse.consensusVersion, Deneb: decoded[i]} + } + case spec.DataVersionElectra: + var decoded []*electra.Attestation + decoded, metadata, err = decodeJSONResponse(bytes.NewReader(httpResponse.body), decoded) + data = make([]*spec.VersionedAttestation, len(decoded)) + for i := range decoded { + data[i] = &spec.VersionedAttestation{Version: httpResponse.consensusVersion, Electra: decoded[i]} + } + case spec.DataVersionFulu: + var decoded []*electra.Attestation + decoded, metadata, err = decodeJSONResponse(bytes.NewReader(httpResponse.body), decoded) + data = make([]*spec.VersionedAttestation, len(decoded)) + for i := range decoded { + data[i] = &spec.VersionedAttestation{Version: httpResponse.consensusVersion, Fulu: decoded[i]} + } + case spec.DataVersionGloas: + var decoded []*gloas.Attestation + decoded, metadata, err = decodeJSONResponse(bytes.NewReader(httpResponse.body), decoded) + data = make([]*spec.VersionedAttestation, len(decoded)) + for i := range decoded { + data[i] = &spec.VersionedAttestation{Version: spec.DataVersionGloas, Gloas: decoded[i]} + } + default: + return nil, fmt.Errorf("unsupported attestation version %s", httpResponse.consensusVersion) + } if err != nil { return nil, err } @@ -119,6 +186,10 @@ func verifyAttestationPool(opts *api.AttestationPoolOpts, data []*spec.Versioned if err := verifyElectraAttestation(opts, datum.Fulu); err != nil { return err } + case spec.DataVersionGloas: + if err := verifyGloasAttestation(opts, datum.Gloas); err != nil { + return err + } default: return errors.New("unsupported attestation version") } @@ -128,6 +199,12 @@ func verifyAttestationPool(opts *api.AttestationPoolOpts, data []*spec.Versioned } func verifyPhase0Attestation(opts *api.AttestationPoolOpts, data *phase0.Attestation) error { + // A null entry in the response array decodes to a nil element, so this must be + // rejected rather than dereferenced. + if data == nil || data.Data == nil { + return errors.New("nil attestation in response") + } + if opts.Slot != nil && data.Data.Slot != *opts.Slot { return errors.New("attestation data not for requested slot") } @@ -140,7 +217,33 @@ func verifyPhase0Attestation(opts *api.AttestationPoolOpts, data *phase0.Attesta } func verifyElectraAttestation(opts *api.AttestationPoolOpts, data *electra.Attestation) error { - if opts.Slot != nil && data.Data.Slot != *opts.Slot { + if data == nil { + return errors.New("nil attestation in response") + } + + return verifyPostElectraAttestation(opts, data.Data, data.CommitteeBits) +} + +func verifyGloasAttestation(opts *api.AttestationPoolOpts, data *gloas.Attestation) error { + if data == nil { + return errors.New("nil attestation in response") + } + + return verifyPostElectraAttestation(opts, data.Data, data.CommitteeBits) +} + +// verifyPostElectraAttestation holds the checks shared by the electra-onwards +// attestation containers, whose committee bits are all bitfield.Bitvector64. +func verifyPostElectraAttestation( + opts *api.AttestationPoolOpts, + data *phase0.AttestationData, + committeeBits bitfield.Bitvector64, +) error { + if data == nil { + return errors.New("nil attestation in response") + } + + if opts.Slot != nil && data.Slot != *opts.Slot { return errors.New("attestation data not for requested slot") } @@ -150,7 +253,7 @@ func verifyElectraAttestation(opts *api.AttestationPoolOpts, data *electra.Attes return nil } - for _, committeeIndex := range data.CommitteeBits.BitIndices() { + for _, committeeIndex := range committeeBits.BitIndices() { if phase0.CommitteeIndex(committeeIndex) == *opts.CommitteeIndex { // We have a match. return nil diff --git a/http/attestationpool_internal_test.go b/http/attestationpool_internal_test.go new file mode 100644 index 00000000..47691970 --- /dev/null +++ b/http/attestationpool_internal_test.go @@ -0,0 +1,140 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "context" + "testing" + + "github.com/OffchainLabs/go-bitfield" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// TestAttestationPoolFromJSONNullElement covers a null entry in the response array, +// which decodes to a nil element. Every version arm wraps the decoded elements in a +// non-nil VersionedAttestation, so the nil has to be caught before the verifier reads +// through it; without the guard the client panics on a malformed response instead of +// returning an error. +func TestAttestationPoolFromJSONNullElement(t *testing.T) { + for _, version := range []spec.DataVersion{ + spec.DataVersionPhase0, + spec.DataVersionElectra, + spec.DataVersionGloas, + } { + t.Run(version.String(), func(t *testing.T) { + _, err := new(Service).attestationPoolFromJSON(context.Background(), + &api.AttestationPoolOpts{}, + &httpResponse{ + consensusVersion: version, + body: []byte(`{"version":"` + version.String() + `","data":[null]}`), + }, + ) + require.EqualError(t, err, "nil attestation in response") + }) + } +} + +func TestAttestationPoolFromJSONGloas(t *testing.T) { + poolResponse, err := new(Service).attestationPoolFromJSON(context.Background(), + &api.AttestationPoolOpts{}, + &httpResponse{ + consensusVersion: spec.DataVersionGloas, + body: []byte(`{"version":"gloas","data":[` + gloasAttestationJSON + `]}`), + }, + ) + require.NoError(t, err) + require.Len(t, poolResponse.Data, 1) + require.Equal(t, spec.DataVersionGloas, poolResponse.Data[0].Version) + require.NotNil(t, poolResponse.Data[0].Gloas) + + aggregate, _, err := decodeAggregateAttestation(&httpResponse{ + consensusVersion: spec.DataVersionGloas, + body: []byte(`{"version":"gloas","data":` + gloasAttestationJSON + `}`), + }) + require.NoError(t, err) + + poolRoot, err := poolResponse.Data[0].HashTreeRoot() + require.NoError(t, err) + aggregateRoot, err := aggregate.HashTreeRoot() + require.NoError(t, err) + require.Equal(t, aggregateRoot, poolRoot) +} + +func TestVerifyAttestationPoolGloas(t *testing.T) { + attestation := func(slot phase0.Slot, committeeIndex phase0.CommitteeIndex) *spec.VersionedAttestation { + committeeBits := bitfield.NewBitvector64() + committeeBits.SetBitAt(uint64(committeeIndex), true) + + return &spec.VersionedAttestation{ + Version: spec.DataVersionGloas, + Gloas: &gloas.Attestation{ + Data: &phase0.AttestationData{Slot: slot}, + CommitteeBits: committeeBits, + }, + } + } + + matchingSlot := phase0.Slot(84434) + otherSlot := phase0.Slot(84435) + matchingCommitteeIndex := phase0.CommitteeIndex(3) + otherCommitteeIndex := phase0.CommitteeIndex(4) + matchingAttestation := attestation(matchingSlot, matchingCommitteeIndex) + + tests := []struct { + name string + opts *api.AttestationPoolOpts + err string + }{ + { + name: "NoFilters", + opts: &api.AttestationPoolOpts{}, + }, + { + name: "SlotAndCommitteeIndexMatch", + opts: &api.AttestationPoolOpts{ + Slot: &matchingSlot, + CommitteeIndex: &matchingCommitteeIndex, + }, + }, + { + name: "SlotDiffers", + opts: &api.AttestationPoolOpts{ + Slot: &otherSlot, + }, + err: "attestation data not for requested slot", + }, + { + name: "CommitteeIndexDiffers", + opts: &api.AttestationPoolOpts{ + CommitteeIndex: &otherCommitteeIndex, + }, + err: "attestation data not for requested committee index", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := verifyAttestationPool(test.opts, []*spec.VersionedAttestation{matchingAttestation}) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/http/beaconstate.go b/http/beaconstate.go index 6e9e4366..35544aef 100644 --- a/http/beaconstate.go +++ b/http/beaconstate.go @@ -1,4 +1,4 @@ -// Copyright © 2020 - 2024 Attestant Limited. +// Copyright © 2020 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -28,6 +28,7 @@ import ( "github.com/attestantio/go-eth2-client/spec/deneb" "github.com/attestantio/go-eth2-client/spec/electra" "github.com/attestantio/go-eth2-client/spec/fulu" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" dynssz "github.com/pk910/dynamic-ssz" ) @@ -167,6 +168,17 @@ func (s *Service) beaconStateFromSSZ(ctx context.Context, res *httpResponse) (*a if err != nil { return nil, errors.Join(errors.New("failed to decode fulu beacon state"), err) } + case spec.DataVersionGloas: + response.Data.Gloas = &gloas.BeaconState{} + if s.customSpecSupport { + err = dynSSZ.UnmarshalSSZ(response.Data.Gloas, res.body) + } else { + err = response.Data.Gloas.UnmarshalSSZ(res.body) + } + + if err != nil { + return nil, errors.Join(errors.New("failed to decode gloas beacon state"), err) + } default: return nil, fmt.Errorf("unhandled state version %s", res.consensusVersion) } @@ -198,6 +210,8 @@ func (*Service) beaconStateFromJSON(res *httpResponse) (*api.Response[*spec.Vers response.Data.Electra, response.Metadata, err = decodeJSONResponse(bytes.NewReader(res.body), &electra.BeaconState{}) case spec.DataVersionFulu: response.Data.Fulu, response.Metadata, err = decodeJSONResponse(bytes.NewReader(res.body), &fulu.BeaconState{}) + case spec.DataVersionGloas: + response.Data.Gloas, response.Metadata, err = decodeJSONResponse(bytes.NewReader(res.body), &gloas.BeaconState{}) default: err = fmt.Errorf("unsupported version %s", res.consensusVersion) } diff --git a/http/blobs_test.go b/http/blobs_test.go index 93b61690..4016352d 100644 --- a/http/blobs_test.go +++ b/http/blobs_test.go @@ -44,6 +44,12 @@ func TestBlobs(t *testing.T) { return } + blobKZGCommitments, err := headBlock.Data.BlobKZGCommitments() + require.NoError(t, err) + if len(blobKZGCommitments) == 0 { + t.Skip("Head block has no blobs") + } + headBlockSlot, err := headBlock.Data.Slot() require.NoError(t, err) headBlockRoot, err := headBlock.Data.Root() diff --git a/http/blobsidecars_test.go b/http/blobsidecars_test.go index 856e611e..9cff26b9 100644 --- a/http/blobsidecars_test.go +++ b/http/blobsidecars_test.go @@ -21,6 +21,7 @@ import ( client "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" "github.com/attestantio/go-eth2-client/spec/deneb" dynssz "github.com/pk910/dynamic-ssz" "github.com/stretchr/testify/require" @@ -38,6 +39,16 @@ func TestBlobsSidecars(t *testing.T) { require.NoError(t, err) require.NotNil(t, headBlock) + if headBlock.Data.Version < spec.DataVersionDeneb { + t.Skip("Client does not support Deneb") + } + + blobKZGCommitments, err := headBlock.Data.BlobKZGCommitments() + require.NoError(t, err) + if len(blobKZGCommitments) == 0 { + t.Skip("Head block has no blobs") + } + headBlockSlot, err := headBlock.Data.Slot() require.NoError(t, err) headBlockRoot, err := headBlock.Data.Root() diff --git a/http/epbsguardwiring_internal_test.go b/http/epbsguardwiring_internal_test.go new file mode 100644 index 00000000..ccf0964d --- /dev/null +++ b/http/epbsguardwiring_internal_test.go @@ -0,0 +1,166 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +// The consistency guards on the two new gloas endpoints are tested directly +// elsewhere, which establishes that they decide correctly but not that anything +// calls them. A live node cannot establish it either: a node that honours the +// request never disagrees with it, so the guards are unreachable in a passing +// live test and deleting the call sites leaves the whole suite green. +// +// These tests close that by answering from a server that deliberately disagrees. +// That server fronts the live node: it answers the one endpoint under test from +// the handler and forwards everything else on, so the spec fetch, the sync +// assertion and the SSZ codec are all still genuine. +// +// It fronts the node rather than the service's base URL being repointed after +// construction, which is what this used to do. New hands the service to +// background goroutines that ping it on their own schedule, and those read base +// with no synchronisation, so writing it afterwards is a data race that +// go test -race reports as soon as HTTP_ADDRESS is set. + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/http/httputil" + "os" + "strings" + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// disagreeingService returns a service whose requests for endpoints under prefix +// are answered by handler, and which is otherwise configured from, and talks to, +// the live node. +func disagreeingService(ctx context.Context, + t *testing.T, + prefix string, + handler http.HandlerFunc, +) *Service { + t.Helper() + + address := os.Getenv("HTTP_ADDRESS") + if address == "" { + t.Skip("HTTP_ADDRESS not set") + } + + target, _, err := parseAddress(address) + require.NoError(t, err) + + proxy := &httputil.ReverseProxy{ + Rewrite: func(r *httputil.ProxyRequest) { + r.SetURL(target) + // The transport the proxy uses does not act on URL credentials the way + // the client does, so they have to be sent explicitly. + if target.User != nil { + password, _ := target.User.Password() + r.Out.SetBasicAuth(target.User.Username(), password) + } + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, prefix) { + handler(w, r) + + return + } + proxy.ServeHTTP(w, r) + })) + t.Cleanup(server.Close) + + return newTestService(ctx, t, true, WithEnforceJSON(true), WithAddress(server.URL)) +} + +// TestEPBSProposalRejectsADisagreeingNode verifies that the request-consistency +// guard is reached from EPBSProposal, by asking a node that includes an +// execution payload that was not requested. +func TestEPBSProposalRejectsADisagreeingNode(t *testing.T) { + ctx := context.Background() + + const slot = 60300 + + reveal := phase0.BLSSignature{0x0a, 0x0b, 0x0c} + + // The response is assembled here rather than inside the handler: a failed + // assertion in the handler's goroutine calls runtime.Goexit outside the test's + // own goroutine, which abandons the request half-answered and reports the + // failure as a transport error naming nothing. + contents := validEPBSBlockContents() + contents.Block.Slot = slot + contents.Block.Body.RANDAOReveal = reveal + root, err := contents.Block.HashTreeRoot() + require.NoError(t, err) + contents.ExecutionPayloadEnvelope.BeaconBlockRoot = root + + data, err := json.Marshal(contents) + require.NoError(t, err) + + // Always answers payload-included, whatever was asked for. This is the + // direction that remains a disagreement: a node volunteering a payload nobody + // asked for changes where the block can be published. The opposite answer is + // what an external builder's bid legitimately looks like, so it would no + // longer prove the guard was reached. + s := disagreeingService(ctx, t, "/eth/v4/validator/blocks/", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Eth-Consensus-Version", "gloas") + fmt.Fprintf(w, `{"version":"gloas","execution_payload_included":true,"data":%s}`, data) + }) + + includePayload := false + + _, err = s.EPBSProposal(ctx, &api.EPBSProposalOpts{ + Slot: slot, + RandaoReveal: reveal, + BuilderConfig: &gloas.BuilderConfig{Builders: []*gloas.BuilderEntry{}}, + IncludePayload: &includePayload, + }) + require.ErrorIs(t, err, client.ErrInconsistentResult) + require.ErrorContains(t, err, "execution payload included true; expected false") +} + +// TestExecutionPayloadEnvelopeRejectsADisagreeingNode verifies that the block-root +// guard is reached from ExecutionPayloadEnvelope, by asking a node that answers +// with an envelope committed to a different block. +func TestExecutionPayloadEnvelopeRejectsADisagreeingNode(t *testing.T) { + ctx := context.Background() + + data, err := json.Marshal(validExecutionPayloadEnvelope()) + require.NoError(t, err) + + // Always answers with the fixture's own root, whatever was asked for. + s := disagreeingService(ctx, t, + "/eth/v1/validator/execution_payload_envelopes/", + func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Eth-Consensus-Version", "gloas") + fmt.Fprintf(w, `{"version":"gloas","data":%s}`, data) + }, + ) + + _, err = s.ExecutionPayloadEnvelope(ctx, &api.ExecutionPayloadEnvelopeOpts{ + Slot: 60300, + BeaconBlockRoot: phase0.Root{0xde, 0xad, 0xbe, 0xef}, + }) + require.ErrorIs(t, err, client.ErrInconsistentResult) + require.ErrorContains(t, err, "execution payload envelope for block") +} diff --git a/http/epbsproposal.go b/http/epbsproposal.go new file mode 100644 index 00000000..483ad04a --- /dev/null +++ b/http/epbsproposal.go @@ -0,0 +1,714 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "math/big" + "strconv" + "strings" + "unicode/utf8" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + apiv1gloas "github.com/attestantio/go-eth2-client/api/v1/gloas" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "go.opentelemetry.io/otel" +) + +// maxProposalValueDigits bounds the length of a value header before it is +// parsed. The total supply of ether is under 10^27 wei, so 40 digits leaves +// generous room for any real value while keeping a hostile one cheap to reject. +const maxProposalValueDigits = 40 + +// maxEPBSResponseSize bounds the bodies of the ePBS fetch endpoints and, via +// post() (see http.go), every POST response body across the library. Those +// bodies are an execution payload envelope, a payload-attestation datum or +// pool, or a small acknowledgement or error, so 64MiB is orders of magnitude +// above any legitimate response while capping a hostile or runaway one. Block +// production is the one endpoint whose response can be far larger, and it +// passes its own limit below rather than raising this one for everybody. +const maxEPBSResponseSize = 64 * 1024 * 1024 + +// maxEPBSProposalResponseSize bounds a full SSZ payload-included block +// production response. It is an allocation budget, not the protocol's static +// maximum: readResponseBody has to buffer a body in full before it can reject +// it, so a limit derived from the pinned Gloas worst case -- 4096 128KiB blobs +// plus 33,554,432 KZG proofs, upwards of 2GiB -- is one the process cannot +// survive reaching, and admitting a body just under it is no better. Every +// live preset proposes orders of magnitude below this. A legitimate response +// refused here fails loudly with the limit in the message, so a future preset +// that genuinely outgrows it needs this raised deliberately; +// TestEPBSProposalResponseLimitsAreSurvivable holds the ceiling it must respect. +const maxEPBSProposalResponseSize = 256 * 1024 * 1024 + +// maxEPBSProposalJSONResponseSize covers the same response as JSON, whose hex +// costs slightly over two bytes per SSZ byte. It is therefore a marginally +// tighter bound on the payload than its SSZ counterpart rather than an equal +// one, which the headroom above makes immaterial. +const maxEPBSProposalJSONResponseSize = 512 * 1024 * 1024 + +// EPBSProposal fetches a potential ePBS beacon block for signing. +func (s *Service) EPBSProposal(ctx context.Context, + opts *api.EPBSProposalOpts, +) ( + *api.Response[*api.VersionedEPBSProposal], + error, +) { + ctx, span := otel.Tracer("attestantio.go-eth2-client.http").Start(ctx, "EPBSProposal") + defer span.End() + + if err := s.assertIsSynced(ctx); err != nil { + return nil, err + } + + query, err := epbsProposalQuery(opts) + if err != nil { + return nil, err + } + + if err := validateBuilderConfig(opts.BuilderConfig, opts.Slot); err != nil { + return nil, err + } + + body, contentType, err := s.marshalRequestBody(ctx, opts.BuilderConfig) + if err != nil { + return nil, err + } + + endpoint := fmt.Sprintf("/eth/v4/validator/blocks/%d", opts.Slot) + headers := map[string]string{ + "Accept": contentType.MediaType(), + "Eth-Consensus-Version": spec.DataVersionGloas.String(), + } + + responseLimit := maxEPBSProposalResponseSize + if contentType == ContentTypeJSON { + responseLimit = maxEPBSProposalJSONResponseSize + } + + httpResponse, err := s.postWithResponseLimit( + ctx, + endpoint, + query, + &opts.Common, + bytes.NewReader(body), + contentType, + headers, + responseLimit, + ) + if err != nil { + return nil, errors.Join(errors.New("failed to request epbs beacon block proposal"), err) + } + if err := populateConsensusVersionFromHeaders(httpResponse); err != nil { + return nil, err + } + + response, err := s.epbsProposalFromResponse(ctx, httpResponse) + if err != nil { + return nil, err + } + + if err := s.assertEPBSProposalMatchesRequest(response.Data, opts); err != nil { + return nil, err + } + builderURL := headerValue(httpResponse.headers, "Eth-Builder-Url") + selfBuildIndex, err := s.builderIndexSelfBuild(ctx) + if err != nil { + return nil, err + } + if err := validateEPBSProposalExecutionValue(response.Data, opts.BuilderConfig, builderURL, selfBuildIndex); err != nil { + return nil, err + } + + return response, nil +} + +// assertEPBSProposalMatchesRequest checks the returned proposal matches the request. +func (s *Service) assertEPBSProposalMatchesRequest(proposal *api.VersionedEPBSProposal, + opts *api.EPBSProposalOpts, +) error { + blockSlot, err := proposal.Slot() + if err != nil { + return err + } + + if blockSlot != opts.Slot { + return errors.Join( + fmt.Errorf("epbs beacon block proposal for slot %d; expected %d", blockSlot, opts.Slot), + client.ErrInconsistentResult, + ) + } + + // A node must not include a payload that was not requested. + if proposal.ExecutionPayloadIncluded && !*opts.IncludePayload { + return errors.Join( + fmt.Errorf("epbs beacon block proposal has execution payload included %t; expected %t", + proposal.ExecutionPayloadIncluded, *opts.IncludePayload), + client.ErrInconsistentResult, + ) + } + + // Only check the RANDAO reveal if we are not connected to DVT middleware, + // as the returned values will be decided by the middleware. + if s.connectedToDVTMiddleware { + return nil + } + + blockRandaoReveal, err := proposal.RandaoReveal() + if err != nil { + return err + } + + if !bytes.Equal(blockRandaoReveal[:], opts.RandaoReveal[:]) { + return errors.Join( + fmt.Errorf("epbs beacon block proposal has RANDAO reveal %#x; expected %#x", + blockRandaoReveal[:], opts.RandaoReveal[:]), + client.ErrInconsistentResult, + ) + } + + return nil +} + +// staticBuilderIndexSelfBuild is the mainnet value of the BUILDER_INDEX_SELF_BUILD +// spec constant, the bid builder index that marks a proposer self-build. It is +// a configurable spec value rather than a fixed sentinel, so a custom preset can +// move it; builderIndexSelfBuild reads it from the node in that case. +const staticBuilderIndexSelfBuild = gloas.BuilderIndex(^uint64(0)) + +// builderIndexSelfBuild returns the builder index that marks a self-built bid. +func (s *Service) builderIndexSelfBuild(ctx context.Context) (gloas.BuilderIndex, error) { + if !s.customSpecSupport { + return staticBuilderIndexSelfBuild, nil + } + + response, err := s.Spec(ctx, &api.SpecOpts{}) + if err != nil { + return 0, err + } + + // BUILDER_INDEX_SELF_BUILD arrived with Gloas, so a node on an earlier + // release does not serve it. Absence is not an error: fall back to the + // mainnet value, which is what such a node is using anyway. + raw, exists := response.Data["BUILDER_INDEX_SELF_BUILD"] + if !exists { + return staticBuilderIndexSelfBuild, nil + } + value, isCorrectType := raw.(uint64) + if !isCorrectType { + return 0, ErrIncorrectType + } + + return gloas.BuilderIndex(value), nil +} + +// validateEPBSProposalExecutionValue checks the bid a proposal commits to against +// the policy that was requested, and checks the execution value the node reported +// against the bid that is signed in the block. A value the block gives no way to +// check -- because the winning builder cannot be identified, or the bid is +// self-built -- is cleared rather than passed on, so a caller ranking proposals +// never treats an unverified number as a verified one. +func validateEPBSProposalExecutionValue(proposal *api.VersionedEPBSProposal, + config *gloas.BuilderConfig, + builderURL string, + selfBuildIndex gloas.BuilderIndex, +) error { + block := proposal.Gloas + if proposal.ExecutionPayloadIncluded { + block = proposal.GloasContents.Block + } + bid := block.Body.SignedExecutionPayloadBid.Message + + // The bid checks below run whether or not the node sent a value header. + // They are statements about the block, and the header is the node's to + // omit, so gating them on it would let a node skip them by leaving it out. + if bid.BuilderIndex == selfBuildIndex { + if bid.Value != 0 { + return errors.Join(errors.New("self-built execution payload bid has non-zero value"), client.ErrInconsistentResult) + } + proposal.ExecutionValue = nil + + return nil + } + + policy := config + if builderURL != "" { + entry := builderEntryForURL(config, builderURL) + if entry == nil { + proposal.ExecutionValue = nil + + return nil + } + + // A builder-API bid is judged against the entry it was solicited + // under, not the config-wide minimum, which gates p2p bids only. + policy = &gloas.BuilderConfig{MinBid: entry.MinBid, Builders: []*gloas.BuilderEntry{entry}} + } else if bid.ExecutionPayment != 0 { + // No builder URL means p2p or self-build. The cap a p2p payment + // counts up to is the maximum over the configured entries whose + // pubkeys include the bidding builder's, and the block carries no + // pubkey to resolve the builder index against, so the payment -- and + // with it the value -- cannot be checked here. + proposal.ExecutionValue = nil + + return nil + } + + // The proposer's take counts the execution payment only up to the cap of + // the entry the bid was solicited under. A p2p bid has no such entry, and + // the payment check above is what makes a zero cap right for it. + maxPayment := phase0.Gwei(0) + if len(policy.Builders) == 1 { + maxPayment = policy.Builders[0].MaxExecutionPayment + } + payment := min(bid.ExecutionPayment, maxPayment) + + value := new(big.Int).SetUint64(uint64(bid.Value)) + total := new(big.Int).Add(value, new(big.Int).SetUint64(uint64(payment))) + if total.Cmp(new(big.Int).SetUint64(uint64(policy.MinBid))) < 0 { + return errors.Join(errors.New("execution payload bid below minimum"), client.ErrInconsistentResult) + } + + if proposal.ExecutionValue == nil { + return nil + } + + // beacon-APIs calls the reported value "the total value of the builder bid" + // without saying whether the execution payment is part of that total. + // Prysm reports the bid value alone; the wording equally admits value plus + // the payment the proposer actually collects. Both ends of that bracket + // are bound to the signed bid, so a value inside it is accepted rather than + // failing the proposal over a reading of an ambiguous sentence. + gwei := big.NewInt(1_000_000_000) + low := value.Mul(value, gwei) + high := total.Mul(total, gwei) + if proposal.ExecutionValue.Cmp(low) < 0 || proposal.ExecutionValue.Cmp(high) > 0 { + return errors.Join(errors.New("execution payload value does not match bid"), client.ErrInconsistentResult) + } + + return nil +} + +func builderEntryForURL(config *gloas.BuilderConfig, builderURL string) *gloas.BuilderEntry { + var entry *gloas.BuilderEntry + for _, candidate := range config.Builders { + if string(candidate.URL) == builderURL { + if entry != nil { + return nil + } + + entry = candidate + } + } + + return entry +} + +func headerValue(headers map[string]string, name string) string { + for key, value := range headers { + if strings.EqualFold(key, name) { + return value + } + } + + return "" +} + +// epbsProposalQuery validates the options and builds the endpoint's query +// string. Both required parameters are checked here, before anything reaches +// the network. +func epbsProposalQuery(opts *api.EPBSProposalOpts) (string, error) { + if opts == nil { + return "", client.ErrNoOptions + } + + if opts.Slot == 0 { + return "", errors.Join(errors.New("no slot specified"), client.ErrInvalidOptions) + } + + // The spec marks include_payload required with no default, and the two + // modes are not interchangeable: false constrains the block to being + // published through the node that produced it. There is no safe value to + // assume, so an unset one is refused. + if opts.IncludePayload == nil { + return "", errors.Join(errors.New("no payload inclusion specified"), client.ErrInvalidOptions) + } + + query := fmt.Sprintf("randao_reveal=%#x&graffiti=%#x&include_payload=%t", + opts.RandaoReveal, opts.Graffiti, *opts.IncludePayload) + + if opts.SkipRandaoVerification { + if !opts.RandaoReveal.IsInfinity() { + return "", errors.Join( + errors.New("randao reveal must be point at infinity if skip randao verification is set"), + client.ErrInvalidOptions, + ) + } + + query += "&skip_randao_verification" + } + + return query, nil +} + +func validateBuilderConfig(config *gloas.BuilderConfig, slot phase0.Slot) error { + if config == nil { + return errors.Join(errors.New("no builder config supplied"), client.ErrInvalidOptions) + } + // An empty builders list is a legitimate request -- it solicits no builder + // bids, leaving only p2p ones -- so only the endpoint's maximum is enforced + // here. A nil slice says the same thing as an empty one and encodes + // identically in both JSON and SSZ, so it is accepted too. + if len(config.Builders) > 64 { + return errors.Join(errors.New("too many builders supplied"), client.ErrInvalidOptions) + } + + for i, builder := range config.Builders { + if builder == nil { + return errors.Join(fmt.Errorf("builder %d missing", i), client.ErrInvalidOptions) + } + if len(builder.URL) == 0 || len(builder.URL) > 2048 || !utf8.Valid(builder.URL) { + return errors.Join(fmt.Errorf("builder %d has invalid URL", i), client.ErrInvalidOptions) + } + auth := builder.Auth + if auth == nil || auth.Message == nil || len(auth.Message.Data) == 0 || len(auth.Message.Data) > 4096 { + return errors.Join(fmt.Errorf("builder %d has invalid authorization", i), client.ErrInvalidOptions) + } + // The authorization slot is verified by the builder rather than by the + // beacon node, so a stale one is not rejected anywhere on the request + // path: the node forwards it, the builder declines to bid, and the + // proposal succeeds on a p2p or local build with no error raised. + // Refusing it here is the only point at which the caller learns. + if auth.Message.Slot != slot { + return errors.Join( + fmt.Errorf("builder %d has authorization for slot %d, not %d", i, auth.Message.Slot, slot), + client.ErrInvalidOptions, + ) + } + if len(builder.BuilderPubkeys) > 64 { + return errors.Join(fmt.Errorf("builder %d has too many public keys", i), client.ErrInvalidOptions) + } + } + + return nil +} + +func populateConsensusVersionFromHeaders(res *httpResponse) error { + for key, value := range res.headers { + if !strings.EqualFold(key, "Eth-Consensus-Version") { + continue + } + + if err := res.consensusVersion.UnmarshalJSON(fmt.Appendf(nil, "%q", value)); err != nil { + return errors.Join(errors.New("failed to parse consensus version"), err) + } + + return nil + } + + return errors.New("no Eth-Consensus-Version header in epbs proposal response") +} + +// epbsProposalFromResponse decodes a fetched ePBS block-production response. +// It is separate from the fetch so that the paths a live node will not produce +// on demand remain testable without one. +func (s *Service) epbsProposalFromResponse(ctx context.Context, + res *httpResponse, +) ( + *api.Response[*api.VersionedEPBSProposal], + error, +) { + if res.consensusVersion != spec.DataVersionGloas { + return nil, fmt.Errorf("epbs proposal not available for version %s", res.consensusVersion) + } + + proposal := &api.VersionedEPBSProposal{ + Version: res.consensusVersion, + } + metadata := metadataFromHeaders(res.headers) + + if err := populateEPBSProposalValuesFromHeaders(proposal, res.headers); err != nil { + return nil, err + } + + // Custom presets require a request-scoped codec. The same codec that + // decodes the proposal has to compute its body root below: the generated + // HashTreeRoot methods inline mainnet preset sizes and are wrong on any + // other preset, and that applies just as much to a JSON-decoded proposal + // as to an SSZ-decoded one. + ds, err := s.dynSSZForRequest(ctx) + if err != nil { + return nil, err + } + + switch res.contentType { + case ContentTypeSSZ: + included, err := epbsPayloadIncludedFromHeaders(res.headers) + if err != nil { + return nil, err + } + + proposal.ExecutionPayloadIncluded = included + + if included { + proposal.GloasContents = &apiv1gloas.BlockContents{} + err = ds.UnmarshalSSZ(proposal.GloasContents, res.body) + } else { + proposal.Gloas = &gloas.BeaconBlock{} + err = ds.UnmarshalSSZ(proposal.Gloas, res.body) + } + + if err != nil { + return nil, errors.Join( + fmt.Errorf("failed to decode %s SSZ epbs proposal", res.consensusVersion), + err, + ) + } + case ContentTypeJSON: + jsonMetadata, err := decodeEPBSProposalJSON(res.body, proposal) + if err != nil { + return nil, errors.Join( + fmt.Errorf("failed to decode %s JSON epbs proposal", res.consensusVersion), + err, + ) + } + + maps.Copy(metadata, jsonMetadata) + default: + return nil, fmt.Errorf("unhandled content type %v", res.contentType) + } + + // Neither an absent data key nor an explicit null leaves a proposal behind. + // Returning success here would hand back a value whose every accessor + // errors, discovered only by whichever the caller reaches first. + if proposal.IsEmpty() { + return nil, fmt.Errorf("no %s epbs proposal in response", res.consensusVersion) + } + + block := proposal.Gloas + if proposal.ExecutionPayloadIncluded && proposal.GloasContents != nil { + block = proposal.GloasContents.Block + } + + if block == nil || block.Body == nil { + return nil, fmt.Errorf("no %s beacon block body in response", res.consensusVersion) + } + if block.Body.SignedExecutionPayloadBid == nil || block.Body.SignedExecutionPayloadBid.Message == nil { + return nil, errors.Join(errors.New("no execution payload bid in epbs proposal response"), client.ErrInconsistentResult) + } + + builderIndex := block.Body.SignedExecutionPayloadBid.Message.BuilderIndex + proposal.BuilderIndex = &builderIndex + + // This is the one place BeaconBlockBodyRoot is set: ds is the codec that + // decoded the block above, so this is the only spec-aware root available. + // Every downstream consumer -- BodyRoot(), Root() and the guard below -- + // relies on this being correct rather than falling back to the generated, + // mainnet-baked Body.HashTreeRoot(). + bodyRootRaw, err := ds.HashTreeRoot(block.Body) + if err != nil { + return nil, errors.Join(errors.New("failed to compute epbs proposal body root"), err) + } + + bodyRoot := phase0.Root(bodyRootRaw) + proposal.BeaconBlockBodyRoot = &bodyRoot + + if proposal.ExecutionPayloadIncluded { + if err := assertIncludedEPBSProposalEnvelopeMatchesBlock(proposal, ds); err != nil { + return nil, err + } + } + + return &api.Response[*api.VersionedEPBSProposal]{ + Data: proposal, + Metadata: metadata, + }, nil +} + +// populateEPBSProposalValuesFromHeaders reads the value components from response headers. +func populateEPBSProposalValuesFromHeaders(proposal *api.VersionedEPBSProposal, + headers map[string]string, +) error { + for k, v := range headers { + var target **big.Int + + switch { + case strings.EqualFold(k, "Eth-Consensus-Block-Value"): + target = &proposal.ConsensusValue + case strings.EqualFold(k, "Eth-Execution-Payload-Value"): + target = &proposal.ExecutionValue + default: + continue + } + + // Bound the input before parsing it. big.Int.SetString is quadratic in + // the length of its input, and this is a response header, so its length + // is chosen by the node: a multi-megabyte value takes minutes to parse, + // and it is parsed after the request's own deadline has been released, + // so no caller timeout interrupts it. A block's worth of wei needs 30 + // digits at the very most, well inside this. + if len(v) > maxProposalValueDigits { + return fmt.Errorf("proposal header %s has %d digits; more than the %d a value can need", + k, len(v), maxProposalValueDigits) + } + + value, isValid := new(big.Int).SetString(v, 10) + if !isValid { + return fmt.Errorf("proposal header %s %s not a valid integer", k, v) + } + + if value.Sign() < 0 { + return fmt.Errorf("proposal header %s %s is negative", k, v) + } + + *target = value + } + + return nil +} + +// epbsPayloadIncludedFromHeaders reads the payload-inclusion flag from the +// Eth-Execution-Payload-Included response header, which is the only place it +// appears when the body is SSZ. A missing or malformed value is an error +// rather than a default: it selects which container the body holds, so guessing +// would decode block contents as a bare block and discard the envelope, blobs +// and proofs the caller needs to publish. +func epbsPayloadIncludedFromHeaders(headers map[string]string) (bool, error) { + for k, v := range headers { + if !strings.EqualFold(k, "Eth-Execution-Payload-Included") { + continue + } + + included, err := strconv.ParseBool(v) + if err != nil { + // Report the header's name but not its value. Its length is the + // node's to choose, so echoing it puts however many megabytes the + // node felt like sending into an error string and then into a log. + return false, errors.New("proposal header Eth-Execution-Payload-Included is not a valid boolean") + } + + return included, nil + } + + return false, errors.New("no Eth-Execution-Payload-Included header in epbs proposal response") +} + +// decodeEPBSProposalJSON reads the payload-inclusion flag from a JSON response +// body and decodes the data into the container it selects. The flag selects +// which of the two containers the data field carries, so it has to be read +// before the data itself can be decoded. +func decodeEPBSProposalJSON(body []byte, proposal *api.VersionedEPBSProposal) (map[string]any, error) { + response := make(map[string]json.RawMessage) + if err := json.Unmarshal(body, &response); err != nil { + return nil, errors.Join(errors.New("failed to parse epbs proposal response"), err) + } + + raw, exists := response["execution_payload_included"] + if !exists { + return nil, errors.New("no execution_payload_included in epbs proposal response") + } + var included *bool + if err := json.Unmarshal(raw, &included); err != nil { + return nil, errors.Join(errors.New("failed to unmarshal execution_payload_included"), err) + } + if included == nil { + return nil, errors.New("execution_payload_included cannot be null") + } + proposal.ExecutionPayloadIncluded = *included + + data, exists := response["data"] + if !exists || string(data) == "null" { + return nil, fmt.Errorf("no %s epbs proposal in response", proposal.Version) + } + if proposal.ExecutionPayloadIncluded { + proposal.GloasContents = new(apiv1gloas.BlockContents) + if err := json.Unmarshal(data, proposal.GloasContents); err != nil { + return nil, errors.Join(errors.New("failed to unmarshal data"), err) + } + } else { + proposal.Gloas = new(gloas.BeaconBlock) + if err := json.Unmarshal(data, proposal.Gloas); err != nil { + return nil, errors.Join(errors.New("failed to unmarshal data"), err) + } + } + + metadata := make(map[string]any, len(response)) + for key, raw := range response { + if key == "data" { + continue + } + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return nil, errors.Join(fmt.Errorf("failed to unmarshal metadata %s", key), err) + } + metadata[key] = value + } + + return metadata, nil +} + +// assertIncludedEPBSProposalEnvelopeMatchesBlock checks that the execution +// payload envelope that travelled with a proposal is for the block it +// travelled with. It uses proposal.Root() rather than +// proposal.GloasContents.Block.HashTreeRoot(): the latter is the generated, +// mainnet-baked hasher this package's body-root fix exists to avoid, so +// calling it here would let the exact bug back in through the guard meant to +// catch it. Root() shares its computation with every other consumer of the +// block root, so the guard and a proposer's signature cannot disagree. +func assertIncludedEPBSProposalEnvelopeMatchesBlock(proposal *api.VersionedEPBSProposal, ds *dynssz.DynSsz) error { + contents := proposal.GloasContents + if contents == nil || contents.Block == nil || contents.ExecutionPayloadEnvelope == nil { + return errors.New("no block contents in epbs proposal response") + } + + blockRoot, err := proposal.Root() + if err != nil { + return errors.Join(errors.New("failed to hash epbs proposal block"), err) + } + if contents.ExecutionPayloadEnvelope.BeaconBlockRoot != blockRoot { + return errors.Join(errors.New("execution payload envelope is for a different block"), client.ErrInconsistentResult) + } + envelope := contents.ExecutionPayloadEnvelope + bid := contents.Block.Body.SignedExecutionPayloadBid.Message + if envelope.BuilderIndex != bid.BuilderIndex { + return errors.Join(errors.New("execution payload envelope builder index does not match bid"), client.ErrInconsistentResult) + } + if envelope.Payload == nil || envelope.Payload.BlockHash != bid.BlockHash { + return errors.Join(errors.New("execution payload block hash does not match bid"), client.ErrInconsistentResult) + } + if envelope.ParentBeaconBlockRoot != bid.ParentBlockRoot { + return errors.Join(errors.New("execution payload envelope parent root does not match bid"), client.ErrInconsistentResult) + } + if contents.ExecutionPayloadEnvelope.ExecutionRequests == nil { + return errors.Join(errors.New("execution payload envelope has no execution requests"), client.ErrInconsistentResult) + } + executionRequestsRoot, err := ds.HashTreeRoot(contents.ExecutionPayloadEnvelope.ExecutionRequests) + if err != nil { + return errors.Join(errors.New("failed to hash execution payload envelope requests"), err) + } + if phase0.Root(executionRequestsRoot) != contents.Block.Body.SignedExecutionPayloadBid.Message.ExecutionRequestsRoot { + return errors.Join(errors.New("execution payload envelope requests do not match bid"), client.ErrInconsistentResult) + } + + return nil +} diff --git a/http/epbsproposal_builderconfig_test.go b/http/epbsproposal_builderconfig_test.go new file mode 100644 index 00000000..46a2110b --- /dev/null +++ b/http/epbsproposal_builderconfig_test.go @@ -0,0 +1,180 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + eth2http "github.com/attestantio/go-eth2-client/http" + "github.com/attestantio/go-eth2-client/mock" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" +) + +func validEPBSBuilderConfig() *gloas.BuilderConfig { + return &gloas.BuilderConfig{ + Builders: []*gloas.BuilderEntry{{ + URL: []byte("https://builder.example"), + Auth: &gloas.SignedBuilderRequestAuth{ + Message: &gloas.BuilderRequestAuth{Data: []byte{0x01}, Slot: 123}, + Signature: phase0.BLSSignature{0x02}, + }, + BuilderPubkeys: []phase0.BLSPubKey{}, + }}, + } +} + +func TestEPBSProposalJSONRequest(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/eth/v1/node/syncing": + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"data":{"head_slot":"0","sync_distance":"0","is_optimistic":false,"is_syncing":false}}`)) + require.NoError(t, err) + case "/eth/v1/node/version": + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"data":{"version":"test"}}`)) + require.NoError(t, err) + case "/eth/v4/validator/blocks/123": + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "application/json", r.Header.Get("Content-Type")) + require.Equal(t, "application/json", r.Header.Get("Accept")) + w.WriteHeader(http.StatusBadRequest) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + service, err := eth2http.New(context.Background(), + eth2http.WithAddress(server.URL), + eth2http.WithAllowDelayedStart(true), + eth2http.WithEnforceJSON(true), + eth2http.WithLogLevel(zerolog.Disabled), + ) + require.NoError(t, err) + + includePayload := true + _, err = service.(client.EPBSProposalProvider).EPBSProposal(context.Background(), &api.EPBSProposalOpts{ + Slot: 123, + BuilderConfig: validEPBSBuilderConfig(), + IncludePayload: &includePayload, + }) + require.Error(t, err) +} + +func TestEPBSProposalJSONResponseAcceptsBareBuilderWin(t *testing.T) { + mockService, err := mock.New(context.Background()) + require.NoError(t, err) + excludePayload := false + mockResponse, err := mockService.EPBSProposal(context.Background(), &api.EPBSProposalOpts{ + Slot: 123, + IncludePayload: &excludePayload, + }) + require.NoError(t, err) + block, err := json.Marshal(mockResponse.Data.Gloas) + require.NoError(t, err) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/eth/v1/node/syncing": + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"data":{"head_slot":"0","sync_distance":"0","is_optimistic":false,"is_syncing":false}}`)) + require.NoError(t, err) + case "/eth/v1/node/version": + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"data":{"version":"test"}}`)) + require.NoError(t, err) + case "/eth/v4/validator/blocks/123": + require.Equal(t, "application/json", r.Header.Get("Content-Type")) + require.Equal(t, "application/json", r.Header.Get("Accept")) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Eth-Consensus-Version", "gloas") + _, err := w.Write(append([]byte(`{"version":"gloas","execution_payload_included":false,"data":`), append(block, '}')...)) + require.NoError(t, err) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + service, err := eth2http.New(context.Background(), + eth2http.WithAddress(server.URL), + eth2http.WithAllowDelayedStart(true), + eth2http.WithEnforceJSON(true), + eth2http.WithLogLevel(zerolog.Disabled), + ) + require.NoError(t, err) + + includePayload := true + response, err := service.(client.EPBSProposalProvider).EPBSProposal(context.Background(), &api.EPBSProposalOpts{ + Slot: 123, + BuilderConfig: validEPBSBuilderConfig(), + IncludePayload: &includePayload, + }) + require.NoError(t, err) + require.False(t, response.Data.ExecutionPayloadIncluded) + require.NotNil(t, response.Data.Gloas) + require.Nil(t, response.Data.GloasContents) +} + +func TestEPBSProposalRejectsNonUTF8BuilderURL(t *testing.T) { + var blockRequests atomic.Uint64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/eth/v1/node/syncing": + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"data":{"head_slot":"0","sync_distance":"0","is_optimistic":false,"is_syncing":false}}`)) + require.NoError(t, err) + case "/eth/v1/node/version": + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"data":{"version":"test"}}`)) + require.NoError(t, err) + case "/eth/v4/validator/blocks/123": + blockRequests.Add(1) + http.NotFound(w, r) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + service, err := eth2http.New(context.Background(), + eth2http.WithAddress(server.URL), + eth2http.WithAllowDelayedStart(true), + eth2http.WithLogLevel(zerolog.Disabled), + ) + require.NoError(t, err) + + includePayload := true + config := validEPBSBuilderConfig() + config.Builders[0].URL = []byte{0xff} + + _, err = service.(client.EPBSProposalProvider).EPBSProposal(context.Background(), &api.EPBSProposalOpts{ + Slot: 123, + BuilderConfig: config, + IncludePayload: &includePayload, + }) + require.ErrorContains(t, err, "builder 0 has invalid URL") + require.Zero(t, blockRequests.Load()) +} diff --git a/http/epbsproposal_customspec_test.go b/http/epbsproposal_customspec_test.go new file mode 100644 index 00000000..90f5ad41 --- /dev/null +++ b/http/epbsproposal_customspec_test.go @@ -0,0 +1,229 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http_test + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + bitfield "github.com/OffchainLabs/go-bitfield" + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + apiv1gloas "github.com/attestantio/go-eth2-client/api/v1/gloas" + eth2http "github.com/attestantio/go-eth2-client/http" + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/holiman/uint256" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" +) + +// TestEPBSProposalCustomSpec verifies that an ePBS proposal decoded against a +// custom (minimal) preset is accepted and yields the correct roots. +// +// The request wire contract is pinned to beacon-APIs 159622d983a703eb03a8a37bb1edeab7ffc3b6bc: +// its required POST body, fields, static SSZ bounds, query parameters and response headers match +// Prysm 393e9b3c8c7b58bee90da9ae03d0f89988427afb's BuilderConfig proto and handler. Prysm retains +// a legacy GET handler, but this client deliberately sends only the pinned POST contract. +// +// Generated HashTreeRoot methods inline mainnet preset sizes (a 512-bit sync +// committee bitvector here) as Go literals and never consult the request's +// codec, so on a preset with a smaller SYNC_COMMITTEE_SIZE they compute a +// wrong body root. The response's execution payload envelope carries the +// correct root, computed by this test with the same request-scoped dynssz +// codec the client uses, via header composition rather than the block's own +// generated HashTreeRoot. Before the fix, the transport compared that +// correct envelope root against the generated (wrong) block root and +// rejected every such response as inconsistent. +func TestEPBSProposalCustomSpec(t *testing.T) { + ctx := context.Background() + dynamicSSZ := dynssz.NewDynSsz(map[string]any{ + "SYNC_COMMITTEE_SIZE": uint64(32), + }) + + slot := phase0.Slot(123) + randaoReveal := phase0.BLSSignature{0x01, 0x02} + graffiti := [32]byte{0x03, 0x04} + builderConfig := &gloas.BuilderConfig{ + BuilderBoostFactor: 100, + Builders: []*gloas.BuilderEntry{}, + } + + block := &gloas.BeaconBlock{ + Slot: slot, + ProposerIndex: 7, + ParentRoot: phase0.Root{0x11}, + StateRoot: phase0.Root{0x22}, + Body: &gloas.BeaconBlockBody{ + RANDAOReveal: randaoReveal, + Graffiti: graffiti, + SignedExecutionPayloadBid: &gloas.SignedExecutionPayloadBid{ + Message: &gloas.ExecutionPayloadBid{BuilderIndex: 3}, + }, + SyncAggregate: &altair.SyncAggregate{ + // Sized for the minimal preset (32 sync committee members, + // 4 bytes); the mainnet-sized generated hasher pads this to + // 64 bytes regardless of what is actually here. + SyncCommitteeBits: bitfield.Bitvector512(bitfield.NewBitvector32()), + }, + }, + } + + wantBodyRootRaw, err := dynamicSSZ.HashTreeRoot(block.Body) + require.NoError(t, err) + wantBodyRoot := phase0.Root(wantBodyRootRaw) + + generatedBodyRootRaw, err := block.Body.HashTreeRoot() + require.NoError(t, err) + generatedBodyRoot := phase0.Root(generatedBodyRootRaw) + + // The fixture is only useful if the generated (mainnet-preset) root + // actually differs from the minimal-preset one; otherwise this test + // could pass by accident on either code path. + require.NotEqual(t, wantBodyRoot, generatedBodyRoot) + + wantBlockRootRaw, err := (&phase0.BeaconBlockHeader{ + Slot: block.Slot, + ProposerIndex: block.ProposerIndex, + ParentRoot: block.ParentRoot, + StateRoot: block.StateRoot, + BodyRoot: wantBodyRoot, + }).HashTreeRoot() + require.NoError(t, err) + wantBlockRoot := phase0.Root(wantBlockRootRaw) + + contents := &apiv1gloas.BlockContents{ + Block: block, + ExecutionPayloadEnvelope: &gloas.ExecutionPayloadEnvelope{ + Payload: &gloas.ExecutionPayload{ + BaseFeePerGas: uint256.NewInt(0), + ExtraData: []byte{}, + Transactions: []bellatrix.Transaction{}, + Withdrawals: []*capella.Withdrawal{}, + BlockAccessList: []byte{}, + }, + ExecutionRequests: &gloas.ExecutionRequests{ + Deposits: []*electra.DepositRequest{}, + Withdrawals: []*electra.WithdrawalRequest{}, + Consolidations: []*electra.ConsolidationRequest{}, + BuilderDeposits: []*gloas.BuilderDepositRequest{}, + BuilderExits: []*gloas.BuilderExitRequest{}, + }, + BuilderIndex: 3, + BeaconBlockRoot: wantBlockRoot, + }, + } + + executionRequestsRoot, err := dynamicSSZ.HashTreeRoot(contents.ExecutionPayloadEnvelope.ExecutionRequests) + require.NoError(t, err) + block.Body.SignedExecutionPayloadBid.Message.ExecutionRequestsRoot = phase0.Root(executionRequestsRoot) + wantBodyRootRaw, err = dynamicSSZ.HashTreeRoot(block.Body) + require.NoError(t, err) + wantBodyRoot = phase0.Root(wantBodyRootRaw) + wantBlockRootRaw, err = (&phase0.BeaconBlockHeader{ + Slot: block.Slot, + ProposerIndex: block.ProposerIndex, + ParentRoot: block.ParentRoot, + StateRoot: block.StateRoot, + BodyRoot: wantBodyRoot, + }).HashTreeRoot() + require.NoError(t, err) + wantBlockRoot = phase0.Root(wantBlockRootRaw) + contents.ExecutionPayloadEnvelope.BeaconBlockRoot = wantBlockRoot + + encoded, err := dynamicSSZ.MarshalSSZ(contents) + require.NoError(t, err) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/eth/v1/node/syncing": + require.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"data":{"head_slot":"0","sync_distance":"0","is_optimistic":false,"is_syncing":false}}`)) + require.NoError(t, err) + case "/eth/v1/node/version": + require.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"data":{"version":"test"}}`)) + require.NoError(t, err) + case "/eth/v1/config/spec": + require.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"data":{"SYNC_COMMITTEE_SIZE":"32"}}`)) + require.NoError(t, err) + case "/eth/v4/validator/blocks/123": + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "gloas", r.Header.Get("Eth-Consensus-Version")) + require.Equal(t, "true", r.URL.Query().Get("include_payload")) + require.Equal(t, fmt.Sprintf("%#x", randaoReveal), r.URL.Query().Get("randao_reveal")) + require.Equal(t, fmt.Sprintf("%#x", graffiti), r.URL.Query().Get("graffiti")) + require.Contains(t, r.Header.Get("Content-Type"), "application/octet-stream") + body, readErr := io.ReadAll(r.Body) + require.NoError(t, readErr) + var gotBuilderConfig gloas.BuilderConfig + require.NoError(t, gotBuilderConfig.UnmarshalSSZ(body)) + require.Equal(t, builderConfig, &gotBuilderConfig) + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Eth-Consensus-Version", "gloas") + w.Header().Set("Eth-Execution-Payload-Included", "true") + w.Header().Set("Eth-Builder-Url", "https://builder.example") + _, err := w.Write(encoded) + require.NoError(t, err) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + service, err := eth2http.New(ctx, + eth2http.WithAddress(server.URL), + eth2http.WithCustomSpecSupport(true), + eth2http.WithLogLevel(zerolog.Disabled), + ) + require.NoError(t, err) + + includePayload := true + response, err := service.(client.EPBSProposalProvider).EPBSProposal(ctx, &api.EPBSProposalOpts{ + Slot: slot, + RandaoReveal: randaoReveal, + Graffiti: graffiti, + BuilderConfig: builderConfig, + IncludePayload: &includePayload, + }) + require.NoError(t, err) + require.NotNil(t, response) + require.Equal(t, "https://builder.example", response.Metadata["Eth-Builder-Url"]) + require.NotNil(t, response.Data.BuilderIndex) + require.Equal(t, gloas.BuilderIndex(3), *response.Data.BuilderIndex) + require.Nil(t, response.Data.ExecutionValue) + require.True(t, response.Data.ExecutionPayloadIncluded) + + gotBodyRoot, err := response.Data.BodyRoot() + require.NoError(t, err) + require.Equal(t, wantBodyRoot, gotBodyRoot) + require.NotEqual(t, generatedBodyRoot, gotBodyRoot) + + gotRoot, err := response.Data.Root() + require.NoError(t, err) + require.Equal(t, wantBlockRoot, gotRoot) +} diff --git a/http/epbsproposal_integrity_internal_test.go b/http/epbsproposal_integrity_internal_test.go new file mode 100644 index 00000000..20130b6f --- /dev/null +++ b/http/epbsproposal_integrity_internal_test.go @@ -0,0 +1,80 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "testing" + + bitfield "github.com/OffchainLabs/go-bitfield" + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + apiv1gloas "github.com/attestantio/go-eth2-client/api/v1/gloas" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/holiman/uint256" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/stretchr/testify/require" +) + +func TestAssertIncludedEPBSProposalEnvelopeMatchesBlock(t *testing.T) { + block := &gloas.BeaconBlock{ + Body: &gloas.BeaconBlockBody{ + ETH1Data: &phase0.ETH1Data{}, + SyncAggregate: &altair.SyncAggregate{SyncCommitteeBits: bitfield.NewBitvector512()}, + SignedExecutionPayloadBid: &gloas.SignedExecutionPayloadBid{Message: &gloas.ExecutionPayloadBid{}}, + ParentExecutionRequests: &gloas.ExecutionRequests{}, + }, + } + contents := &apiv1gloas.BlockContents{ + Block: block, + ExecutionPayloadEnvelope: &gloas.ExecutionPayloadEnvelope{ + Payload: &gloas.ExecutionPayload{BaseFeePerGas: uint256.NewInt(1)}, + ExecutionRequests: &gloas.ExecutionRequests{}, + }, + } + + bodyRoot, err := block.Body.HashTreeRoot() + require.NoError(t, err) + root := phase0.Root(bodyRoot) + + proposal := &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + GloasContents: contents, + BeaconBlockBodyRoot: &root, + } + + blockRoot, err := proposal.Root() + require.NoError(t, err) + contents.ExecutionPayloadEnvelope.BeaconBlockRoot = blockRoot + executionRequestsRoot, err := dynssz.GetGlobalDynSsz().HashTreeRoot(contents.ExecutionPayloadEnvelope.ExecutionRequests) + require.NoError(t, err) + contents.Block.Body.SignedExecutionPayloadBid.Message.ExecutionRequestsRoot = phase0.Root(executionRequestsRoot) + require.NoError(t, assertIncludedEPBSProposalEnvelopeMatchesBlock(proposal, dynssz.GetGlobalDynSsz())) + + contents.ExecutionPayloadEnvelope.BeaconBlockRoot = phase0.Root{0xff} + err = assertIncludedEPBSProposalEnvelopeMatchesBlock(proposal, dynssz.GetGlobalDynSsz()) + require.ErrorIs(t, err, client.ErrInconsistentResult) + + // A proposal whose body root was never retained must not fall back to + // the generated (and potentially wrong-preset) block hash: the guard + // must error rather than pass. + contents.ExecutionPayloadEnvelope.BeaconBlockRoot = blockRoot + proposal.BeaconBlockBodyRoot = nil + err = assertIncludedEPBSProposalEnvelopeMatchesBlock(proposal, dynssz.GetGlobalDynSsz()) + require.Error(t, err) + require.NotErrorIs(t, err, client.ErrInconsistentResult) +} diff --git a/http/epbsproposal_internal_test.go b/http/epbsproposal_internal_test.go new file mode 100644 index 00000000..fb64d892 --- /dev/null +++ b/http/epbsproposal_internal_test.go @@ -0,0 +1,640 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "net/http" + "strings" + "testing" + + bitfield "github.com/OffchainLabs/go-bitfield" + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + apiv1gloas "github.com/attestantio/go-eth2-client/api/v1/gloas" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" +) + +// validEPBSBeaconBlock returns a gloas beacon block populated just far enough +// for its own JSON marshaler to run: every pointer the marshaler dereferences +// is non-nil. +func validEPBSBeaconBlock() *gloas.BeaconBlock { + return &gloas.BeaconBlock{ + Slot: 60300, + ProposerIndex: 403, + Body: &gloas.BeaconBlockBody{ + RANDAOReveal: phase0.BLSSignature{0x0a, 0x0b, 0x0c}, + ETH1Data: &phase0.ETH1Data{BlockHash: make([]byte, phase0.Hash32Length)}, + SyncAggregate: &altair.SyncAggregate{SyncCommitteeBits: bitfield.NewBitvector512()}, + SignedExecutionPayloadBid: &gloas.SignedExecutionPayloadBid{Message: &gloas.ExecutionPayloadBid{}}, + ParentExecutionRequests: &gloas.ExecutionRequests{}, + }, + } +} + +// validEPBSBlockContents returns block contents carrying one KZG proof and no +// blobs. Blobs are left empty deliberately: deneb.Blob is a 128KiB array, so a +// populated one turns any assertion failure into a 256KiB hex diff. The +// payload's BaseFeePerGas must be non-nil, as its own marshaler dereferences it. +func validEPBSBlockContents() *apiv1gloas.BlockContents { + contents := &apiv1gloas.BlockContents{ + Block: validEPBSBeaconBlock(), + ExecutionPayloadEnvelope: &gloas.ExecutionPayloadEnvelope{ + Payload: &gloas.ExecutionPayload{BaseFeePerGas: uint256.NewInt(7)}, + ExecutionRequests: &gloas.ExecutionRequests{}, + BuilderIndex: 12, + }, + KZGProofs: []deneb.KZGProof{{0x01, 0x02, 0x03}}, + Blobs: []deneb.Blob{}, + } + + contents.Block.Body.SignedExecutionPayloadBid.Message.BuilderIndex = contents.ExecutionPayloadEnvelope.BuilderIndex + executionRequestsRoot, err := contents.ExecutionPayloadEnvelope.ExecutionRequests.HashTreeRoot() + if err != nil { + panic(err) + } + contents.Block.Body.SignedExecutionPayloadBid.Message.ExecutionRequestsRoot = phase0.Root(executionRequestsRoot) + + root, err := contents.Block.HashTreeRoot() + if err != nil { + panic(err) + } + contents.ExecutionPayloadEnvelope.BeaconBlockRoot = root + + return contents +} + +// epbsProposalJSONBody wraps a marshaled datum in the produceBlockV4 response +// object, whose value fields and payload-inclusion flag sit alongside the data. +// It returns the marshaled datum as well, so a test can compare what came back +// against the exact bytes that went in. +func epbsProposalJSONBody(t *testing.T, included bool, datum any) (body, data []byte) { + t.Helper() + + data, err := json.Marshal(datum) + require.NoError(t, err) + + return fmt.Appendf(nil, + `{"version":"gloas","consensus_block_value":"3476149000000000","execution_payload_value":"12345","execution_payload_included":%t,"data":%s}`, + included, data, + ), data +} + +// requireJSONEquals asserts that a decoded datum re-marshals to the bytes it was +// decoded from. Comparing bytes rather than using require.Equal on the structs +// is deliberate: the nested gloas codecs normalise a nil slice to an empty one +// on the way in, so struct equality would assert those packages' conventions +// rather than this decoder's behaviour. Byte equality still catches what +// matters — drop a field and its key re-marshals differently. +func requireJSONEquals(t *testing.T, want []byte, got any) { + t.Helper() + + reencoded, err := json.Marshal(got) + require.NoError(t, err) + require.JSONEq(t, string(want), string(reencoded)) +} + +// TestEPBSProposalFromResponse covers the decode half of the block-production +// endpoint, which takes an already-fetched response and so needs no beacon +// node. +func TestEPBSProposalFromResponse(t *testing.T) { + ctx := context.Background() + s := &Service{} + + // With the execution payload excluded the data is a bare beacon block, and + // the caller must fetch the envelope from the producing node to publish it. + t.Run("JSONPayloadExcluded", func(t *testing.T) { + body, data := epbsProposalJSONBody(t, false, validEPBSBeaconBlock()) + + response, err := s.epbsProposalFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionGloas, + body: body, + headers: map[string]string{}, + }) + require.NoError(t, err) + require.Equal(t, spec.DataVersionGloas, response.Data.Version) + require.False(t, response.Data.ExecutionPayloadIncluded) + require.Nil(t, response.Data.GloasContents) + require.NotNil(t, response.Data.Gloas) + require.Equal(t, phase0.Slot(60300), response.Data.Gloas.Slot) + requireJSONEquals(t, data, response.Data.Gloas) + }) + + // With the execution payload included the data is block contents: the block + // plus the envelope, blobs and proofs a caller needs to publish it from any + // node. The flag is what selects between the two containers, so decoding + // this body as a bare block would silently discard everything but the block. + t.Run("JSONPayloadIncluded", func(t *testing.T) { + body, data := epbsProposalJSONBody(t, true, validEPBSBlockContents()) + + response, err := s.epbsProposalFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionGloas, + body: body, + headers: map[string]string{}, + }) + require.NoError(t, err) + require.True(t, response.Data.ExecutionPayloadIncluded) + require.Nil(t, response.Data.Gloas) + require.NotNil(t, response.Data.GloasContents) + requireJSONEquals(t, data, response.Data.GloasContents) + + envelope, err := response.Data.ExecutionPayloadEnvelope() + require.NoError(t, err) + require.Equal(t, gloas.BuilderIndex(12), envelope.BuilderIndex) + + proofs, err := response.Data.KZGProofs() + require.NoError(t, err) + require.Len(t, proofs, 1) + }) + + // SSZ carries no field names and no response wrapper, so the header is the + // only place the payload-inclusion flag can be read from. + t.Run("SSZPayloadExcluded", func(t *testing.T) { + block := validEPBSBeaconBlock() + sszBody, err := block.MarshalSSZ() + require.NoError(t, err) + + response, err := s.epbsProposalFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeSSZ, + consensusVersion: spec.DataVersionGloas, + body: sszBody, + headers: map[string]string{"Eth-Execution-Payload-Included": "false"}, + }) + require.NoError(t, err) + require.False(t, response.Data.ExecutionPayloadIncluded) + require.Nil(t, response.Data.GloasContents) + require.NotNil(t, response.Data.Gloas) + + want, err := block.HashTreeRoot() + require.NoError(t, err) + got, err := response.Data.Gloas.HashTreeRoot() + require.NoError(t, err) + require.Equal(t, want, got) + }) + + // A gloas block carries preset-derived fixed-size fields — a sync committee + // bitvector is 512 bits at the mainnet preset and 32 at minimal — and SSZ + // addresses variable-size fields by offsets stored after them, so every + // offset in the body shifts by 60 bytes between the two presets. The + // generated codec has the mainnet preset baked in and cannot read a body + // encoded at another one, which is the whole reason this arm decodes through + // the request-scoped dynamic codec instead. + t.Run("SSZAtTheNodesPreset", func(t *testing.T) { + custom := customSpecService(ctx, t) + + ds, err := custom.dynSSZForRequest(ctx) + require.NoError(t, err) + + // The sync committee bitvector is the preset-derived field that shifts + // the offsets, so the fixture has to be sized from the node's spec + // rather than from the compiled-in preset to encode at all. + specResponse, err := custom.Spec(ctx, &api.SpecOpts{}) + require.NoError(t, err) + syncCommitteeSize, isUint64 := specResponse.Data["SYNC_COMMITTEE_SIZE"].(uint64) + require.True(t, isUint64, "SYNC_COMMITTEE_SIZE missing from spec") + + block := validEPBSBeaconBlock() + block.Body.SyncAggregate.SyncCommitteeBits = make(bitfield.Bitvector512, syncCommitteeSize/8) + + sszBody, err := ds.MarshalSSZ(block) + require.NoError(t, err) + + response, err := custom.epbsProposalFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeSSZ, + consensusVersion: spec.DataVersionGloas, + body: sszBody, + headers: map[string]string{"Eth-Execution-Payload-Included": "false"}, + }) + require.NoError(t, err) + require.NotNil(t, response.Data.Gloas) + require.Equal(t, phase0.Slot(60300), response.Data.Gloas.Slot) + }) + + // Both value components arrive in headers. Value() sums them, so a caller + // choosing between proposals is comparing consensus rewards plus execution + // payload value. + t.Run("ValuesFromHeaders", func(t *testing.T) { + body, _ := epbsProposalJSONBody(t, false, validEPBSBeaconBlock()) + + response, err := s.epbsProposalFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionGloas, + body: body, + headers: map[string]string{ + "Eth-Consensus-Block-Value": "3476149000000000", + "Eth-Execution-Payload-Value": "999", + }, + }) + require.NoError(t, err) + require.Equal(t, big.NewInt(3476149000000000), response.Data.ConsensusValue) + require.Equal(t, big.NewInt(999), response.Data.ExecutionValue) + require.Equal(t, big.NewInt(3476149000000999), response.Data.Value()) + }) + + // A reported zero must survive parsing. It differs from an omitted header, + // which leaves the value unknown. The consensus header is omitted here, so + // it must stay unknown alongside the known zero: asserting only the total + // would not catch a regression that defaulted an absent component to zero or + // wrote the execution header to the wrong field. Headers are the sole + // source, so the body's own value fields are metadata and do not contribute. + t.Run("ZeroExecutionValueFromHeaders", func(t *testing.T) { + body, _ := epbsProposalJSONBody(t, false, validEPBSBeaconBlock()) + + response, err := s.epbsProposalFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionGloas, + body: body, + headers: map[string]string{ + "Eth-Execution-Payload-Value": "0", + }, + }) + require.NoError(t, err) + require.Nil(t, response.Data.ConsensusValue) + require.Equal(t, big.NewInt(0), response.Data.ExecutionValue) + require.Equal(t, big.NewInt(0), response.Data.Value()) + }) + + // A proposal without a signed execution payload bid has no builder index, + // so it is not valid for downstream selection. + t.Run("MissingExecutionPayloadBid", func(t *testing.T) { + block := validEPBSBeaconBlock() + block.Body.SignedExecutionPayloadBid = nil + body, _ := epbsProposalJSONBody(t, false, block) + + _, err := s.epbsProposalFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionGloas, + body: body, + headers: map[string]string{}, + }) + require.ErrorIs(t, err, client.ErrInconsistentResult) + require.ErrorContains(t, err, "no execution payload bid in epbs proposal response") + }) + + // The JSON decoder fills in only the keys it finds, so a body carrying no + // data key at all, or an explicit null, leaves the seeded nil pointer + // untouched. That has to be an error rather than a success wrapping + // nothing: a caller handed a proposal whose arms are both nil gets errors + // only later, from whichever accessor it happens to reach first. + t.Run("NoDatum", func(t *testing.T) { + tests := []struct { + name string + body string + }{ + {name: "MissingDataKeyExcluded", body: `{"version":"gloas","execution_payload_included":false}`}, + {name: "NullDataExcluded", body: `{"version":"gloas","execution_payload_included":false,"data":null}`}, + {name: "MissingDataKeyIncluded", body: `{"version":"gloas","execution_payload_included":true}`}, + {name: "NullDataIncluded", body: `{"version":"gloas","execution_payload_included":true,"data":null}`}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := s.epbsProposalFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionGloas, + body: []byte(test.body), + headers: map[string]string{}, + }) + require.ErrorContains(t, err, "no gloas epbs proposal in response") + }) + } + }) + + // The endpoint is gloas-onwards, so a response claiming any earlier fork is + // a node answering a request it should have rejected. + t.Run("PreGloasVersion", func(t *testing.T) { + body, _ := epbsProposalJSONBody(t, false, validEPBSBeaconBlock()) + + _, err := s.epbsProposalFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionFulu, + body: body, + headers: map[string]string{}, + }) + require.ErrorContains(t, err, "epbs proposal not available for version fulu") + }) + + t.Run("UnknownContentType", func(t *testing.T) { + body, _ := epbsProposalJSONBody(t, false, validEPBSBeaconBlock()) + + _, err := s.epbsProposalFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeUnknown, + consensusVersion: spec.DataVersionGloas, + body: body, + headers: map[string]string{}, + }) + require.ErrorContains(t, err, "unhandled content type") + }) + + // The flag has no default in the spec, and the two containers are not + // interchangeable, so a body that omits it cannot be decoded at all. + t.Run("JSONWithoutInclusionFlag", func(t *testing.T) { + _, err := s.epbsProposalFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionGloas, + body: []byte(`{"version":"gloas","data":{}}`), + headers: map[string]string{}, + }) + require.ErrorContains(t, err, "no execution_payload_included in epbs proposal response") + }) + + // On the SSZ path the header is the only source for the flag. Absent, the + // body cannot be attributed to either container. + t.Run("SSZWithoutInclusionHeader", func(t *testing.T) { + _, err := s.epbsProposalFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeSSZ, + consensusVersion: spec.DataVersionGloas, + body: []byte{}, + headers: map[string]string{}, + }) + require.ErrorContains(t, err, "no Eth-Execution-Payload-Included header in epbs proposal response") + }) + + // A malformed flag must be rejected rather than read as false. Treating an + // unparseable value as false would decode block contents as a bare block and + // silently drop the envelope, blobs and proofs. + t.Run("SSZWithMalformedInclusionHeader", func(t *testing.T) { + _, err := s.epbsProposalFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeSSZ, + consensusVersion: spec.DataVersionGloas, + body: []byte{}, + headers: map[string]string{"Eth-Execution-Payload-Included": "yes"}, + }) + require.ErrorContains(t, err, "Eth-Execution-Payload-Included is not a valid boolean") + + // The offending value is named but not quoted back. Its length is the + // node's to choose, so an error carrying it puts however much the node + // sent into a log line. + _, err = s.epbsProposalFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeSSZ, + consensusVersion: spec.DataVersionGloas, + body: []byte{}, + headers: map[string]string{"Eth-Execution-Payload-Included": strings.Repeat("y", 4096)}, + }) + require.Less(t, len(err.Error()), 256, "the error must not echo an unbounded header value") + }) + + // A value header's length is chosen by the node, and big.Int.SetString is + // quadratic in it. The parse also happens after the request's deadline has + // been released, so nothing on the caller's side interrupts it: a + // multi-megabyte value burns minutes of CPU on the one path that has a slot + // to meet. Rejected on length before parsing. + t.Run("OverlongValueHeader", func(t *testing.T) { + body, _ := epbsProposalJSONBody(t, false, validEPBSBeaconBlock()) + + _, err := s.epbsProposalFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionGloas, + body: body, + headers: map[string]string{ + "Eth-Consensus-Block-Value": strings.Repeat("9", maxProposalValueDigits+1), + }, + }) + require.ErrorContains(t, err, "more than the 40 a value can need") + + // A value at the limit is still accepted, so the bound rejects only what + // no real node sends. + response, err := s.epbsProposalFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionGloas, + body: body, + headers: map[string]string{ + "Eth-Consensus-Block-Value": strings.Repeat("9", maxProposalValueDigits), + }, + }) + require.NoError(t, err) + require.Equal(t, strings.Repeat("9", maxProposalValueDigits), response.Data.ConsensusValue.String()) + }) + + // A negative value is not a value. big.Int.SetString accepts a leading + // minus, and Value() would then subtract it from the total, making a proposal + // look cheaper than a rival's. + t.Run("NegativeValueHeader", func(t *testing.T) { + body, _ := epbsProposalJSONBody(t, false, validEPBSBeaconBlock()) + + _, err := s.epbsProposalFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionGloas, + body: body, + headers: map[string]string{"Eth-Execution-Payload-Value": "-1"}, + }) + require.ErrorContains(t, err, "is negative") + }) + + t.Run("MalformedValueHeader", func(t *testing.T) { + body, _ := epbsProposalJSONBody(t, false, validEPBSBeaconBlock()) + + _, err := s.epbsProposalFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionGloas, + body: body, + headers: map[string]string{"Eth-Consensus-Block-Value": "lots"}, + }) + require.ErrorContains(t, err, "not a valid integer") + }) + + // An omitted execution value is unknown, not zero. A caller must not use + // it as evidence that the execution bid has no value. + t.Run("AbsentExecutionValueIsUnknown", func(t *testing.T) { + body, _ := epbsProposalJSONBody(t, false, validEPBSBeaconBlock()) + + response, err := s.epbsProposalFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionGloas, + body: body, + headers: map[string]string{}, + }) + require.NoError(t, err) + require.Nil(t, response.Data.ConsensusValue) + require.Nil(t, response.Data.ExecutionValue) + require.Nil(t, response.Data.Value()) + }) + + t.Run("CorruptJSON", func(t *testing.T) { + body, _ := epbsProposalJSONBody(t, false, validEPBSBeaconBlock()) + + _, err := s.epbsProposalFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionGloas, + body: body[:len(body)-1], + headers: map[string]string{}, + }) + require.ErrorContains(t, err, "failed to parse epbs proposal response") + }) + + t.Run("CorruptSSZ", func(t *testing.T) { + block := validEPBSBeaconBlock() + sszBody, err := block.MarshalSSZ() + require.NoError(t, err) + + _, err = s.epbsProposalFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeSSZ, + consensusVersion: spec.DataVersionGloas, + body: sszBody[:len(sszBody)-1], + headers: map[string]string{"Eth-Execution-Payload-Included": "false"}, + }) + require.ErrorContains(t, err, "failed to decode gloas SSZ epbs proposal") + }) +} + +// TestAssertEPBSProposalMatchesRequest covers the checks made on what the node +// returned. The faults cannot be provoked against a live node — one that +// honours the request cannot be made to answer with the wrong slot, mode or +// RANDAO reveal — so this is the only place they are exercised. +// BuilderBidExcludesRequestedPayload is the other side of that: a divergence +// the guard has to let through rather than reject. +func TestAssertEPBSProposalMatchesRequest(t *testing.T) { + s := &Service{} + + includePayload := true + reveal := phase0.BLSSignature{0x0a, 0x0b, 0x0c} + + // Matches validEPBSBeaconBlock, so the request and the response agree. + matchingOpts := func() *api.EPBSProposalOpts { + want := includePayload + + return &api.EPBSProposalOpts{ + Slot: 60300, + RandaoReveal: reveal, + IncludePayload: &want, + } + } + + matchingProposal := func() *api.VersionedEPBSProposal { + return &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + GloasContents: validEPBSBlockContents(), + } + } + + t.Run("Matching", func(t *testing.T) { + require.NoError(t, s.assertEPBSProposalMatchesRequest(matchingProposal(), matchingOpts())) + }) + + t.Run("WrongSlot", func(t *testing.T) { + opts := matchingOpts() + opts.Slot = 60301 + + err := s.assertEPBSProposalMatchesRequest(matchingProposal(), opts) + require.ErrorIs(t, err, client.ErrInconsistentResult) + require.ErrorContains(t, err, "for slot 60300; expected 60301") + }) + + // The mode decides where the block can be published, so a node that includes + // a payload nobody asked for has changed that without saying so. This is the + // only direction that is a fault; the other one is what an external builder's + // bid legitimately looks like. + t.Run("WrongPayloadInclusion", func(t *testing.T) { + exclude := false + opts := matchingOpts() + opts.IncludePayload = &exclude + + err := s.assertEPBSProposalMatchesRequest(matchingProposal(), opts) + require.ErrorIs(t, err, client.ErrInconsistentResult) + require.ErrorContains(t, err, "execution payload included true; expected false") + }) + + // An external builder's bid comes back as the beacon block alone whatever + // include_payload asked for, because the node does not hold the builder's + // payload and so has none to send. Refusing it would leave the caller with + // nothing to sign on every slot a builder wins. The proposal is decoded from + // a response body rather than built as a struct literal so that what is proved + // is a real bid surviving the whole path, not just the check at the end of it + // agreeing with a hand-made value. + t.Run("BuilderBidExcludesRequestedPayload", func(t *testing.T) { + ctx := context.Background() + body, _ := epbsProposalJSONBody(t, false, validEPBSBeaconBlock()) + + response, err := s.epbsProposalFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionGloas, + body: body, + headers: map[string]string{}, + }) + require.NoError(t, err) + require.False(t, response.Data.ExecutionPayloadIncluded) + + require.NoError(t, s.assertEPBSProposalMatchesRequest(response.Data, matchingOpts())) + }) + + t.Run("WrongRandaoReveal", func(t *testing.T) { + opts := matchingOpts() + opts.RandaoReveal = phase0.BLSSignature{0xff} + + err := s.assertEPBSProposalMatchesRequest(matchingProposal(), opts) + require.ErrorIs(t, err, client.ErrInconsistentResult) + require.ErrorContains(t, err, "RANDAO reveal") + }) + + // Behind DVT middleware the reveal is the middleware's to decide, so a + // mismatch there is expected rather than a fault. The slot and the mode are + // still checked. + t.Run("RandaoRevealIgnoredBehindDVT", func(t *testing.T) { + dvt := &Service{connectedToDVTMiddleware: true} + + opts := matchingOpts() + opts.RandaoReveal = phase0.BLSSignature{0xff} + + require.NoError(t, dvt.assertEPBSProposalMatchesRequest(matchingProposal(), opts)) + + opts.Slot = 60301 + require.ErrorIs(t, + dvt.assertEPBSProposalMatchesRequest(matchingProposal(), opts), + client.ErrInconsistentResult, + ) + }) +} + +// customSpecService returns a service configured the way a caller on a +// non-mainnet preset must configure one: with custom spec support, so SSZ is +// decoded against the spec the node reports rather than the compiled-in preset. +func customSpecService(ctx context.Context, t *testing.T) *Service { + t.Helper() + + return newTestService(ctx, t, true) +} diff --git a/http/epbsproposal_response_internal_test.go b/http/epbsproposal_response_internal_test.go new file mode 100644 index 00000000..ad2eb9ea --- /dev/null +++ b/http/epbsproposal_response_internal_test.go @@ -0,0 +1,259 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "context" + "encoding/json" + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/mock" + "github.com/attestantio/go-eth2-client/spec" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/stretchr/testify/require" +) + +func TestDecodeEPBSProposalJSONAcceptsBareBuilderWin(t *testing.T) { + service, err := mock.New(context.Background()) + require.NoError(t, err) + includePayload := false + response, err := service.EPBSProposal(context.Background(), &api.EPBSProposalOpts{ + Slot: 123, + IncludePayload: &includePayload, + }) + require.NoError(t, err) + block := response.Data.Gloas + data, err := json.Marshal(block) + require.NoError(t, err) + + body := append([]byte(`{"execution_payload_included":false,"data":`), data...) + body = append(body, '}') + + proposal := &api.VersionedEPBSProposal{Version: spec.DataVersionGloas} + metadata, err := decodeEPBSProposalJSON(body, proposal) + require.NoError(t, err) + require.Equal(t, false, metadata["execution_payload_included"]) + require.False(t, proposal.ExecutionPayloadIncluded) + require.Equal(t, block, proposal.Gloas) + require.Nil(t, proposal.GloasContents) +} + +func TestDecodeEPBSProposalJSONRejectsNullPayloadInclusion(t *testing.T) { + service, err := mock.New(context.Background()) + require.NoError(t, err) + includePayload := false + response, err := service.EPBSProposal(context.Background(), &api.EPBSProposalOpts{ + Slot: 123, + IncludePayload: &includePayload, + }) + require.NoError(t, err) + data, err := json.Marshal(response.Data.Gloas) + require.NoError(t, err) + body := append([]byte(`{"execution_payload_included":null,"data":`), data...) + body = append(body, '}') + + proposal := &api.VersionedEPBSProposal{Version: spec.DataVersionGloas} + _, err = decodeEPBSProposalJSON(body, proposal) + require.EqualError(t, err, "execution_payload_included cannot be null") +} + +func TestDecodeEPBSProposalJSONRejectsMissingOrMalformedMetadata(t *testing.T) { + tests := []struct { + name string + body string + err string + }{ + { + name: "MissingPayloadInclusion", + body: `{"data":{}}`, + err: "no execution_payload_included in epbs proposal response", + }, + { + name: "MalformedPayloadInclusion", + body: `{"execution_payload_included":"false","data":{}}`, + err: "failed to unmarshal execution_payload_included\njson: cannot unmarshal string into Go value of type bool", + }, + { + name: "MissingData", + body: `{"execution_payload_included":false}`, + err: "no gloas epbs proposal in response", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + proposal := &api.VersionedEPBSProposal{Version: spec.DataVersionGloas} + _, err := decodeEPBSProposalJSON([]byte(test.body), proposal) + require.EqualError(t, err, test.err) + }) + } +} + +func TestAssertIncludedEPBSProposalEnvelopeMatchesBlockRejectsMismatchedBuilderIndex(t *testing.T) { + mockService, err := mock.New(context.Background()) + require.NoError(t, err) + + includePayload := true + response, err := mockService.EPBSProposal(context.Background(), &api.EPBSProposalOpts{ + Slot: 123, + IncludePayload: &includePayload, + }) + require.NoError(t, err) + blockRoot, err := response.Data.Root() + require.NoError(t, err) + response.Data.GloasContents.ExecutionPayloadEnvelope.BeaconBlockRoot = blockRoot + response.Data.GloasContents.Block.Body.SignedExecutionPayloadBid.Message.BuilderIndex = 1 + response.Data.GloasContents.ExecutionPayloadEnvelope.BuilderIndex = 2 + + err = assertIncludedEPBSProposalEnvelopeMatchesBlock(response.Data, dynssz.GetGlobalDynSsz()) + require.ErrorContains(t, err, "execution payload envelope builder index does not match bid") + require.ErrorIs(t, err, client.ErrInconsistentResult) +} + +func TestAssertIncludedEPBSProposalEnvelopeMatchesBlockRejectsMismatchedBlockHash(t *testing.T) { + mockService, err := mock.New(context.Background()) + require.NoError(t, err) + + includePayload := true + response, err := mockService.EPBSProposal(context.Background(), &api.EPBSProposalOpts{ + Slot: 123, + IncludePayload: &includePayload, + }) + require.NoError(t, err) + blockRoot, err := response.Data.Root() + require.NoError(t, err) + response.Data.GloasContents.ExecutionPayloadEnvelope.BeaconBlockRoot = blockRoot + response.Data.GloasContents.Block.Body.SignedExecutionPayloadBid.Message.BlockHash[0] = 1 + response.Data.GloasContents.ExecutionPayloadEnvelope.Payload.BlockHash[0] = 2 + + err = assertIncludedEPBSProposalEnvelopeMatchesBlock(response.Data, dynssz.GetGlobalDynSsz()) + require.ErrorContains(t, err, "execution payload block hash does not match bid") + require.ErrorIs(t, err, client.ErrInconsistentResult) +} + +func TestAssertIncludedEPBSProposalEnvelopeMatchesBlockRejectsMismatchedParentRoot(t *testing.T) { + mockService, err := mock.New(context.Background()) + require.NoError(t, err) + + includePayload := true + response, err := mockService.EPBSProposal(context.Background(), &api.EPBSProposalOpts{ + Slot: 123, + IncludePayload: &includePayload, + }) + require.NoError(t, err) + blockRoot, err := response.Data.Root() + require.NoError(t, err) + response.Data.GloasContents.ExecutionPayloadEnvelope.BeaconBlockRoot = blockRoot + response.Data.GloasContents.ExecutionPayloadEnvelope.ParentBeaconBlockRoot[0] = 1 + + err = assertIncludedEPBSProposalEnvelopeMatchesBlock(response.Data, dynssz.GetGlobalDynSsz()) + require.ErrorContains(t, err, "execution payload envelope parent root does not match bid") + require.ErrorIs(t, err, client.ErrInconsistentResult) +} + +func TestAssertIncludedEPBSProposalEnvelopeMatchesBlockRejectsMismatchedExecutionRequests(t *testing.T) { + mockService, err := mock.New(context.Background()) + require.NoError(t, err) + + includePayload := true + response, err := mockService.EPBSProposal(context.Background(), &api.EPBSProposalOpts{ + Slot: 123, + IncludePayload: &includePayload, + }) + require.NoError(t, err) + blockRoot, err := response.Data.Root() + require.NoError(t, err) + response.Data.GloasContents.ExecutionPayloadEnvelope.BeaconBlockRoot = blockRoot + response.Data.GloasContents.Block.Body.SignedExecutionPayloadBid.Message.ExecutionRequestsRoot[0] = 1 + + err = assertIncludedEPBSProposalEnvelopeMatchesBlock(response.Data, dynssz.GetGlobalDynSsz()) + require.ErrorContains(t, err, "execution payload envelope requests do not match bid") + require.ErrorIs(t, err, client.ErrInconsistentResult) +} + +func TestAssertEPBSProposalMatchesRequestAllowsBareBuilderWin(t *testing.T) { + mockService, err := mock.New(context.Background()) + require.NoError(t, err) + + payloadIncluded := false + response, err := mockService.EPBSProposal(context.Background(), &api.EPBSProposalOpts{ + Slot: 123, + IncludePayload: &payloadIncluded, + }) + require.NoError(t, err) + + requestedIncluded := true + require.NoError(t, (&Service{}).assertEPBSProposalMatchesRequest(response.Data, &api.EPBSProposalOpts{ + Slot: 123, + IncludePayload: &requestedIncluded, + })) +} + +func TestAssertEPBSProposalMatchesRequestRejectsUnexpectedContents(t *testing.T) { + mockService, err := mock.New(context.Background()) + require.NoError(t, err) + + payloadIncluded := true + response, err := mockService.EPBSProposal(context.Background(), &api.EPBSProposalOpts{ + Slot: 123, + IncludePayload: &payloadIncluded, + }) + require.NoError(t, err) + + requestedIncluded := false + err = (&Service{}).assertEPBSProposalMatchesRequest(response.Data, &api.EPBSProposalOpts{ + Slot: 123, + IncludePayload: &requestedIncluded, + }) + require.EqualError(t, err, "epbs beacon block proposal has execution payload included true; expected false\ninconsistent result") +} + +func TestEPBSPayloadIncludedFromHeaders(t *testing.T) { + tests := []struct { + name string + headers map[string]string + included bool + err string + }{ + { + name: "Included", + headers: map[string]string{"Eth-Execution-Payload-Included": "true"}, + included: true, + }, + { + name: "Missing", + headers: map[string]string{}, + err: "no Eth-Execution-Payload-Included header in epbs proposal response", + }, + { + name: "Malformed", + headers: map[string]string{"Eth-Execution-Payload-Included": "sometimes"}, + err: "proposal header Eth-Execution-Payload-Included is not a valid boolean", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + included, err := epbsPayloadIncludedFromHeaders(test.headers) + if test.err == "" { + require.NoError(t, err) + require.Equal(t, test.included, included) + } else { + require.EqualError(t, err, test.err) + } + }) + } +} diff --git a/http/epbsproposal_selfbuildindex_internal_test.go b/http/epbsproposal_selfbuildindex_internal_test.go new file mode 100644 index 00000000..a81d7f3b --- /dev/null +++ b/http/epbsproposal_selfbuildindex_internal_test.go @@ -0,0 +1,79 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "context" + "testing" + + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/stretchr/testify/require" +) + +// TestBuilderIndexSelfBuild covers how the self-build sentinel is resolved. It +// is a spec value rather than a fixed constant, but it arrived with Gloas, so a +// node on an earlier release serves a spec without it; that has to fall back +// rather than fail the proposal it is being read for. +func TestBuilderIndexSelfBuild(t *testing.T) { + tests := []struct { + name string + customSpecSupport bool + spec map[string]any + expected gloas.BuilderIndex + err error + }{ + { + name: "StaticWithoutCustomSpecSupport", + spec: map[string]any{"BUILDER_INDEX_SELF_BUILD": uint64(5)}, + expected: staticBuilderIndexSelfBuild, + }, + { + name: "SpecValue", + customSpecSupport: true, + spec: map[string]any{"BUILDER_INDEX_SELF_BUILD": uint64(5)}, + expected: gloas.BuilderIndex(5), + }, + { + name: "AbsentFallsBackToMainnet", + customSpecSupport: true, + spec: map[string]any{"SYNC_COMMITTEE_SIZE": uint64(32)}, + expected: staticBuilderIndexSelfBuild, + }, + { + name: "WrongType", + customSpecSupport: true, + spec: map[string]any{"BUILDER_INDEX_SELF_BUILD": "self"}, + err: ErrIncorrectType, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + s := &Service{ + customSpecSupport: test.customSpecSupport, + connectionActive: true, + spec: test.spec, + } + + index, err := s.builderIndexSelfBuild(context.Background()) + if test.err != nil { + require.ErrorIs(t, err, test.err) + + return + } + require.NoError(t, err) + require.Equal(t, test.expected, index) + }) + } +} diff --git a/http/epbsproposal_test.go b/http/epbsproposal_test.go new file mode 100644 index 00000000..1e9870ce --- /dev/null +++ b/http/epbsproposal_test.go @@ -0,0 +1,224 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http_test + +import ( + "context" + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/http" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// TestEPBSProposalOptsValidation covers the options the endpoint refuses before +// it reaches the network. The payload-inclusion case is the one that matters +// most: the spec marks the parameter required with no default, and the two modes +// carry different operational constraints, so an unset value has to be rejected +// rather than resolved on the caller's behalf. +func TestEPBSProposalOptsValidation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + service := testService(ctx, t).(client.Service) + provider := service.(client.EPBSProposalProvider) + + includePayload := true + + t.Run("NilOpts", func(t *testing.T) { + _, err := provider.EPBSProposal(ctx, nil) + require.ErrorIs(t, err, client.ErrNoOptions) + }) + + t.Run("NoSlot", func(t *testing.T) { + _, err := provider.EPBSProposal(ctx, &api.EPBSProposalOpts{ + IncludePayload: &includePayload, + }) + require.ErrorIs(t, err, client.ErrInvalidOptions) + require.ErrorContains(t, err, "no slot specified") + }) + + // Leaving IncludePayload unset must not silently select either mode. False + // is the constraining one — the producing node caches the envelope and must + // also be the publisher — so a Go bool's zero value would quietly commit the + // caller to stateful operation. + t.Run("NoIncludePayload", func(t *testing.T) { + _, err := provider.EPBSProposal(ctx, &api.EPBSProposalOpts{Slot: 1}) + require.ErrorIs(t, err, client.ErrInvalidOptions) + require.ErrorContains(t, err, "no payload inclusion specified") + }) + + t.Run("SkipRandaoVerificationWithoutInfinity", func(t *testing.T) { + _, err := provider.EPBSProposal(ctx, &api.EPBSProposalOpts{ + Slot: 1, + IncludePayload: &includePayload, + SkipRandaoVerification: true, + RandaoReveal: phase0.BLSSignature{0x01}, + }) + require.ErrorIs(t, err, client.ErrInvalidOptions) + require.ErrorContains(t, err, "randao reveal must be point at infinity") + }) +} + +// TestEPBSProposal exercises the endpoint against a live node, across both +// payload-inclusion modes and both encodings. +// +// The RANDAO reveal is the point at infinity with skip_randao_verification set, +// which is what lets a test produce a block without holding a validator key: the +// node then returns the reveal it was given, so the endpoint's own consistency +// check still has something to verify. +func TestEPBSProposal(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + service := testService(ctx, t).(client.Service) + + // Every subtest asks the node to produce a gloas block, so the gate belongs + // on the whole test rather than on each of them. + requireOnGloas(ctx, t, service) + + // Custom spec support is on for both services, not just the JSON one: the + // validation devnet runs the minimal preset, and a gloas block is undecodable + // against the compiled-in one. The shared service from testService does not + // have it, so it cannot be used for either encoding here. + sszService, err := newTestService(ctx, true) + require.NoError(t, err) + + // A service that pins JSON, so the two encodings are both reached. + jsonService, err := newTestService(ctx, true, http.WithEnforceJSON(true)) + require.NoError(t, err) + + tests := []struct { + name string + service client.Service + included bool + builderRequested bool + }{ + {name: "SSZPayloadExcluded", service: sszService, included: false}, + {name: "SSZPayloadIncluded", service: sszService, included: true}, + {name: "JSONPayloadExcluded", service: jsonService, included: false}, + {name: "JSONPayloadIncluded", service: jsonService, included: true}, + // A populated builders list is a different body on the wire, in both + // encodings, and nothing an empty one sends reaches it. + {name: "SSZBuilderRequested", service: sszService, builderRequested: true}, + {name: "JSONBuilderRequested", service: jsonService, builderRequested: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + includePayload := test.included + infinity := infinitySignature() + + // The node produces a block for the slot it is about to propose, so + // the target is derived from its head. Re-read per subtest rather + // than once: each row produces a whole block against a live node, and + // on a short-slot devnet the head moves past a slot fixed up front, + // which the node then refuses to build for. + slot := headSlot(ctx, t, service) + 1 + builderConfig := localPreferredBuilderConfig() + if test.builderRequested { + builderConfig = builderRequestedConfig(slot) + } + + response, err := test.service.(client.EPBSProposalProvider).EPBSProposal(ctx, + &api.EPBSProposalOpts{ + Slot: slot, + RandaoReveal: infinity, + IncludePayload: &includePayload, + SkipRandaoVerification: true, + BuilderConfig: builderConfig, + }, + ) + require.NoError(t, err) + require.Equal(t, spec.DataVersionGloas, response.Data.Version) + require.Equal(t, test.included, response.Data.ExecutionPayloadIncluded) + require.False(t, response.Data.IsEmpty()) + + proposalSlot, err := response.Data.Slot() + require.NoError(t, err) + require.Equal(t, slot, proposalSlot) + + reveal, err := response.Data.RandaoReveal() + require.NoError(t, err) + require.Equal(t, infinity, reveal) + + // The envelope, blobs and proofs exist only when the payload + // travelled with the block. Asking for them in the other mode is a + // caller error, not an empty result: the envelope has to be fetched + // from the node that produced the block. + envelope, err := response.Data.ExecutionPayloadEnvelope() + if !test.included { + require.ErrorContains(t, err, "the execution payload was not included") + + return + } + + require.NoError(t, err) + require.NotNil(t, envelope.Payload) + require.NotNil(t, envelope.Payload.BaseFeePerGas) + }) + } +} + +// localPreferredBuilderConfig asks for a build with no direct builder bids +// solicited. The contract documents the empty list as exactly that -- "Empty +// means request none, so only p2p bids are considered" -- and a zero boost +// factor expresses no preference among those. +func localPreferredBuilderConfig() *gloas.BuilderConfig { + return &gloas.BuilderConfig{ + Builders: []*gloas.BuilderEntry{}, + } +} + +// builderRequestedConfig solicits a direct bid from one builder. +// +// The bid cannot be won from a test: auth.message carries a signature the +// slot's proposer makes, whose key a test does not hold, so the builder +// declines and the node falls back to a p2p or local build. Everything up to +// that point is still exercised, and none of it is reachable with an empty +// list: a populated BuilderEntry through both encodings, and the node parsing +// the entry and routing on its URL. +// +// The URL is a refused loopback port rather than a devnet builder so the node's +// dial fails at once instead of holding the request open for a timeout. +// devnet-8 publishes no builder API to point at in any case -- its buildoor +// pairs expose only a beacon and an EL RPC endpoint. +// +// The authorization slot has to be the request's own; validateBuilderConfig +// refuses a mismatch before the request is sent. +func builderRequestedConfig(slot phase0.Slot) *gloas.BuilderConfig { + return &gloas.BuilderConfig{ + BuilderBoostFactor: 100, + Builders: []*gloas.BuilderEntry{{ + URL: []byte("http://127.0.0.1:1"), + Auth: &gloas.SignedBuilderRequestAuth{ + Message: &gloas.BuilderRequestAuth{Data: []byte{0x01}, Slot: slot}, + }, + BuilderPubkeys: []phase0.BLSPubKey{}, + }}, + } +} + +// infinitySignature returns the BLS point at infinity, which is what a caller +// passes as the RANDAO reveal when asking the node to skip verifying it. +func infinitySignature() phase0.BLSSignature { + var signature phase0.BLSSignature + signature[0] = 0xc0 + + return signature +} diff --git a/http/epbsproposal_validation_internal_test.go b/http/epbsproposal_validation_internal_test.go new file mode 100644 index 00000000..d01d86a8 --- /dev/null +++ b/http/epbsproposal_validation_internal_test.go @@ -0,0 +1,134 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "testing" + + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// requestSlot is the slot the configs below are validated against. It is +// non-zero so that the per-entry slot check is genuinely exercised: a zero +// authorization slot matching a zero request slot would assert nothing. +const requestSlot = phase0.Slot(123) + +func validBuilderConfigForValidation() *gloas.BuilderConfig { + return &gloas.BuilderConfig{ + Builders: []*gloas.BuilderEntry{{ + URL: []byte("https://builder.example"), + Auth: &gloas.SignedBuilderRequestAuth{ + Message: &gloas.BuilderRequestAuth{Data: []byte{0x01}, Slot: requestSlot}, + Signature: phase0.BLSSignature{0x02}, + }, + }}, + } +} + +func TestValidateBuilderConfig(t *testing.T) { + tooManyBuilders := make([]*gloas.BuilderEntry, 65) + tooManyPubkeys := validBuilderConfigForValidation() + tooManyPubkeys.Builders[0].BuilderPubkeys = make([]phase0.BLSPubKey, 65) + + tests := []struct { + name string + config *gloas.BuilderConfig + err string + }{ + { + name: "Valid", + config: validBuilderConfigForValidation(), + }, + { + name: "MissingConfig", + err: "no builder config supplied", + }, + { + // An empty builders list solicits no builder bids and leaves only + // p2p ones, which the endpoint documents as a valid request. + name: "BuildersOmitted", + config: &gloas.BuilderConfig{}, + }, + { + name: "TooManyBuilders", + config: &gloas.BuilderConfig{Builders: tooManyBuilders}, + err: "too many builders supplied", + }, + { + name: "MissingBuilder", + config: &gloas.BuilderConfig{Builders: []*gloas.BuilderEntry{nil}}, + err: "builder 0 missing", + }, + { + name: "InvalidURL", + config: func() *gloas.BuilderConfig { + config := validBuilderConfigForValidation() + config.Builders[0].URL = []byte{0xff} + return config + }(), + err: "builder 0 has invalid URL", + }, + { + name: "MissingAuthorization", + config: func() *gloas.BuilderConfig { + config := validBuilderConfigForValidation() + config.Builders[0].Auth = nil + return config + }(), + err: "builder 0 has invalid authorization", + }, + { + name: "EmptyAuthorizationData", + config: func() *gloas.BuilderConfig { + config := validBuilderConfigForValidation() + config.Builders[0].Auth.Message.Data = []byte{} + return config + }(), + err: "builder 0 has invalid authorization", + }, + { + // The authorization slot sits inside the signed message and is + // verified by the builder, not the beacon node. A stale one -- + // an auth object cached across slots -- costs that builder's bid + // every slot with no error anywhere in the stack, so the request + // is refused here instead. + name: "AuthorizationForWrongSlot", + config: func() *gloas.BuilderConfig { + config := validBuilderConfigForValidation() + config.Builders[0].Auth.Message.Slot = requestSlot - 1 + + return config + }(), + err: "builder 0 has authorization for slot 122, not 123", + }, + { + name: "TooManyPubkeys", + config: tooManyPubkeys, + err: "builder 0 has too many public keys", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateBuilderConfig(test.config, requestSlot) + if test.err == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, test.err) + } + }) + } +} diff --git a/http/epbsproposal_value_internal_test.go b/http/epbsproposal_value_internal_test.go new file mode 100644 index 00000000..6ac28760 --- /dev/null +++ b/http/epbsproposal_value_internal_test.go @@ -0,0 +1,191 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "math/big" + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +func TestValidateEPBSProposalExecutionValueRejectsMismatchedP2PHeader(t *testing.T) { + proposal := epbsProposalWithBid(1, 10, 0, 11_000_000_000) + + err := validateEPBSProposalExecutionValue(proposal, &gloas.BuilderConfig{MinBid: 10}, "", staticBuilderIndexSelfBuild) + require.Error(t, err) + require.ErrorIs(t, err, client.ErrInconsistentResult) +} + +func TestValidateEPBSProposalExecutionValueRejectsP2PBelowMinimum(t *testing.T) { + proposal := epbsProposalWithBid(1, 9, 0, 9_000_000_000) + + err := validateEPBSProposalExecutionValue(proposal, &gloas.BuilderConfig{MinBid: 10}, "", staticBuilderIndexSelfBuild) + require.ErrorIs(t, err, client.ErrInconsistentResult) +} + +func TestValidateEPBSProposalExecutionValueRejectsMismatchedDirectHeader(t *testing.T) { + proposal := epbsProposalWithBid(1, 10, 5, 15_000_000_000) + config := &gloas.BuilderConfig{Builders: []*gloas.BuilderEntry{{ + URL: []byte("https://builder.example"), + MaxExecutionPayment: 2, + MinBid: 12, + }}} + + err := validateEPBSProposalExecutionValue(proposal, config, "https://builder.example", staticBuilderIndexSelfBuild) + require.ErrorIs(t, err, client.ErrInconsistentResult) +} + +func TestValidateEPBSProposalExecutionValueUnknownAndSelfBuild(t *testing.T) { + duplicateURLs := &gloas.BuilderConfig{Builders: []*gloas.BuilderEntry{ + {URL: []byte("https://duplicate.example"), MaxExecutionPayment: 2}, + {URL: []byte("https://duplicate.example"), MaxExecutionPayment: 3}, + }} + direct := func(maxPayment, minBid phase0.Gwei) *gloas.BuilderConfig { + return &gloas.BuilderConfig{Builders: []*gloas.BuilderEntry{ + {URL: []byte("https://direct.example"), MaxExecutionPayment: maxPayment, MinBid: minBid}, + }} + } + tests := []struct { + name string + proposal *api.VersionedEPBSProposal + config *gloas.BuilderConfig + url string + err bool + unknown bool + }{ + { + name: "NoHeader", + proposal: epbsProposalWithoutValue(1, 10, 0), + config: &gloas.BuilderConfig{MinBid: 10}, + unknown: true, + }, + { + name: "NoHeaderBelowMinimum", + proposal: epbsProposalWithoutValue(1, 9, 0), + config: &gloas.BuilderConfig{MinBid: 10}, + err: true, + }, + { + name: "P2PPaymentUncheckable", + proposal: epbsProposalWithBid(1, 10, 3, 13_000_000_000), + config: &gloas.BuilderConfig{MinBid: 10}, + unknown: true, + }, + { + name: "DuplicateBuilderURL", + proposal: epbsProposalWithBid(1, 10, 0, 10_000_000_000), + config: duplicateURLs, + url: "https://duplicate.example", + unknown: true, + }, + { + name: "SelfBuild", + proposal: epbsProposalWithBid(staticBuilderIndexSelfBuild, 0, 7, 7_000_000_000), + config: duplicateURLs, + unknown: true, + }, + { + name: "SelfBuildNonZeroBid", + proposal: epbsProposalWithBid(staticBuilderIndexSelfBuild, 1, 0, 1_000_000_000), + config: duplicateURLs, + err: true, + }, + { + name: "DirectBelowMinimum", + proposal: epbsProposalWithBid(1, 10, 2, 12_000_000_000), + config: direct(2, 13), + url: "https://direct.example", + err: true, + }, + // beacon-APIs does not say whether the reported value includes the + // execution payment, so both ends of the bid-bound bracket are accepted + // and anything outside it is not. + { + name: "P2PValueMatches", + proposal: epbsProposalWithBid(1, 10, 0, 10_000_000_000), + config: &gloas.BuilderConfig{MinBid: 10}, + }, + { + name: "DirectValueWithoutPayment", + proposal: epbsProposalWithBid(1, 10, 3, 10_000_000_000), + config: direct(5, 10), + url: "https://direct.example", + }, + { + name: "DirectValueWithPayment", + proposal: epbsProposalWithBid(1, 10, 3, 13_000_000_000), + config: direct(5, 10), + url: "https://direct.example", + }, + { + name: "DirectValueWithUncappedPayment", + proposal: epbsProposalWithBid(1, 10, 3, 13_000_000_000), + config: direct(2, 10), + url: "https://direct.example", + err: true, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + value := test.proposal.ExecutionValue + err := validateEPBSProposalExecutionValue(test.proposal, test.config, test.url, staticBuilderIndexSelfBuild) + if test.err { + require.ErrorIs(t, err, client.ErrInconsistentResult) + + return + } + require.NoError(t, err) + if test.unknown { + require.Nil(t, test.proposal.ExecutionValue) + + return + } + require.Equal(t, value, test.proposal.ExecutionValue) + }) + } +} + +func epbsProposalWithBid(builderIndex gloas.BuilderIndex, + value phase0.Gwei, + executionPayment phase0.Gwei, + executionValue uint64, +) *api.VersionedEPBSProposal { + return &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionValue: new(big.Int).SetUint64(executionValue), + Gloas: &gloas.BeaconBlock{Body: &gloas.BeaconBlockBody{ + SignedExecutionPayloadBid: &gloas.SignedExecutionPayloadBid{Message: &gloas.ExecutionPayloadBid{ + BuilderIndex: builderIndex, + Value: value, + ExecutionPayment: executionPayment, + }}, + }}, + } +} + +func epbsProposalWithoutValue(builderIndex gloas.BuilderIndex, + value phase0.Gwei, + executionPayment phase0.Gwei, +) *api.VersionedEPBSProposal { + proposal := epbsProposalWithBid(builderIndex, value, executionPayment, 0) + proposal.ExecutionValue = nil + + return proposal +} diff --git a/http/events.go b/http/events.go index 6d8067f2..88231d89 100644 --- a/http/events.go +++ b/http/events.go @@ -1,4 +1,4 @@ -// Copyright © 2020 - 2025 Attestant Limited. +// Copyright © 2020 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -15,7 +15,6 @@ package http import ( "context" - "encoding/json" "errors" "fmt" "maps" @@ -27,12 +26,7 @@ import ( client "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/api" - apiv1 "github.com/attestantio/go-eth2-client/api/v1" - "github.com/attestantio/go-eth2-client/spec" - "github.com/attestantio/go-eth2-client/spec/altair" - "github.com/attestantio/go-eth2-client/spec/capella" - "github.com/attestantio/go-eth2-client/spec/electra" - "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/attestantio/go-eth2-client/internal/eventdispatch" "github.com/r3labs/sse/v2" "github.com/rs/zerolog" ) @@ -43,22 +37,14 @@ func (s *Service) Events(ctx context.Context, opts *api.EventsOpts) error { return err } - if opts == nil { - return client.ErrNoOptions - } - - if len(opts.Topics) == 0 { - return errors.Join(errors.New("no topics supplied"), client.ErrInvalidOptions) + if err := ValidateEventsOpts(opts); err != nil { + return err } // #nosec G404 log := s.log.With().Str("id", fmt.Sprintf("%02x", rand.Int31())).Str("address", s.address).Logger() ctx = log.WithContext(ctx) - if err := s.checkEventsOpts(opts); err != nil { - return err - } - endpoint := "/eth/v1/events" query := "topics=" + strings.Join(opts.Topics, "&topics=") callURL := urlForCall(s.base, endpoint, query) @@ -103,73 +89,30 @@ func (s *Service) Events(ctx context.Context, opts *api.EventsOpts) error { return nil } -func (s *Service) checkEventsOpts(opts *api.EventsOpts) error { - // Ensure we support the requested topic(s), and have a handler for each. - for _, topic := range opts.Topics { - if _, exists := apiv1.SupportedEventTopics[topic]; !exists { - return fmt.Errorf("unsupported event topic %s", topic) - } +// ValidateEventsOpts checks the options for an events subscription. +func ValidateEventsOpts(opts *api.EventsOpts) error { + if opts == nil { + return client.ErrNoOptions + } + if len(opts.Topics) == 0 { + return errors.Join(errors.New("no topics supplied"), client.ErrInvalidOptions) + } - if opts.Handler != nil { - // There is a generic handler in place, no further checks for this topic required. - continue + for _, topic := range opts.Topics { + if !eventdispatch.Supports(topic) { + return fmt.Errorf("unsupported event topic %s: %w", topic, client.ErrInvalidOptions) } - if err := s.checkEventSpecificHandler(opts, topic); err != nil { - return err + if opts.Handler == nil && !eventdispatch.HasTopicHandler(opts, topic) { + return fmt.Errorf("no handler for %s event: %w", topic, client.ErrInvalidOptions) } } return nil } -func (*Service) checkEventSpecificHandler(opts *api.EventsOpts, topic string) error { - var hasHandler bool - - switch topic { - case "attestation": - hasHandler = opts.AttestationHandler != nil - case "attester_slashing": - hasHandler = opts.AttesterSlashingHandler != nil - case "blob_sidecar": - hasHandler = opts.BlobSidecarHandler != nil - case "block": - hasHandler = opts.BlockHandler != nil - case "block_gossip": - hasHandler = opts.BlockGossipHandler != nil - case "bls_to_execution_change": - hasHandler = opts.BLSToExecutionChangeHandler != nil - case "chain_reorg": - hasHandler = opts.ChainReorgHandler != nil - case "contribution_and_proof": - hasHandler = opts.ContributionAndProofHandler != nil - case "data_column_sidecar": - hasHandler = opts.DataColumnSidecarHandler != nil - case "finalized_checkpoint": - hasHandler = opts.FinalizedCheckpointHandler != nil - case "head": - hasHandler = opts.HeadHandler != nil - case "payload_attributes": - hasHandler = opts.PayloadAttributesHandler != nil - case "proposer_slashing": - hasHandler = opts.ProposerSlashingHandler != nil - case "single_attestation": - hasHandler = opts.SingleAttestationHandler != nil - case "voluntary_exit": - hasHandler = opts.VoluntaryExitHandler != nil - default: - return fmt.Errorf("unsupported event %s", topic) - } - - if !hasHandler { - return fmt.Errorf("no handler for %s event", topic) - } - - return nil -} - // handleEvent handles all events. -func (s *Service) handleEvent(ctx context.Context, +func (*Service) handleEvent(ctx context.Context, msg *sse.Event, opts *api.EventsOpts, ) { @@ -181,445 +124,20 @@ func (s *Service) handleEvent(ctx context.Context, return } - switch string(msg.Event) { - case "attestation": - s.handleAttestationEvent(ctx, msg, opts) - case "attester_slashing": - s.handleAttesterSlashingEvent(ctx, msg, opts) - case "blob_sidecar": - s.handleBlobSidecarEvent(ctx, msg, opts) - case "block": - s.handleBlockEvent(ctx, msg, opts) - case "block_gossip": - s.handleBlockGossipEvent(ctx, msg, opts) - case "bls_to_execution_change": - s.handleBLSToExecutionChangeEvent(ctx, msg, opts) - case "chain_reorg": - s.handleChainReorgEvent(ctx, msg, opts) - case "contribution_and_proof": - s.handleContributionAndProofEvent(ctx, msg, opts) - case "data_column_sidecar": - s.handleDataColumnSidecarEvent(ctx, msg, opts) - case "finalized_checkpoint": - s.handleFinalizedCheckpointEvent(ctx, msg, opts) - case "head": - s.handleHeadEvent(ctx, msg, opts) - case "payload_attributes": - s.handlePayloadAttributesEvent(ctx, msg, opts) - case "proposer_slashing": - s.handleProposerSlashingEvent(ctx, msg, opts) - case "single_attestation": - s.handleSingleAttestationEvent(ctx, msg, opts) - case "voluntary_exit": - s.handleVoluntaryExitEvent(ctx, msg, opts) - case "": - // Used as keepalive. Ignore. - default: - log.Warn().Str("topic", string(msg.Event)).Msg("Received message with unhandled topic; ignoring") - } -} - -func (*Service) handleAttestationEvent(ctx context.Context, - msg *sse.Event, - opts *api.EventsOpts, -) { - log := zerolog.Ctx(ctx) - data := &spec.VersionedAttestation{} - - err := json.Unmarshal(msg.Data, data) - if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse attestation") - - return - } - - switch { - case opts.AttestationHandler != nil: - opts.AttestationHandler(ctx, data) - case opts.Handler != nil: - opts.Handler(&apiv1.Event{ - Topic: string(msg.Event), - Data: data, - }) - default: - log.Debug().Msg("No specific or generic handler supplied; ignoring") - } -} - -func (*Service) handleAttesterSlashingEvent(ctx context.Context, - msg *sse.Event, - opts *api.EventsOpts, -) { - log := zerolog.Ctx(ctx) - data := &electra.AttesterSlashing{} - - err := json.Unmarshal(msg.Data, data) - if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse attester slashing event") - - return - } - - switch { - case opts.AttesterSlashingHandler != nil: - opts.AttesterSlashingHandler(ctx, data) - case opts.Handler != nil: - opts.Handler(&apiv1.Event{ - Topic: string(msg.Event), - Data: data, - }) - default: - log.Debug().Msg("No specific or generic handler supplied; ignoring") - } -} - -func (*Service) handleBlobSidecarEvent(ctx context.Context, - msg *sse.Event, - opts *api.EventsOpts, -) { - log := zerolog.Ctx(ctx) - data := &apiv1.BlobSidecarEvent{} - - err := json.Unmarshal(msg.Data, data) - if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse blob sidecar event") - - return - } - - switch { - case opts.BlobSidecarHandler != nil: - opts.BlobSidecarHandler(ctx, data) - case opts.Handler != nil: - opts.Handler(&apiv1.Event{ - Topic: string(msg.Event), - Data: data, - }) - default: - log.Debug().Msg("No specific or generic handler supplied; ignoring") - } -} - -func (*Service) handleBlockEvent(ctx context.Context, - msg *sse.Event, - opts *api.EventsOpts, -) { - log := zerolog.Ctx(ctx) - data := &apiv1.BlockEvent{} - - err := json.Unmarshal(msg.Data, data) - if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse block event") - - return - } - - switch { - case opts.BlockHandler != nil: - opts.BlockHandler(ctx, data) - case opts.Handler != nil: - opts.Handler(&apiv1.Event{ - Topic: string(msg.Event), - Data: data, - }) - default: - log.Debug().Msg("No specific or generic handler supplied; ignoring") - } -} - -func (*Service) handleBlockGossipEvent(ctx context.Context, - msg *sse.Event, - opts *api.EventsOpts, -) { - log := zerolog.Ctx(ctx) - data := &apiv1.BlockGossipEvent{} - - err := json.Unmarshal(msg.Data, data) - if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse block gossip event") - - return - } - - switch { - case opts.BlockGossipHandler != nil: - opts.BlockGossipHandler(ctx, data) - case opts.Handler != nil: - opts.Handler(&apiv1.Event{ - Topic: string(msg.Event), - Data: data, - }) - default: - log.Debug().Msg("No specific or generic handler supplied; ignoring") - } -} - -func (*Service) handleBLSToExecutionChangeEvent(ctx context.Context, - msg *sse.Event, - opts *api.EventsOpts, -) { - log := zerolog.Ctx(ctx) - data := &capella.SignedBLSToExecutionChange{} - - err := json.Unmarshal(msg.Data, data) - if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse bls to execution change event") - - return - } - - switch { - case opts.BLSToExecutionChangeHandler != nil: - opts.BLSToExecutionChangeHandler(ctx, data) - case opts.Handler != nil: - opts.Handler(&apiv1.Event{ - Topic: string(msg.Event), - Data: data, - }) - default: - log.Debug().Msg("No specific or generic handler supplied; ignoring") - } -} - -func (*Service) handleChainReorgEvent(ctx context.Context, - msg *sse.Event, - opts *api.EventsOpts, -) { - log := zerolog.Ctx(ctx) - data := &apiv1.ChainReorgEvent{} - - err := json.Unmarshal(msg.Data, data) - if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse chain reorg event") - - return - } - - switch { - case opts.ChainReorgHandler != nil: - opts.ChainReorgHandler(ctx, data) - case opts.Handler != nil: - opts.Handler(&apiv1.Event{ - Topic: string(msg.Event), - Data: data, - }) - default: - log.Debug().Msg("No specific or generic handler supplied; ignoring") - } -} - -func (*Service) handleContributionAndProofEvent(ctx context.Context, - msg *sse.Event, - opts *api.EventsOpts, -) { - log := zerolog.Ctx(ctx) - data := &altair.SignedContributionAndProof{} - - err := json.Unmarshal(msg.Data, data) - if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse contribution and proof event") - - return - } - - switch { - case opts.ContributionAndProofHandler != nil: - opts.ContributionAndProofHandler(ctx, data) - case opts.Handler != nil: - opts.Handler(&apiv1.Event{ - Topic: string(msg.Event), - Data: data, - }) - default: - log.Debug().Msg("No specific or generic handler supplied; ignoring") - } -} - -func (*Service) handleFinalizedCheckpointEvent(ctx context.Context, - msg *sse.Event, - opts *api.EventsOpts, -) { - log := zerolog.Ctx(ctx) - data := &apiv1.FinalizedCheckpointEvent{} - - err := json.Unmarshal(msg.Data, data) - if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse finalized checkpoint event") - - return - } - - switch { - case opts.FinalizedCheckpointHandler != nil: - opts.FinalizedCheckpointHandler(ctx, data) - case opts.Handler != nil: - opts.Handler(&apiv1.Event{ - Topic: string(msg.Event), - Data: data, - }) - default: - log.Debug().Msg("No specific or generic handler supplied; ignoring") - } -} - -func (*Service) handleHeadEvent(ctx context.Context, - msg *sse.Event, - opts *api.EventsOpts, -) { - log := zerolog.Ctx(ctx) - data := &apiv1.HeadEvent{} - - err := json.Unmarshal(msg.Data, data) - if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse head event") - - return - } - - switch { - case opts.HeadHandler != nil: - opts.HeadHandler(ctx, data) - case opts.Handler != nil: - opts.Handler(&apiv1.Event{ - Topic: string(msg.Event), - Data: data, - }) - default: - log.Debug().Msg("No specific or generic handler supplied; ignoring") - } -} - -func (*Service) handlePayloadAttributesEvent(ctx context.Context, - msg *sse.Event, - opts *api.EventsOpts, -) { - log := zerolog.Ctx(ctx) - data := &apiv1.PayloadAttributesEvent{} - - err := json.Unmarshal(msg.Data, data) - if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse payload attributes event") - - return - } - - switch { - case opts.PayloadAttributesHandler != nil: - opts.PayloadAttributesHandler(ctx, data) - case opts.Handler != nil: - opts.Handler(&apiv1.Event{ - Topic: string(msg.Event), - Data: data, - }) - default: - log.Debug().Msg("No specific or generic handler supplied; ignoring") - } -} - -func (*Service) handleProposerSlashingEvent(ctx context.Context, - msg *sse.Event, - opts *api.EventsOpts, -) { - log := zerolog.Ctx(ctx) - data := &phase0.ProposerSlashing{} - - err := json.Unmarshal(msg.Data, data) - if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse proposer slashing event") - - return - } - - switch { - case opts.ProposerSlashingHandler != nil: - opts.ProposerSlashingHandler(ctx, data) - case opts.Handler != nil: - opts.Handler(&apiv1.Event{ - Topic: string(msg.Event), - Data: data, - }) - default: - log.Debug().Msg("No specific or generic handler supplied; ignoring") - } -} - -func (*Service) handleSingleAttestationEvent(ctx context.Context, - msg *sse.Event, - opts *api.EventsOpts, -) { - log := zerolog.Ctx(ctx) - data := &electra.SingleAttestation{} - - err := json.Unmarshal(msg.Data, data) - if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse single attestation") - - return - } - - switch { - case opts.SingleAttestationHandler != nil: - opts.SingleAttestationHandler(ctx, data) - case opts.Handler != nil: - opts.Handler(&apiv1.Event{ - Topic: string(msg.Event), - Data: data, - }) - default: - log.Debug().Msg("No specific or generic handler supplied; ignoring") - } -} - -func (*Service) handleVoluntaryExitEvent(ctx context.Context, - msg *sse.Event, - opts *api.EventsOpts, -) { - log := zerolog.Ctx(ctx) - data := &phase0.SignedVoluntaryExit{} - - err := json.Unmarshal(msg.Data, data) - if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse voluntary exit") + topic := string(msg.Event) + if len(topic) == 0 { + // Used as keepalive. Ignore. return } - switch { - case opts.VoluntaryExitHandler != nil: - opts.VoluntaryExitHandler(ctx, data) - case opts.Handler != nil: - opts.Handler(&apiv1.Event{ - Topic: string(msg.Event), - Data: data, - }) - default: - log.Debug().Msg("No specific or generic handler supplied; ignoring") - } -} - -func (*Service) handleDataColumnSidecarEvent(ctx context.Context, - msg *sse.Event, - opts *api.EventsOpts, -) { - log := zerolog.Ctx(ctx) - data := &apiv1.DataColumnSidecarEvent{} - - err := json.Unmarshal(msg.Data, data) - if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse data column sidecar event") - - return - } + err := eventdispatch.Handle(ctx, opts, topic, msg.Data) switch { - case opts.DataColumnSidecarHandler != nil: - opts.DataColumnSidecarHandler(ctx, data) - case opts.Handler != nil: - opts.Handler(&apiv1.Event{ - Topic: string(msg.Event), - Data: data, - }) + case err == nil: + case errors.Is(err, eventdispatch.ErrUnsupportedTopic): + log.Warn().Str("topic", topic).Msg("Received message with unhandled topic; ignoring") default: - log.Debug().Msg("No specific or generic handler supplied; ignoring") + log.Error().Err(err).Str("topic", topic).RawJSON("data", msg.Data).Msg("Failed to parse event") } } diff --git a/http/events_internal_test.go b/http/events_internal_test.go index a8189d60..09201ea7 100644 --- a/http/events_internal_test.go +++ b/http/events_internal_test.go @@ -1,4 +1,4 @@ -// Copyright © 2020, 2021 Attestant Limited. +// Copyright © 2020 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -16,185 +16,346 @@ package http import ( "bytes" "context" - "fmt" - "os" + "encoding/json" + "reflect" "testing" - "time" - client "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/api" apiv1 "github.com/attestantio/go-eth2-client/api/v1" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/r3labs/sse/v2" "github.com/rs/zerolog" "github.com/stretchr/testify/require" ) -// timeout for tests. -var timeout = 60 * time.Second +// eventPayloads are the data of real events, by topic, so that decoding them tests the +// library's types against what nodes send rather than against their own marshalling. The +// payloads of attestation, attester_slashing, block, bls_to_execution_change, chain_reorg, +// contribution_and_proof, finalized_checkpoint, head, proposer_slashing and voluntary_exit +// were captured from mainnet nodes. The rest are verbatim from the examples in beacon-APIs +// apis/eventstream/index.yaml at master, except for: +// - blob_sidecar, from v3.1.0, as the topic has since been removed; +// - data_column_sidecar, from v4.0.0, and payload_attributes, from v3.1.0, as the later +// examples are of shapes the library does not yet decode; +// - the KZG commitments of blob_sidecar and data_column_sidecar, which the spec examples give +// as 96 bytes, cut to the 48 bytes of a commitment; +// - the light-client examples, reduced to their version and signature slot. +var eventPayloads = map[string]string{ + "attestation": `{"aggregation_bits":"0x00002840403040000000020008800040008042800000020220","data":{"slot":"4095945","index":"12","beacon_block_root":"0xff27c7551bf4cfe4dc4cce00920e7a5c5074860d1dbd8aa8b3b5f888523f51ff","source":{"epoch":"127997","root":"0x38758fb180459583bd5e8e1a31711eb09e63eb92be974485397e9a2c57de2783"},"target":{"epoch":"127998","root":"0x46d4629861bd81cfc94007501b4edb1b3ca9444b41d7a98681b6c2f4bdb978bd"}},"signature":"0xacb9f562a28c4ef5b60b88678068ea51573a3237d3331dda3b2d845a0d03bc56ab2994d2deb90d9f074a8bdab59945150d0a7717e74b1bf2627f8971c81091f724c211dfce8fa16fb839c6a1bfd341ddec5e7eb88472682fd1a170e373660534"}`, + "attester_slashing": `{"attestation_1":{"attesting_indices":["1379","6498","7152","7611","10719","11656","15711","20612","26976","28038","30613","39996","72161","72684","75322","86121","89806","92747","94094","96198","96587","99048","100892","101838","117219","135916","139472","140755","145132","146549","148793","153618","154320","154414","156422","163002","165632","166740","167801","169764","171695","174283","176982","177143","177856","178016","180422","180593","186627","190359","192302","192973","201784","202000","213132","214455","216157","218189","218670","218872","221371","223604","225384","228340","232488","236360","237398","237769","239585","239870","243367","245718","247409","250257","251557","251850","255266","269326","269413","277447","278479","280748","282230","286188","286499","287581","293997","294106","297527","298179","299246","304274","305482","308474","312315","312856","315565","318733","319788","320418","322572","329735","331990","337653","337896","341979","344354","349106","350432","350598","350719","351231","353417","356015","356912","358208","358253","358775","360731","361675","361809","362528","372325","372331","372770","377376","386524","389323","389993","391013","394262","395176","395752","398129","399427","405839","407363","408517","409937","410538","411482","413713","416159","421753","426669","427714","428875","429693","430575","432645","437734","445366","448354","450266","452487","455531","455575","458233","468388","468877","471109","477185","480640","484346","484790","485906","487414","487705","493398","493527","497774","497887","498530","498791","501663","502258","503864","504418","506511","507566","508833","511154","515792","517955","522747","524759","533659","539011","539517","542154","542387","545084","547320","564444","565691","566062","566942","567905","569019","570238","574236","574836","583402","586842","589094","589373","590472","591564","592365","599309","601533","614547","615289","618674","620483","620874","621114","631082","631580","635052","635357","635632","640540","641046","641712","642095","644393","645990","650639","651381","655674","658553","658730","660381","663012","666071","671248","672021","673352","677063","680655","684323","685145","687842","688381","688464","692537","710555","713517","716137","716725","716893","720080","720715","721026","721397","721969","722568","726847","728006","728026","728968","729431","730915","731268","734600","734705","736076","742690","743783","744046","746246","746281","747514","755518","756113","759722","763739","764264","764403","774204","774657","775367","776295","776468","777628","777724","778906","778935","784104","790549","793434","793732","793991","798735","798740","799926","800022","805848","806025","808386","817029","818647","820641","824328","826419","827758","829139","830010","831317","837969","841062","843916","845313","845802","847624","852439","856121","862363","863997","868399","872373","872685","877312","877842","878805","878819","882746","884016","885135","888929","891416","896204","897101","898180","902030","902136","902843","903615","909161","920873","922759","923975","924185","924653","925291","927531","930388","931933","935314","936880","939910","940153","941510","942425","944415","945704","946209","949636","950399","953330","954070","954479","957162","958646","959098","959979","963938","963981","966927","968451","970800","971047","975171","975715","976123","978689","980754","980953","983241","983503","985284","988359","988747","989661","989900","990231","991724","994685","995191","995380","997021","998184","1002418","1005226","1006270","1009818","1014829","1014891","1015330","1015344","1015979","1016933","1019651","1019804","1022015","1022177","1022438","1022480","1024014","1026716","1026957","1028027","1028042","1031558","1032367","1032944","1033387","1034311","1034709","1037676","1039639","1043684","1044190"],"data":{"slot":"7858464","index":"6","beacon_block_root":"0x47e718a353454bf041b953cbad56f814546514cb0dd0987d79bad2636c8af36f","source":{"epoch":"245576","root":"0x209ea5723ad520c4b288ddefd00c18ef92cc802fad92a05479f2da13039d2bc9"},"target":{"epoch":"245577","root":"0x47e718a353454bf041b953cbad56f814546514cb0dd0987d79bad2636c8af36f"}},"signature":"0xb2073a97debb76ed591afb65d3a59eb32ac16f88d85e73ff0b5a19a935feb5bb8e4d3c9bdeca93bc070fc2ae29c7903308c1e6480232d03a2bb0d813c3c8d15dbf7a573df382f08051d364eda0d16d26bcbe3bff5c02892a58278d16d94d18ab"},"attestation_2":{"attesting_indices":["386524"],"data":{"slot":"7858464","index":"6","beacon_block_root":"0x030dc7282d3cca8416970028f33361a044445176119ad35bd35c2ed9e797f910","source":{"epoch":"245576","root":"0x209ea5723ad520c4b288ddefd00c18ef92cc802fad92a05479f2da13039d2bc9"},"target":{"epoch":"245577","root":"0x030dc7282d3cca8416970028f33361a044445176119ad35bd35c2ed9e797f910"}},"signature":"0x80a603ce9cd34572947273feb894e715f0497d843cea397f84586603888620cb9721f479d2f46f3eb6568c8e7bf1d19a075b993679bd60bdd04613693d49799309fb44216f8ad03eb1f327e543a1292a8573a338dca2d8cb6beb593b93cfff56"}}`, + "blob_sidecar": `{"block_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", "index": "1", "slot": "1", "kzg_commitment": "0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2", "versioned_hash": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2"}`, + "block": `{"slot":"4095943","block":"0x1c3981b7439cd2dc53dca1a99122e1cacb36a13796d426d4c8a03ba745cb0c8b","execution_optimistic":false}`, + "block_gossip": `{"slot":"10", "block":"0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf"}`, + "bls_to_execution_change": `{"message":{"validator_index":"63401","from_bls_pubkey":"0xa46ed2574770ec1942d577ef89e0bf7b0d601349dab791740dead3fb5a6e2624cf62b9e58de1074c49f44b986eb39002","to_execution_address":"0xd641D2Cc74C7b6A641861260d07D67eB67bc7403"},"signature":"0xb9ce6f10137a8bc73cd5545e5d6c7c61bc1294b853e71e5eb090a7a773d7773c4d7732f4244e9891cd5c1f7a5fbc951c0b368a8bd7dc44b1598a6d1bdb4476f9f364ef521c366e55565792810f0cb8bf8cc20134f32ed8dde54507f622402d6e"}`, + "chain_reorg": `{"slot":"4100237","depth":"2","old_head_block":"0x5c988c12b7d8638c06e6b9511e09e5e28511a16a33c153413416d3fd5d95353a","new_head_block":"0x783ccb4310c0ddab3ea500f8d2b88c5ad8d6b2d601513f4ebf491066cda1d180","old_head_state":"0xdbad017808a1c5a77866fccc3e15f14a67585fff25b3a4d947ae9cc6d937b4ab","new_head_state":"0x67f0302bb939f64b1feaaa907b30c9631c0588480f2305578377bfd87df68b95","epoch":"128132","execution_optimistic":false}`, + "contribution_and_proof": `{"message":{"aggregator_index":"355177","contribution":{"slot":"4095970","beacon_block_root":"0xfe17cc29bb937740eead4b84716751b00e361891dae7c8f0e98f4deb5f753cbc","subcommittee_index":"0","aggregation_bits":"0xffffffffffffffffffffdfffffffffff","signature":"0x926e8fbf2b8599f76a42e2dd02b954853d3841577a0c68303fb9a5690f7973e95454bc1df03118d53c80e3cf13dc33490f2516aefb4cb7766a724a10dd0536811ec43b7e5f08442ef7dbc072b4b484ea1acde78e5aae8d636b06dd18677c535b"},"selection_proof":"0x84c805b21a40315dc19ea89e6d64d8f5e913d5e003a813d74a23f16add72250791a950b7508e2ea69adab8169c2800b70d26fea4cdb51f1fb96d939baaa44567eb9e96d2021799478fd3f557326a62060215be95465fcc9c49f67dd8685a20bc"},"signature":"0x8093efce898e36cab5ab2b198a48046d029b36909a29ec33ca7075f389133288c4d7e13cf3e20396612050d4aebe9212154fd5a2be4bf356e6191600d65906d5c404bd46c95ae20fe4bc5e18c6e2808c97a4572f995bf90db8aaf3fd84fb87ac"}`, + "data_column_sidecar": `{"block_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", "index": "1", "slot": "1", "kzg_commitments": ["0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2"]}`, + "execution_payload": `{"slot":"10", "builder_index":"42", "block_hash":"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "block_root":"0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf", "execution_optimistic": false}`, + "execution_payload_available": `{"slot": "10", "block_root": "0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf"}`, + "execution_payload_bid": `{"version":"gloas", "data":{"message": {"parent_block_hash": "0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf", "parent_block_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", "block_hash": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "prev_randao": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", "fee_recipient": "0x0000000000000000000000000000000000000000", "gas_limit": "30000000", "builder_index": "42", "slot": "10", "value": "1000000000", "execution_payment": "0", "blob_kzg_commitments": ["0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2"], "execution_requests_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2"}, "signature": "0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505"}}`, + "execution_payload_gossip": `{"slot":"10", "builder_index":"42", "block_hash":"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "block_root":"0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf"}`, + "fast_confirmation": `{"block": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", "slot": "1", "current_slot": "2"}`, + "finalized_checkpoint": `{"block":"0x38758fb180459583bd5e8e1a31711eb09e63eb92be974485397e9a2c57de2783","state":"0x9c237b2a66df8636f816e6b2c8860ba287fc5b817d882b1be8b7111486fb4ddc","epoch":"127997","execution_optimistic":false}`, + "head": `{"slot":"4095940","block":"0x73d83c5f925716c9bd2d1e9c339fb99b0ec4addef3e93f6f35d4c5f1de7ae092","state":"0xead0e6eb4004576546864f10cfa4aeac31afbf96abc405a86c00cbda8f3e8ed0","epoch_transition":false,"previous_duty_dependent_root":"0xeca94cc9180212a2cff2659289cc7e6f2df08a645120e35e25d09c2ddc7db5f1","current_duty_dependent_root":"0xdda286c4a096fc8ec0d6ba9e14e688cbb046bfb33462fdf94953e75d0cea0074","execution_optimistic":false}`, + "head_v2": `{"version":"gloas","data":{"slot":"10","block":"0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf","state":"0x600e852a08c1200654ddf11025f1ceacb3c2e74bdd5c630cde0838b2591b69f9","payload_status":"empty","epoch_transition":false,"current_epoch_dependent_root":"0x5e0043f107cb57913498fbf2f99ff55e730bf1e151f02f221e977c91a90a0e91","next_epoch_dependent_root":"0x5e0043f107cb57913498fbf2f99ff55e730bf1e151f02f221e977c91a90a0e91","execution_optimistic":false}}`, + "light_client_finality_update": `{"version":"altair","data":{"signature_slot":"1"}}`, + "light_client_optimistic_update": `{"version":"altair","data":{"signature_slot":"2"}}`, + "payload_attestation_message": `{"version":"gloas", "data":{"validator_index": "123", "data": {"beacon_block_root": "0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf", "slot": "10", "payload_present": true, "blob_data_available": true}, "signature": "0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505"}}`, + "payload_attributes": `{"version": "capella", "data": {"proposer_index": "123", "proposal_slot": "10", "parent_block_number": "9", "parent_block_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", "parent_block_hash": "0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf", "payload_attributes": {"timestamp": "123456", "prev_randao": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", "suggested_fee_recipient": "0x0000000000000000000000000000000000000000", "withdrawals": [{"index": "5", "validator_index": "10", "address": "0x0000000000000000000000000000000000000000", "amount": "15640"}]}}}`, + "proposer_preferences": `{"version":"gloas", "data":{"message": {"dependent_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", "proposal_slot": "32", "validator_index": "123", "fee_recipient": "0x0000000000000000000000000000000000000000", "target_gas_limit": "60000000"}, "signature": "0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505"}}`, + "proposer_slashing": `{"signed_header_1":{"message":{"slot":"6137846","proposer_index":"552061","parent_root":"0x0000000000000000000000000000000000000000000000000000000000000000","state_root":"0x0000000000000000000000000000000000000000000000000000000000000000","body_root":"0x82710f9c7025617ac3ef1f722451c05046ea01d525d60b7594669e63080e5db7"},"signature":"0xb544eaa34654372143d0442fbba6713d978f780da85503b2a070998970867b4936e884c9f948d51c6e08f6c927f0cd4d0a334e4f04404876e08863e73d8add1b3949fd911eb386fa24ea4c1dc062564b0af8a2f95e8940737f958c8baa584cd6"},"signed_header_2":{"message":{"slot":"6137846","proposer_index":"552061","parent_root":"0x873f73baa696664b8b73b160e7cfe352a924238935324007086e93a158b6e23d","state_root":"0xfad223f846ec0798c8a128e52ded70c02eeda3bdcf733db7ee77b0f02a46cef9","body_root":"0xe8b27e1cb99c74d0a39d19a573743426aba74cca872b3fc44115113c669d5a96"},"signature":"0x90f6920c78ed00a06affa06231780b9ad632b54ee2e1a6531e3881c9a96f47dadfba648566cdd684386df19150a9b6eb18a11e79f3d71e5fc85c7af81dd5fcd92f8ddbc870e526caed49c48ebc426489abc1efc64e3c7a14541b558877309141"}}`, + "single_attestation": `{"committee_index":"1", "attester_index": "1", "data":{"slot":"1", "index":"1", "beacon_block_root":"0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", "source":{"epoch":"1", "root":"0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2"}, "target":{"epoch":"1", "root":"0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2"}}, "signature":"0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505"}`, + "voluntary_exit": `{"message":{"epoch":"1", "validator_index":"1"}, "signature":"0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505"}`, +} -func TestEventHandler(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() +func TestHandleEventDispatchesEverySupportedTopic(t *testing.T) { + tests := []struct { + topic string + expected any + }{ + {topic: "attestation", expected: &spec.VersionedAttestation{}}, + {topic: "attester_slashing", expected: &electra.AttesterSlashing{}}, + {topic: "blob_sidecar", expected: &apiv1.BlobSidecarEvent{}}, + {topic: "block", expected: &apiv1.BlockEvent{}}, + {topic: "block_gossip", expected: &apiv1.BlockGossipEvent{}}, + {topic: "bls_to_execution_change", expected: &capella.SignedBLSToExecutionChange{}}, + {topic: "chain_reorg", expected: &apiv1.ChainReorgEvent{}}, + {topic: "contribution_and_proof", expected: &altair.SignedContributionAndProof{}}, + {topic: "data_column_sidecar", expected: &apiv1.DataColumnSidecarEvent{}}, + {topic: "execution_payload", expected: &apiv1.ExecutionPayloadEvent{}}, + {topic: "execution_payload_available", expected: &apiv1.ExecutionPayloadAvailableEvent{}}, + {topic: "execution_payload_bid", expected: &gloas.SignedExecutionPayloadBid{}}, + {topic: "execution_payload_gossip", expected: &apiv1.ExecutionPayloadGossipEvent{}}, + {topic: "fast_confirmation", expected: &apiv1.FastConfirmationEvent{}}, + {topic: "finalized_checkpoint", expected: &apiv1.FinalizedCheckpointEvent{}}, + {topic: "head", expected: &apiv1.HeadEvent{}}, + {topic: "head_v2", expected: &apiv1.HeadEventV2{}}, + {topic: "light_client_finality_update", expected: &apiv1.LightClientFinalityUpdateEvent{}}, + {topic: "light_client_optimistic_update", expected: &apiv1.LightClientOptimisticUpdateEvent{}}, + {topic: "payload_attestation_message", expected: &gloas.PayloadAttestationMessage{}}, + {topic: "payload_attributes", expected: &apiv1.PayloadAttributesEvent{}}, + {topic: "proposer_preferences", expected: &gloas.SignedProposerPreferences{}}, + {topic: "proposer_slashing", expected: &phase0.ProposerSlashing{}}, + {topic: "single_attestation", expected: &electra.SingleAttestation{}}, + {topic: "voluntary_exit", expected: &phase0.SignedVoluntaryExit{}}, + } - handled := false - handler := func(*apiv1.Event) { - handled = true + topics := make(map[string]bool, len(tests)) + for _, test := range tests { + require.NotContains(t, topics, test.topic) + topics[test.topic] = true } + require.Equal(t, apiv1.SupportedEventTopics, topics) - tests := []struct { - name string - message *sse.Event - handler api.EventHandlerFunc - handled bool - }{ - { - name: "MessageNil", - handler: handler, - handled: false, - }, - { - name: "MessageEmpty", - message: &sse.Event{}, - handler: handler, - handled: false, - }, + for _, test := range tests { + t.Run(test.topic, func(t *testing.T) { + require.True(t, apiv1.SupportedEventTopics[test.topic]) + require.Contains(t, eventPayloads, test.topic, "no real payload for the topic") + + var event *apiv1.Event + service := &Service{} + service.handleEvent(context.Background(), &sse.Event{ + Event: []byte(test.topic), + Data: []byte(eventPayloads[test.topic]), + }, &api.EventsOpts{ + Handler: func(received *apiv1.Event) { + event = received + }, + }) + + require.NotNil(t, event, "the payload was not decoded") + require.Equal(t, test.topic, event.Topic) + require.IsType(t, test.expected, event.Data) + + input, err := json.Marshal(map[string]any{"topic": test.topic, "data": json.RawMessage(eventPayloads[test.topic])}) + require.NoError(t, err) + var decoded apiv1.Event + require.NoError(t, json.Unmarshal(input, &decoded)) + require.IsType(t, test.expected, decoded.Data) + }) + } +} + +type gloasHandlerTest struct { + topic string + setHandler func(*api.EventsOpts, func()) +} + +func gloasHandlerTests() []gloasHandlerTest { + return []gloasHandlerTest{ { - name: "EventUnknown", - message: &sse.Event{ - Event: []byte("unknown"), + topic: "execution_payload", + setHandler: func(opts *api.EventsOpts, handled func()) { + opts.ExecutionPayloadHandler = func(context.Context, *apiv1.ExecutionPayloadEvent) { handled() } }, - handler: handler, - handled: false, }, { - name: "HandlerNil", - message: &sse.Event{ - Event: []byte("head"), + topic: "execution_payload_available", + setHandler: func(opts *api.EventsOpts, handled func()) { + opts.ExecutionPayloadAvailableHandler = func(context.Context, *apiv1.ExecutionPayloadAvailableEvent) { handled() } }, - handled: false, }, { - name: "HeadGood", - message: &sse.Event{ - Event: []byte("head"), - Data: []byte(`{"slot":"4095940","block":"0x73d83c5f925716c9bd2d1e9c339fb99b0ec4addef3e93f6f35d4c5f1de7ae092","state":"0xead0e6eb4004576546864f10cfa4aeac31afbf96abc405a86c00cbda8f3e8ed0","epoch_transition":false,"previous_duty_dependent_root":"0xeca94cc9180212a2cff2659289cc7e6f2df08a645120e35e25d09c2ddc7db5f1","current_duty_dependent_root":"0xdda286c4a096fc8ec0d6ba9e14e688cbb046bfb33462fdf94953e75d0cea0074","execution_optimistic":false}`), + topic: "execution_payload_bid", + setHandler: func(opts *api.EventsOpts, handled func()) { + opts.ExecutionPayloadBidHandler = func(context.Context, *gloas.SignedExecutionPayloadBid) { handled() } }, - handler: handler, - handled: true, }, { - name: "BlockGood", - message: &sse.Event{ - Event: []byte("block"), - Data: []byte(`{"slot":"4095943","block":"0x1c3981b7439cd2dc53dca1a99122e1cacb36a13796d426d4c8a03ba745cb0c8b","execution_optimistic":false}`), + topic: "execution_payload_gossip", + setHandler: func(opts *api.EventsOpts, handled func()) { + opts.ExecutionPayloadGossipHandler = func(context.Context, *apiv1.ExecutionPayloadGossipEvent) { handled() } }, - handler: handler, - handled: true, }, { - name: "AttestationGood", - message: &sse.Event{ - Event: []byte("attestation"), - Data: []byte(`{"aggregation_bits":"0x00002840403040000000020008800040008042800000020220","data":{"slot":"4095945","index":"12","beacon_block_root":"0xff27c7551bf4cfe4dc4cce00920e7a5c5074860d1dbd8aa8b3b5f888523f51ff","source":{"epoch":"127997","root":"0x38758fb180459583bd5e8e1a31711eb09e63eb92be974485397e9a2c57de2783"},"target":{"epoch":"127998","root":"0x46d4629861bd81cfc94007501b4edb1b3ca9444b41d7a98681b6c2f4bdb978bd"}},"signature":"0xacb9f562a28c4ef5b60b88678068ea51573a3237d3331dda3b2d845a0d03bc56ab2994d2deb90d9f074a8bdab59945150d0a7717e74b1bf2627f8971c81091f724c211dfce8fa16fb839c6a1bfd341ddec5e7eb88472682fd1a170e373660534"}`), + topic: "fast_confirmation", + setHandler: func(opts *api.EventsOpts, handled func()) { + opts.FastConfirmationHandler = func(context.Context, *apiv1.FastConfirmationEvent) { handled() } }, - handler: handler, - handled: true, }, { - name: "VoluntaryExitGood", - message: &sse.Event{ - Event: []byte("voluntary_exit"), - Data: []byte(`{"message":{"epoch":"1", "validator_index":"1"}, "signature":"0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505"}`), + topic: "payload_attestation_message", + setHandler: func(opts *api.EventsOpts, handled func()) { + opts.PayloadAttestationMessageHandler = func(context.Context, *gloas.PayloadAttestationMessage) { handled() } }, - handler: handler, - handled: true, }, { - name: "FinalizedCheckpointGood", - message: &sse.Event{ - Event: []byte("finalized_checkpoint"), - Data: []byte(`{"block":"0x38758fb180459583bd5e8e1a31711eb09e63eb92be974485397e9a2c57de2783","state":"0x9c237b2a66df8636f816e6b2c8860ba287fc5b817d882b1be8b7111486fb4ddc","epoch":"127997","execution_optimistic":false}`), + topic: "proposer_preferences", + setHandler: func(opts *api.EventsOpts, handled func()) { + opts.ProposerPreferencesHandler = func(context.Context, *gloas.SignedProposerPreferences) { handled() } }, - handler: handler, - handled: true, }, + } +} + +func TestHandleEventGloasSpecificHandlers(t *testing.T) { + tests := gloasHandlerTests() + + for _, test := range tests { + t.Run(test.topic, func(t *testing.T) { + var genericHandled bool + var specificHandled bool + opts := &api.EventsOpts{ + Handler: func(*apiv1.Event) { + genericHandled = true + }, + } + test.setHandler(opts, func() { specificHandled = true }) + + (&Service{}).handleEvent(context.Background(), &sse.Event{ + Event: []byte(test.topic), + Data: []byte(eventPayloads[test.topic]), + }, opts) + + require.True(t, specificHandled) + require.False(t, genericHandled) + }) + } +} + +func TestHandleEventGloasMalformedPayloads(t *testing.T) { + tests := gloasHandlerTests() + + for _, test := range tests { + t.Run(test.topic, func(t *testing.T) { + var genericHandled bool + var specificHandled bool + opts := &api.EventsOpts{ + Handler: func(*apiv1.Event) { + genericHandled = true + }, + } + test.setHandler(opts, func() { specificHandled = true }) + + (&Service{}).handleEvent(context.Background(), &sse.Event{ + Event: []byte(test.topic), + Data: []byte(`invalid`), + }, opts) + + require.False(t, specificHandled) + require.False(t, genericHandled) + }) + } +} + +func TestHandleEventControls(t *testing.T) { + tests := []struct { + name string + message *sse.Event + }{ + {name: "Nil"}, + {name: "Keepalive", message: &sse.Event{}}, + {name: "UnknownTopic", message: &sse.Event{Event: []byte("unknown")}}, { - name: "ChainReorgGood", + name: "NoHandlers", message: &sse.Event{ - Event: []byte("chain_reorg"), - Data: []byte(`{"slot":"4100237","depth":"2","old_head_block":"0x5c988c12b7d8638c06e6b9511e09e5e28511a16a33c153413416d3fd5d95353a","new_head_block":"0x783ccb4310c0ddab3ea500f8d2b88c5ad8d6b2d601513f4ebf491066cda1d180","old_head_state":"0xdbad017808a1c5a77866fccc3e15f14a67585fff25b3a4d947ae9cc6d937b4ab","new_head_state":"0x67f0302bb939f64b1feaaa907b30c9631c0588480f2305578377bfd87df68b95","epoch":"128132","execution_optimistic":false}`), + Event: []byte("head"), + Data: eventData(t, json.RawMessage(`{"slot":"231192","block":"0xbe36e714a6114cf718e35dafc4ac530ce8f01e4a9a360e78098eb129772dcc39","state":"0x61099b2c1dee0104c93ce0e14e5f5fc4b6faceff4cb863278d055bdfb73b7dc7","epoch_transition":false,"previous_duty_dependent_root":"0xa692c095bbca3eeaf99eeabada78874c028c02b176ccf691f3e8fa075d67f5c6","current_duty_dependent_root":"0x92c6b763f610d5941d2041906007bf9449d37772aacf0483a76275ac27c096b4"}`), false), }, - handler: handler, - handled: true, }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.NotPanics(t, func() { + (&Service{}).handleEvent(context.Background(), test.message, &api.EventsOpts{}) + }) + }) + } +} + +func eventData(t *testing.T, data any, versioned bool) []byte { + t.Helper() + + payload, err := json.Marshal(data) + require.NoError(t, err) + if !versioned { + return payload + } + + wrapped, err := json.Marshal(struct { + Version string `json:"version"` + Data json.RawMessage `json:"data"` + }{ + Version: "gloas", + Data: payload, + }) + require.NoError(t, err) + + return wrapped +} + +func TestExecutionPayloadTopicsSpecExamples(t *testing.T) { + // Verbatim data lines from beacon-APIs apis/eventstream/index.yaml. + tests := []struct { + name string + input string + optimistic bool + }{ { - name: "ProposerSlashingGood", - message: &sse.Event{ - Event: []byte("proposer_slashing"), - Data: []byte(`{"signed_header_1":{"message":{"slot":"6137846","proposer_index":"552061","parent_root":"0x0000000000000000000000000000000000000000000000000000000000000000","state_root":"0x0000000000000000000000000000000000000000000000000000000000000000","body_root":"0x82710f9c7025617ac3ef1f722451c05046ea01d525d60b7594669e63080e5db7"},"signature":"0xb544eaa34654372143d0442fbba6713d978f780da85503b2a070998970867b4936e884c9f948d51c6e08f6c927f0cd4d0a334e4f04404876e08863e73d8add1b3949fd911eb386fa24ea4c1dc062564b0af8a2f95e8940737f958c8baa584cd6"},"signed_header_2":{"message":{"slot":"6137846","proposer_index":"552061","parent_root":"0x873f73baa696664b8b73b160e7cfe352a924238935324007086e93a158b6e23d","state_root":"0xfad223f846ec0798c8a128e52ded70c02eeda3bdcf733db7ee77b0f02a46cef9","body_root":"0xe8b27e1cb99c74d0a39d19a573743426aba74cca872b3fc44115113c669d5a96"},"signature":"0x90f6920c78ed00a06affa06231780b9ad632b54ee2e1a6531e3881c9a96f47dadfba648566cdd684386df19150a9b6eb18a11e79f3d71e5fc85c7af81dd5fcd92f8ddbc870e526caed49c48ebc426489abc1efc64e3c7a14541b558877309141"}}`), - }, - handler: handler, - handled: true, + name: "execution_payload", + input: `{"slot":"10", "builder_index":"42", "block_hash":"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "block_root":"0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf", "execution_optimistic": false}`, + optimistic: true, }, { - name: "AttesterSlashingGood", - message: &sse.Event{ - Event: []byte("attester_slashing"), - Data: []byte(`{"attestation_1":{"attesting_indices":["1379","6498","7152","7611","10719","11656","15711","20612","26976","28038","30613","39996","72161","72684","75322","86121","89806","92747","94094","96198","96587","99048","100892","101838","117219","135916","139472","140755","145132","146549","148793","153618","154320","154414","156422","163002","165632","166740","167801","169764","171695","174283","176982","177143","177856","178016","180422","180593","186627","190359","192302","192973","201784","202000","213132","214455","216157","218189","218670","218872","221371","223604","225384","228340","232488","236360","237398","237769","239585","239870","243367","245718","247409","250257","251557","251850","255266","269326","269413","277447","278479","280748","282230","286188","286499","287581","293997","294106","297527","298179","299246","304274","305482","308474","312315","312856","315565","318733","319788","320418","322572","329735","331990","337653","337896","341979","344354","349106","350432","350598","350719","351231","353417","356015","356912","358208","358253","358775","360731","361675","361809","362528","372325","372331","372770","377376","386524","389323","389993","391013","394262","395176","395752","398129","399427","405839","407363","408517","409937","410538","411482","413713","416159","421753","426669","427714","428875","429693","430575","432645","437734","445366","448354","450266","452487","455531","455575","458233","468388","468877","471109","477185","480640","484346","484790","485906","487414","487705","493398","493527","497774","497887","498530","498791","501663","502258","503864","504418","506511","507566","508833","511154","515792","517955","522747","524759","533659","539011","539517","542154","542387","545084","547320","564444","565691","566062","566942","567905","569019","570238","574236","574836","583402","586842","589094","589373","590472","591564","592365","599309","601533","614547","615289","618674","620483","620874","621114","631082","631580","635052","635357","635632","640540","641046","641712","642095","644393","645990","650639","651381","655674","658553","658730","660381","663012","666071","671248","672021","673352","677063","680655","684323","685145","687842","688381","688464","692537","710555","713517","716137","716725","716893","720080","720715","721026","721397","721969","722568","726847","728006","728026","728968","729431","730915","731268","734600","734705","736076","742690","743783","744046","746246","746281","747514","755518","756113","759722","763739","764264","764403","774204","774657","775367","776295","776468","777628","777724","778906","778935","784104","790549","793434","793732","793991","798735","798740","799926","800022","805848","806025","808386","817029","818647","820641","824328","826419","827758","829139","830010","831317","837969","841062","843916","845313","845802","847624","852439","856121","862363","863997","868399","872373","872685","877312","877842","878805","878819","882746","884016","885135","888929","891416","896204","897101","898180","902030","902136","902843","903615","909161","920873","922759","923975","924185","924653","925291","927531","930388","931933","935314","936880","939910","940153","941510","942425","944415","945704","946209","949636","950399","953330","954070","954479","957162","958646","959098","959979","963938","963981","966927","968451","970800","971047","975171","975715","976123","978689","980754","980953","983241","983503","985284","988359","988747","989661","989900","990231","991724","994685","995191","995380","997021","998184","1002418","1005226","1006270","1009818","1014829","1014891","1015330","1015344","1015979","1016933","1019651","1019804","1022015","1022177","1022438","1022480","1024014","1026716","1026957","1028027","1028042","1031558","1032367","1032944","1033387","1034311","1034709","1037676","1039639","1043684","1044190"],"data":{"slot":"7858464","index":"6","beacon_block_root":"0x47e718a353454bf041b953cbad56f814546514cb0dd0987d79bad2636c8af36f","source":{"epoch":"245576","root":"0x209ea5723ad520c4b288ddefd00c18ef92cc802fad92a05479f2da13039d2bc9"},"target":{"epoch":"245577","root":"0x47e718a353454bf041b953cbad56f814546514cb0dd0987d79bad2636c8af36f"}},"signature":"0xb2073a97debb76ed591afb65d3a59eb32ac16f88d85e73ff0b5a19a935feb5bb8e4d3c9bdeca93bc070fc2ae29c7903308c1e6480232d03a2bb0d813c3c8d15dbf7a573df382f08051d364eda0d16d26bcbe3bff5c02892a58278d16d94d18ab"},"attestation_2":{"attesting_indices":["386524"],"data":{"slot":"7858464","index":"6","beacon_block_root":"0x030dc7282d3cca8416970028f33361a044445176119ad35bd35c2ed9e797f910","source":{"epoch":"245576","root":"0x209ea5723ad520c4b288ddefd00c18ef92cc802fad92a05479f2da13039d2bc9"},"target":{"epoch":"245577","root":"0x030dc7282d3cca8416970028f33361a044445176119ad35bd35c2ed9e797f910"}},"signature":"0x80a603ce9cd34572947273feb894e715f0497d843cea397f84586603888620cb9721f479d2f46f3eb6568c8e7bf1d19a075b993679bd60bdd04613693d49799309fb44216f8ad03eb1f327e543a1292a8573a338dca2d8cb6beb593b93cfff56"}}`), - }, - handler: handler, - handled: true, + name: "execution_payload_gossip", + input: `{"slot":"10", "builder_index":"42", "block_hash":"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "block_root":"0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf"}`, }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var event *apiv1.Event + (&Service{}).handleEvent(context.Background(), &sse.Event{Event: []byte(test.name), Data: []byte(test.input)}, &api.EventsOpts{ + Handler: func(received *apiv1.Event) { event = received }, + }) + require.NotNil(t, event) + _, hasOptimistic := reflect.TypeOf(event.Data).Elem().FieldByName("ExecutionOptimistic") + require.Equal(t, test.optimistic, hasOptimistic) + encoded, err := json.Marshal(event.Data) + require.NoError(t, err) + require.JSONEq(t, test.input, string(encoded)) + }) + } +} + +// TestHandleEventLogs confirms that an event of an unsupported topic is reported as such, at +// warning, and an event whose data does not decode as a parse failure, at error. +func TestHandleEventLogs(t *testing.T) { + // Other tests in the package disable logging globally. + level := zerolog.GlobalLevel() + zerolog.SetGlobalLevel(zerolog.TraceLevel) + t.Cleanup(func() { zerolog.SetGlobalLevel(level) }) + + tests := []struct { + name string + message *sse.Event + expected string + }{ { - name: "BLSToExecutionChangeGood", - message: &sse.Event{ - Event: []byte("bls_to_execution_change"), - Data: []byte(`{"message":{"validator_index":"63401","from_bls_pubkey":"0xa46ed2574770ec1942d577ef89e0bf7b0d601349dab791740dead3fb5a6e2624cf62b9e58de1074c49f44b986eb39002","to_execution_address":"0xd641D2Cc74C7b6A641861260d07D67eB67bc7403"},"signature":"0xb9ce6f10137a8bc73cd5545e5d6c7c61bc1294b853e71e5eb090a7a773d7773c4d7732f4244e9891cd5c1f7a5fbc951c0b368a8bd7dc44b1598a6d1bdb4476f9f364ef521c366e55565792810f0cb8bf8cc20134f32ed8dde54507f622402d6e"}`), - }, - handler: handler, - handled: true, + name: "UnsupportedTopic", + message: &sse.Event{Event: []byte("unknown"), Data: []byte(`{}`)}, + expected: `{"level":"warn","topic":"unknown","message":"Received message with unhandled topic; ignoring"}`, }, { - name: "ContributionAndProofGood", - message: &sse.Event{ - Event: []byte("contribution_and_proof"), - Data: []byte(`{"message":{"aggregator_index":"355177","contribution":{"slot":"4095970","beacon_block_root":"0xfe17cc29bb937740eead4b84716751b00e361891dae7c8f0e98f4deb5f753cbc","subcommittee_index":"0","aggregation_bits":"0xffffffffffffffffffffdfffffffffff","signature":"0x926e8fbf2b8599f76a42e2dd02b954853d3841577a0c68303fb9a5690f7973e95454bc1df03118d53c80e3cf13dc33490f2516aefb4cb7766a724a10dd0536811ec43b7e5f08442ef7dbc072b4b484ea1acde78e5aae8d636b06dd18677c535b"},"selection_proof":"0x84c805b21a40315dc19ea89e6d64d8f5e913d5e003a813d74a23f16add72250791a950b7508e2ea69adab8169c2800b70d26fea4cdb51f1fb96d939baaa44567eb9e96d2021799478fd3f557326a62060215be95465fcc9c49f67dd8685a20bc"},"signature":"0x8093efce898e36cab5ab2b198a48046d029b36909a29ec33ca7075f389133288c4d7e13cf3e20396612050d4aebe9212154fd5a2be4bf356e6191600d65906d5c404bd46c95ae20fe4bc5e18c6e2808c97a4572f995bf90db8aaf3fd84fb87ac"}`), - }, - handler: handler, - handled: true, + name: "Malformed", + message: &sse.Event{Event: []byte("head"), Data: []byte(`{}`)}, + expected: `{"level":"error","error":"slot missing","topic":"head","data":{},"message":"Failed to parse event"}`, }, } - // Note: Rate limiting for internal tests would need to be implemented separately - // if needed. For now, this test runs without rate limiting. - var s client.Service - var err error - if os.Getenv("HTTP_BEARER_TOKEN") != "" { - s, err = New(ctx, - WithTimeout(timeout), - WithAddress(os.Getenv("HTTP_ADDRESS")), - WithExtraHeaders(map[string]string{"Authorization": fmt.Sprintf("Bearer %s", os.Getenv("HTTP_BEARER_TOKEN"))}), - ) - } else { - s, err = New(ctx, - WithTimeout(timeout), - WithAddress(os.Getenv("HTTP_ADDRESS")), - ) - } - require.NoError(t, err) - - h, isHTTPService := s.(*Service) - require.True(t, isHTTPService) - for _, test := range tests { t.Run(test.name, func(t *testing.T) { - handled = false - log := zerolog.New(&bytes.Buffer{}) - ctx = log.WithContext(ctx) - h.handleEvent(ctx, test.message, &api.EventsOpts{ - Handler: test.handler, - }) - require.Equal(t, test.handled, handled) + var output bytes.Buffer + ctx := zerolog.New(&output).WithContext(context.Background()) + + (&Service{}).handleEvent(ctx, test.message, &api.EventsOpts{Handler: func(*apiv1.Event) {}}) + + require.JSONEq(t, test.expected, output.String()) }) } } diff --git a/http/events_test.go b/http/events_test.go index 45b78dac..edb71d65 100644 --- a/http/events_test.go +++ b/http/events_test.go @@ -1,4 +1,4 @@ -// Copyright © 2020, 2021 Attestant Limited. +// Copyright © 2020 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -22,9 +22,35 @@ import ( client "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/api" apiv1 "github.com/attestantio/go-eth2-client/api/v1" + beaconhttp "github.com/attestantio/go-eth2-client/http" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/stretchr/testify/require" ) +func TestValidateEventsOptsNil(t *testing.T) { + require.ErrorIs(t, beaconhttp.ValidateEventsOpts(nil), client.ErrNoOptions) +} + +// TestValidateEventsOptsIgnoresSupportedEventTopics confirms that changing the exported +// apiv1.SupportedEventTopics map changes neither which topics are accepted nor which refused, as +// validation checks the topics the dispatcher supports. No test in the package runs in parallel, +// so changing the map here, and restoring it, races with no other. +func TestValidateEventsOptsIgnoresSupportedEventTopics(t *testing.T) { + apiv1.SupportedEventTopics["injected"] = true + delete(apiv1.SupportedEventTopics, "head") + t.Cleanup(func() { + delete(apiv1.SupportedEventTopics, "injected") + apiv1.SupportedEventTopics["head"] = true + }) + + handler := func(*apiv1.Event) {} + + require.EqualError(t, + beaconhttp.ValidateEventsOpts(&api.EventsOpts{Topics: []string{"injected"}, Handler: handler}), + "unsupported event topic injected: invalid options") + require.NoError(t, beaconhttp.ValidateEventsOpts(&api.EventsOpts{Topics: []string{"head"}, Handler: handler})) +} + func TestEvents(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -63,3 +89,138 @@ func TestEvents(t *testing.T) { }) } } + +// gloasEventTopics are the SSE topics introduced by the Gloas (ePBS) fork. +var gloasEventTopics = []string{ + "execution_payload", + "execution_payload_available", + "execution_payload_bid", + "execution_payload_gossip", + "fast_confirmation", + "payload_attestation_message", + "proposer_preferences", +} + +// TestEventsGloasTopics confirms the public Events() entry point accepts a +// subscription naming any of the Gloas event topics. +// +// Events() gates every requested topic on the topics the dispatcher supports, via +// ValidateEventsOpts, before it builds the subscription, so a topic the gate +// refuses never reaches the wire, however completely the dispatcher downstream +// would handle it. A test that calls handleEvent directly sits downstream of +// this check and so cannot exercise it. This test is the only one that checks +// the gate accepts the Gloas topics, and it needs a node. +// TestValidateEventsOptsIgnoresSupportedEventTopics checks, without one, only +// that the gate consults the dispatcher rather than SupportedEventTopics. +// +// Every topic is subscribed to with its own topic-specific handler, which must +// be accepted outright — that also pins each topic to the EventsOpts field +// the dispatcher binds to it, which a topic-agnostic generic handler would not. +// One topic is additionally subscribed to with no handler at all: "no handler +// for event" is only reachable past the supported-topic check, so it +// is direct evidence of clearing the gate rather than of merely getting a nil +// error afterwards. +func TestEventsGloasTopics(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + service := testService(ctx, t).(client.EventsProvider) + + tests := []struct { + name string + opts *api.EventsOpts + err string + }{ + { + // The gate's two rejections are near-identical in wording, so this + // asserts the exact message: "unsupported event topic X" is the + // supported-topic check's own return, while "no handler for X event" + // can only be reached once that check has passed. + name: "ExecutionPayloadNoHandler", + opts: &api.EventsOpts{Topics: []string{"execution_payload"}}, + err: "no handler for execution_payload event: invalid options", + }, + { + name: "ExecutionPayloadSpecificHandler", + opts: &api.EventsOpts{ + Topics: []string{"execution_payload"}, + ExecutionPayloadHandler: func(context.Context, *apiv1.ExecutionPayloadEvent) {}, + }, + }, + { + name: "ExecutionPayloadAvailableSpecificHandler", + opts: &api.EventsOpts{ + Topics: []string{"execution_payload_available"}, + ExecutionPayloadAvailableHandler: func(context.Context, *apiv1.ExecutionPayloadAvailableEvent) {}, + }, + }, + { + name: "ExecutionPayloadBidSpecificHandler", + opts: &api.EventsOpts{ + Topics: []string{"execution_payload_bid"}, + ExecutionPayloadBidHandler: func(context.Context, *gloas.SignedExecutionPayloadBid) {}, + }, + }, + { + name: "ExecutionPayloadGossipSpecificHandler", + opts: &api.EventsOpts{ + Topics: []string{"execution_payload_gossip"}, + ExecutionPayloadGossipHandler: func(context.Context, *apiv1.ExecutionPayloadGossipEvent) {}, + }, + }, + { + name: "FastConfirmationSpecificHandler", + opts: &api.EventsOpts{ + Topics: []string{"fast_confirmation"}, + FastConfirmationHandler: func(context.Context, *apiv1.FastConfirmationEvent) {}, + }, + }, + { + name: "PayloadAttestationMessageSpecificHandler", + opts: &api.EventsOpts{ + Topics: []string{"payload_attestation_message"}, + PayloadAttestationMessageHandler: func(context.Context, *gloas.PayloadAttestationMessage) {}, + }, + }, + { + name: "ProposerPreferencesSpecificHandler", + opts: &api.EventsOpts{ + Topics: []string{"proposer_preferences"}, + ProposerPreferencesHandler: func(context.Context, *gloas.SignedProposerPreferences) {}, + }, + }, + { + name: "AllGloasTopicsGenericHandler", + opts: &api.EventsOpts{ + Topics: gloasEventTopics, + Handler: func(*apiv1.Event) {}, + }, + }, + { + // Control: shows what a topic the dispatcher does not support looks + // like, so the assertions above cannot pass vacuously. + name: "UnknownTopic", + opts: &api.EventsOpts{ + Topics: []string{"not_an_event_topic"}, + Handler: func(*apiv1.Event) {}, + }, + err: "unsupported event topic not_an_event_topic: invalid options", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // A fresh context per subtest, so that any subscription Events() + // does accept is torn down as soon as the subtest ends. + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + err := service.Events(ctx, test.opts) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/http/executionpayloadenvelope.go b/http/executionpayloadenvelope.go new file mode 100644 index 00000000..5c87e1af --- /dev/null +++ b/http/executionpayloadenvelope.go @@ -0,0 +1,184 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "bytes" + "context" + "errors" + "fmt" + "maps" + "net/http" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "go.opentelemetry.io/otel" +) + +// ExecutionPayloadEnvelope obtains a cached execution payload envelope. +func (s *Service) ExecutionPayloadEnvelope(ctx context.Context, + opts *api.ExecutionPayloadEnvelopeOpts, +) ( + *api.Response[*spec.VersionedExecutionPayloadEnvelope], + error, +) { + ctx, span := otel.Tracer("attestantio.go-eth2-client.http").Start(ctx, "ExecutionPayloadEnvelope") + defer span.End() + + httpResponse, err := s.fetchExecutionPayloadEnvelope(ctx, opts) + if err != nil { + return nil, err + } + + response, err := s.executionPayloadEnvelopeFromResponse(ctx, httpResponse) + if err != nil { + return nil, err + } + + if err := assertEnvelopeIsForBlock(response.Data, opts.BeaconBlockRoot); err != nil { + return nil, err + } + + return response, nil +} + +// assertEnvelopeIsForBlock checks that an envelope belongs to the requested block. +func assertEnvelopeIsForBlock(envelope *spec.VersionedExecutionPayloadEnvelope, expected phase0.Root) error { + root, err := envelope.BeaconBlockRoot() + if err != nil { + return err + } + + if root != expected { + return errors.Join( + fmt.Errorf("execution payload envelope for block %#x; expected %#x", root, expected), + client.ErrInconsistentResult, + ) + } + + return nil +} + +// fetchExecutionPayloadEnvelope validates the options and performs the GET, +// translating the endpoint's defined 404 into a sentinel. +func (s *Service) fetchExecutionPayloadEnvelope(ctx context.Context, + opts *api.ExecutionPayloadEnvelopeOpts, +) (*httpResponse, error) { + if err := s.assertIsSynced(ctx); err != nil { + return nil, err + } + + if opts == nil { + return nil, client.ErrNoOptions + } + + if opts.Slot == 0 { + return nil, errors.Join(errors.New("no slot specified"), client.ErrInvalidOptions) + } + + if opts.BeaconBlockRoot.IsZero() { + return nil, errors.Join(errors.New("no beacon block root specified"), client.ErrInvalidOptions) + } + + endpoint := fmt.Sprintf("/eth/v1/validator/execution_payload_envelopes/%d/%#x", opts.Slot, opts.BeaconBlockRoot) + + httpResponse, err := s.getWithResponseLimit(ctx, endpoint, "", &opts.Common, true, maxEPBSResponseSize) + if err != nil { + return nil, notFoundToSentinel(err) + } + + return httpResponse, nil +} + +// notFoundToSentinel preserves a 404 API error while adding the cache-miss sentinel. +func notFoundToSentinel(err error) error { + var apiErr *api.Error + if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound { + return errors.Join(client.ErrNoExecutionPayloadEnvelope, apiErr) + } + + return err +} + +// executionPayloadEnvelopeFromResponse decodes a fetched envelope response. It +// is separate from the fetch so that both encodings stay testable without a +// node holding a cached envelope, which it will only do for one slot at a time. +func (s *Service) executionPayloadEnvelopeFromResponse(ctx context.Context, + res *httpResponse, +) ( + *api.Response[*spec.VersionedExecutionPayloadEnvelope], + error, +) { + if res.consensusVersion != spec.DataVersionGloas { + return nil, fmt.Errorf("execution payload envelope not available for version %s", res.consensusVersion) + } + + // The envelope is a gloas-onwards container, so the wire bytes always parse + // into *gloas.ExecutionPayloadEnvelope. + envelope := &gloas.ExecutionPayloadEnvelope{} + metadata := metadataFromHeaders(res.headers) + + switch res.contentType { + case ContentTypeSSZ: + // Decoded through the request-scoped codec, as the sibling beacon-side + // envelope endpoint does. Measured against a minimal-preset node this + // container encodes byte-identically to mainnet, since nothing in its + // fixed part is preset-derived, so the generated codec would read it too. + // The dynamic one is kept because it cannot be wrong for a preset this + // code has not been run against, and the endpoint is called at most once + // per proposal, so the codec build does not sit in a hot path. + ds, err := s.dynSSZForRequest(ctx) + if err != nil { + return nil, err + } + + if err := ds.UnmarshalSSZ(envelope, res.body); err != nil { + return nil, errors.Join( + fmt.Errorf("failed to decode %s SSZ execution payload envelope", res.consensusVersion), + err, + ) + } + case ContentTypeJSON: + // Seeded with a typed nil pointer so that a body with no data key in it + // is distinguishable from one carrying a zero-valued envelope. + decoded, jsonMetadata, err := decodeJSONResponse(bytes.NewReader(res.body), (*gloas.ExecutionPayloadEnvelope)(nil)) + if err != nil { + return nil, errors.Join( + fmt.Errorf("failed to decode %s JSON execution payload envelope", res.consensusVersion), + err, + ) + } + + if decoded == nil { + return nil, fmt.Errorf("no %s execution payload envelope in response", res.consensusVersion) + } + + envelope = decoded + + maps.Copy(metadata, jsonMetadata) + default: + return nil, fmt.Errorf("unhandled content type %v", res.contentType) + } + + return &api.Response[*spec.VersionedExecutionPayloadEnvelope]{ + Data: &spec.VersionedExecutionPayloadEnvelope{ + Version: res.consensusVersion, + Gloas: envelope, + }, + Metadata: metadata, + }, nil +} diff --git a/http/executionpayloadenvelope_internal_test.go b/http/executionpayloadenvelope_internal_test.go new file mode 100644 index 00000000..cb1e3523 --- /dev/null +++ b/http/executionpayloadenvelope_internal_test.go @@ -0,0 +1,203 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" +) + +// validExecutionPayloadEnvelope returns an envelope populated far enough for its +// own marshalers to run: BaseFeePerGas is dereferenced by the payload's. +func validExecutionPayloadEnvelope() *gloas.ExecutionPayloadEnvelope { + return &gloas.ExecutionPayloadEnvelope{ + Payload: &gloas.ExecutionPayload{BaseFeePerGas: uint256.NewInt(7)}, + ExecutionRequests: &gloas.ExecutionRequests{}, + BuilderIndex: 12, + BeaconBlockRoot: phase0.Root{0x0a, 0x0b}, + ParentBeaconBlockRoot: phase0.Root{0x0c, 0x0d}, + } +} + +// TestExecutionPayloadEnvelopeFromResponse covers the decode half of the +// endpoint. A node caches an envelope for one slot at a time, so the success +// path cannot be provoked on demand against a live node; this is where it, the +// SSZ body and the error paths are exercised. +func TestExecutionPayloadEnvelopeFromResponse(t *testing.T) { + ctx := context.Background() + s := &Service{} + + envelope := validExecutionPayloadEnvelope() + + envelopeJSON, err := json.Marshal(envelope) + require.NoError(t, err) + + jsonBody := fmt.Appendf(nil, `{"version":"gloas","data":%s}`, envelopeJSON) + + t.Run("JSON", func(t *testing.T) { + response, err := s.executionPayloadEnvelopeFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionGloas, + body: jsonBody, + headers: map[string]string{}, + }) + require.NoError(t, err) + require.Equal(t, spec.DataVersionGloas, response.Data.Version) + require.False(t, response.Data.IsEmpty()) + + builderIndex, err := response.Data.BuilderIndex() + require.NoError(t, err) + require.Equal(t, gloas.BuilderIndex(12), builderIndex) + + root, err := response.Data.BeaconBlockRoot() + require.NoError(t, err) + require.Equal(t, phase0.Root{0x0a, 0x0b}, root) + + payload, err := response.Data.Payload() + require.NoError(t, err) + require.Equal(t, uint256.NewInt(7), payload.BaseFeePerGas) + }) + + t.Run("SSZ", func(t *testing.T) { + sszBody, err := envelope.MarshalSSZ() + require.NoError(t, err) + + response, err := s.executionPayloadEnvelopeFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeSSZ, + consensusVersion: spec.DataVersionGloas, + body: sszBody, + headers: map[string]string{}, + }) + require.NoError(t, err) + + // SSZ carries no field names, so a wrongly ordered codec shows up as a + // differing hash tree root rather than a decode error. + want, err := envelope.HashTreeRoot() + require.NoError(t, err) + got, err := response.Data.Gloas.HashTreeRoot() + require.NoError(t, err) + require.Equal(t, want, got) + }) + + // An envelope without its payload is not a usable envelope: the whole point + // of retrieving one is to publish the payload it carries. The spec marks the + // field required, and the container's own decoder enforces it, which matters + // because a node still on the removed blinded-envelope schema sends a + // payload_root in its place and encoding/json would otherwise ignore the + // unrecognised key and hand back an envelope with no payload at all. + t.Run("JSONWithoutPayload", func(t *testing.T) { + blinded := []byte(`{"version":"gloas","data":{` + + `"payload_root":"0xfd2b34f63745b836c3ce3153439315284de96536d78ceac0f62408c9b1eebfd1",` + + `"execution_requests":{"deposits":[],"withdrawals":[],"consolidations":[],` + + `"builder_deposits":[],"builder_exits":[]},` + + `"builder_index":"18446744073709551615",` + + `"beacon_block_root":"0xea5d1f1b8bd38f4c8f133689dcfc0d333bf5fcf96d791beffef6023cd5146860",` + + `"parent_beacon_block_root":"0x60ee9ee6812eaa4b111e3d37f777eda3b3baf5b530afe7e4c1a34dcf31f156fc"}}`) + + _, err := s.executionPayloadEnvelopeFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionGloas, + body: blinded, + headers: map[string]string{}, + }) + require.ErrorContains(t, err, "failed to decode gloas JSON execution payload envelope") + require.ErrorContains(t, err, "payload missing") + }) + + t.Run("PreGloasVersion", func(t *testing.T) { + _, err := s.executionPayloadEnvelopeFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionFulu, + body: jsonBody, + headers: map[string]string{}, + }) + require.ErrorContains(t, err, "execution payload envelope not available for version fulu") + }) + + t.Run("UnknownContentType", func(t *testing.T) { + _, err := s.executionPayloadEnvelopeFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeUnknown, + consensusVersion: spec.DataVersionGloas, + body: jsonBody, + headers: map[string]string{}, + }) + require.ErrorContains(t, err, "unhandled content type") + }) + + // The decoder writes only the keys it finds, so a body with no data key at + // all leaves the seeded nil pointer untouched. Returning success there would + // hand back a wrapper whose every accessor errors. + t.Run("NoDatum", func(t *testing.T) { + for _, body := range []string{ + `{"version":"gloas"}`, + `{"version":"gloas","data":null}`, + } { + _, err := s.executionPayloadEnvelopeFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionGloas, + body: []byte(body), + headers: map[string]string{}, + }) + require.ErrorContains(t, err, "no gloas execution payload envelope in response") + } + }) + + // The block root is the only field the request and the response have in + // common, and it is what binds the envelope to the block the caller proposed. + // A node answering with a different one hands back an envelope that cannot be + // published, so it is refused rather than passed on. + t.Run("BlockRootMismatch", func(t *testing.T) { + wrapped := &spec.VersionedExecutionPayloadEnvelope{ + Version: spec.DataVersionGloas, + Gloas: validExecutionPayloadEnvelope(), + } + + // The fixture's own root, so the matching case must be accepted. + require.NoError(t, assertEnvelopeIsForBlock(wrapped, phase0.Root{0x0a, 0x0b})) + + err := assertEnvelopeIsForBlock(wrapped, phase0.Root{0xff, 0xee}) + require.ErrorIs(t, err, client.ErrInconsistentResult) + require.ErrorContains(t, err, "execution payload envelope for block") + }) + + t.Run("CorruptSSZ", func(t *testing.T) { + sszBody, err := envelope.MarshalSSZ() + require.NoError(t, err) + + _, err = s.executionPayloadEnvelopeFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeSSZ, + consensusVersion: spec.DataVersionGloas, + body: sszBody[:len(sszBody)-1], + headers: map[string]string{}, + }) + require.ErrorContains(t, err, "failed to decode gloas SSZ execution payload envelope") + }) +} diff --git a/http/executionpayloadenvelope_sentinel_internal_test.go b/http/executionpayloadenvelope_sentinel_internal_test.go new file mode 100644 index 00000000..a29ff67b --- /dev/null +++ b/http/executionpayloadenvelope_sentinel_internal_test.go @@ -0,0 +1,56 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "net/http" + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/stretchr/testify/require" +) + +// TestExecutionPayloadEnvelopeSentinel verifies a cache-miss 404 keeps its API error. +func TestExecutionPayloadEnvelopeSentinel(t *testing.T) { + notFound := &api.Error{ + Method: http.MethodGet, + StatusCode: http.StatusNotFound, + Endpoint: "/eth/v1/validator/execution_payload_envelopes/1/0x00", + Data: []byte(`{"message":"execution payload envelope not found for slot 1","code":404}`), + } + + err := notFoundToSentinel(notFound) + + // What a caller matches on. + require.ErrorIs(t, err, client.ErrNoExecutionPayloadEnvelope) + + // What a multi-client's failover matches on. + var apiErr *api.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusNotFound, apiErr.StatusCode) + require.Equal(t, 4, statusCodeFamily(apiErr.StatusCode), + "a 4xx is what stops a multi-client treating this as the node failing") + + // And the body survives, which is the only way to tell an empty cache apart + // from a node that does not serve this route at all — both answer 404. + require.Contains(t, string(apiErr.Data), "not found for slot") + + // Any other status is not this endpoint's defined outcome and must stay a + // plain error, so a real fault is not reported as an empty cache. + for _, statusCode := range []int{http.StatusBadRequest, http.StatusForbidden, http.StatusInternalServerError} { + other := notFoundToSentinel(&api.Error{StatusCode: statusCode}) + require.NotErrorIs(t, other, client.ErrNoExecutionPayloadEnvelope) + } +} diff --git a/http/executionpayloadenvelope_test.go b/http/executionpayloadenvelope_test.go new file mode 100644 index 00000000..726f0f47 --- /dev/null +++ b/http/executionpayloadenvelope_test.go @@ -0,0 +1,123 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http_test + +import ( + "context" + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/attestantio/go-eth2-client/testclients" + "github.com/stretchr/testify/require" +) + +// TestExecutionPayloadEnvelope exercises the validator-side envelope retrieval +// against a live node. This is how a caller that asked for a proposal with the +// payload excluded gets the envelope it needs to publish, so the node only holds +// one: the envelope it built for the slot it is proposing. +func TestExecutionPayloadEnvelope(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + service := testService(ctx, t).(client.Service) + provider := service.(client.ExecutionPayloadEnvelopeProvider) + + t.Run("NilOpts", func(t *testing.T) { + _, err := provider.ExecutionPayloadEnvelope(ctx, nil) + require.ErrorIs(t, err, client.ErrNoOptions) + }) + + t.Run("NoSlot", func(t *testing.T) { + _, err := provider.ExecutionPayloadEnvelope(ctx, &api.ExecutionPayloadEnvelopeOpts{ + BeaconBlockRoot: phase0.Root{0x01}, + }) + require.ErrorIs(t, err, client.ErrInvalidOptions) + require.ErrorContains(t, err, "no slot specified") + }) + + // The block root is what makes the lookup re-org resistant, so an unset one + // would ask the node to hand over whatever it happens to hold. + t.Run("NoBeaconBlockRoot", func(t *testing.T) { + _, err := provider.ExecutionPayloadEnvelope(ctx, &api.ExecutionPayloadEnvelopeOpts{Slot: 1}) + require.ErrorIs(t, err, client.ErrInvalidOptions) + require.ErrorContains(t, err, "no beacon block root specified") + }) + + // A node caches only the current slot's locally built envelope, so a slot + // already behind it holds nothing. That is a defined outcome rather than a + // failure, and it surfaces as a sentinel: reported as a raw transport error, + // a caller could not tell "this node did not build that block" — the re-org + // and wrong-node cases, which are recoverable — apart from a real fault. + t.Run("PastSlotIsSentinel", func(t *testing.T) { + // Gated although it passes on a pre-Gloas node, because it passes there + // for the wrong reason: that node has no such endpoint, and its 404 maps + // to the same sentinel this asserts. What is meant to be under test is a + // Gloas node reporting a cache miss, which needs a gloas head. + requireOnGloas(ctx, t, service) + + // Far enough back to be certain nothing is cached, and still comfortably + // after the fork, which the node rejects separately. Slot is unsigned, so + // a head below the offset would wrap to a slot in the far future and be + // refused for an entirely different reason. + head := headSlot(ctx, t, service) + if head <= 100 { + t.Skipf("chain is too young to have a past slot (head is %d)", head) + } + slot := head - 100 + + _, err := provider.ExecutionPayloadEnvelope(ctx, &api.ExecutionPayloadEnvelopeOpts{ + Slot: slot, + BeaconBlockRoot: phase0.Root{0x01}, + }) + require.ErrorIs(t, err, client.ErrNoExecutionPayloadEnvelope) + }) + + // A pre-Gloas slot is a different refusal, and it must not be folded into the + // sentinel: asking for an envelope before the fork that introduced envelopes + // is a caller mistake, not a cache miss to be retried elsewhere. + t.Run("PreGloasSlotIsNotSentinel", func(t *testing.T) { + // The weaker gate, and deliberately so: this is a test of pre-Gloas + // behaviour, whose only precondition is that an endpoint exists to do the + // refusing. Gating it on the head's fork instead would keep it dark on a + // mainnet-lineage node forever, when it should start running there the day + // that node's client learns Gloas — years before the fork activates. + requireKnowsGloas(ctx, t, service) + + _, err := provider.ExecutionPayloadEnvelope(ctx, &api.ExecutionPayloadEnvelopeOpts{ + Slot: 1, + BeaconBlockRoot: phase0.Root{0x01}, + }) + require.Error(t, err) + + var apiErr *api.Error + require.ErrorAs(t, err, &apiErr) + + // Knowing the fork turns out not to imply serving its endpoints. The + // regression endpoint publishes GLOAS_FORK_EPOCH already and has no route + // behind it, so every request here is a bare 404 with no pre-fork refusal + // inside it to inspect, and nothing for this test to assert. + // + // The escape is withheld once the chain is on Gloas, deliberately: there a + // 404 for a pre-fork slot is the defect this test exists to catch — it is + // what maps onto the sentinel — rather than a route that is not there yet. + if apiErr.StatusCode == 404 && !testclients.OnGloas(ctx, service) { + gate(t, "node knows gloas but does not serve the envelope endpoint (404)") + } + + require.NotErrorIs(t, err, client.ErrNoExecutionPayloadEnvelope) + require.Equal(t, 400, apiErr.StatusCode) + }) +} diff --git a/http/gloasgate_test.go b/http/gloasgate_test.go new file mode 100644 index 00000000..9f37f7a4 --- /dev/null +++ b/http/gloasgate_test.go @@ -0,0 +1,92 @@ +package http_test + +import ( + "context" + "fmt" + "testing" + + "github.com/attestantio/go-eth2-client/testclients" + "github.com/stretchr/testify/require" +) + +type gateT interface { + Helper() + Skip(args ...any) + Fatalf(format string, args ...any) +} + +func requireKnowsGloas(ctx context.Context, t gateT, service any) { + t.Helper() + if testclients.KnowsGloas(ctx, service) { + return + } + // KnowsGloas cannot tell a node without the fork from a node it failed to + // ask, so the reason must not claim the former. + gate(t, "node does not report knowing gloas (no GLOAS_FORK_EPOCH in its spec, or the spec could not be read)") +} + +func requireOnGloas(ctx context.Context, t gateT, service any) { + t.Helper() + if testclients.OnGloas(ctx, service) { + return + } + gate(t, fmt.Sprintf("chain is not on gloas (head is %s)", testclients.HeadVersion(ctx, service))) +} + +func gate(t gateT, reason string) { + t.Helper() + if requireGloas { + t.Fatalf("HTTP_REQUIRE_GLOAS is set, so this test must not be skipped: %s", reason) + return + } + t.Skip(reason) +} + +type recordingT struct { + skipped string + failed string +} + +func (r *recordingT) Helper() {} +func (r *recordingT) Skip(args ...any) { r.skipped = fmt.Sprint(args...) } +func (r *recordingT) Fatalf(format string, args ...any) { r.failed = fmt.Sprintf(format, args...) } + +func TestGateSkipsOrFails(t *testing.T) { + tests := []struct { + name string + require bool + skipped string + failed string + }{ + {name: "SkipsWhenNotRequired", skipped: "chain is not on gloas (head is fulu)"}, + {name: "FailsWhenRequired", require: true, failed: "HTTP_REQUIRE_GLOAS is set, so this test must not be skipped: chain is not on gloas (head is fulu)"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + old := requireGloas + t.Cleanup(func() { requireGloas = old }) + requireGloas = test.require + r := &recordingT{} + gate(r, "chain is not on gloas (head is fulu)") + require.Equal(t, test.skipped, r.skipped) + require.Equal(t, test.failed, r.failed) + }) + } +} + +func TestGloasGatesNameTheirLevel(t *testing.T) { + old := requireGloas + t.Cleanup(func() { requireGloas = old }) + requireGloas = false + ctx := context.Background() + + knows := &recordingT{} + requireKnowsGloas(ctx, knows, "not a service") + require.Equal(t, + "node does not report knowing gloas (no GLOAS_FORK_EPOCH in its spec, or the spec could not be read)", + knows.skipped) + + on := &recordingT{} + requireOnGloas(ctx, on, "not a service") + require.Equal(t, "chain is not on gloas (head is unknown)", on.skipped) +} diff --git a/http/http.go b/http/http.go index 7b887df6..a8936f39 100644 --- a/http/http.go +++ b/http/http.go @@ -48,6 +48,22 @@ func (s *Service) post(ctx context.Context, ) ( *httpResponse, error, +) { + return s.postWithResponseLimit(ctx, endpoint, query, opts, body, contentType, headers, maxEPBSResponseSize) +} + +// postWithResponseLimit sends an HTTP POST request and bounds its response body when requested. +func (s *Service) postWithResponseLimit(ctx context.Context, + endpoint string, + query string, + opts *api.CommonOpts, + body io.Reader, + contentType ContentType, + headers map[string]string, + responseLimit int, +) ( + *httpResponse, + error, ) { ctx, span := otel.Tracer("attestantio.go-eth2-client.http").Start(ctx, "post") defer span.End() @@ -89,7 +105,7 @@ func (s *Service) post(ctx context.Context, s.addExtraHeaders(req) req.Header.Set("Content-Type", contentType.MediaType()) - // Always take response of POST in JSON, as it's generally small. + // POST defaults to JSON responses; endpoint headers may negotiate another encoding. req.Header.Set("Accept", "application/json") for k, v := range headers { @@ -126,7 +142,7 @@ func (s *Service) post(ctx context.Context, } populateHeaders(res, resp) - res.body, err = io.ReadAll(resp.Body) + res.body, err = readResponseBody(resp.Body, responseLimit) if err != nil { switch { case errors.Is(err, context.Canceled): @@ -227,9 +243,23 @@ type httpResponse struct { body []byte } +func readResponseBody(body io.Reader, limit int) ([]byte, error) { + if limit <= 0 { + return io.ReadAll(body) + } + + data, err := io.ReadAll(io.LimitReader(body, int64(limit)+1)) + if err != nil { + return nil, err + } + if len(data) > limit { + return nil, fmt.Errorf("response body exceeds %d bytes", limit) + } + + return data, nil +} + // get sends an HTTP get request and returns the response. -// -//nolint:revive func (s *Service) get(ctx context.Context, endpoint string, query string, @@ -238,6 +268,20 @@ func (s *Service) get(ctx context.Context, ) ( *httpResponse, error, +) { + return s.getWithResponseLimit(ctx, endpoint, query, opts, supportsSSZ, 0) +} + +//nolint:revive +func (s *Service) getWithResponseLimit(ctx context.Context, + endpoint string, + query string, + opts *api.CommonOpts, + supportsSSZ bool, + responseLimit int, +) ( + *httpResponse, + error, ) { ctx, span := otel.Tracer("attestantio.go-eth2-client.http").Start(ctx, "get") defer span.End() @@ -308,7 +352,7 @@ func (s *Service) get(ctx context.Context, // require the calling function to be aware that it needs to close the body // once it is done with it. To avoid that complexity, we read here and store the // body as a byte array. - res.body, err = io.ReadAll(resp.Body) + res.body, err = readResponseBody(resp.Body, responseLimit) if err != nil { switch { case errors.Is(err, context.Canceled): diff --git a/http/main_internal_test.go b/http/main_internal_test.go new file mode 100644 index 00000000..a3248083 --- /dev/null +++ b/http/main_internal_test.go @@ -0,0 +1,162 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "context" + "fmt" + "net/http" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// timeout for tests. +var timeout = 60 * time.Second + +// newTestService creates a service against HTTP_ADDRESS for this package's internal +// tests, authenticating with HTTP_BEARER_TOKEN when one is set. It is the package http +// counterpart of newTestService in main_test.go, which package http cannot reach: the +// two are separate packages, so every internal test that wants a live service has to +// build one for itself, and this is the one place that does it. +// +// It returns *Service rather than client.Service because an internal test invariably +// wants the concrete type, and asserting for it at each call site established nothing. +// customSpecSupport picks which branch the SSZ codecs take. +// +// params are applied after the defaults, so a caller can override any of them — with +// one trap worth naming, since it is this helper's own defect in miniature: +// WithExtraHeaders replaces the header map rather than merging into it, so a caller +// passing its own would drop the Authorization header again. Nothing does today. +// +// The call sites this replaced were every one package http had, from gopls references +// on New: epbsguardwiring_internal_test.go (disagreeingService), +// epbsproposal_internal_test.go (customSpecService) and events_internal_test.go +// (twice, one arm per bearer-token branch). Two of the four omitted the token. The +// same query now answers with a single hit in package http, the New call below. +func newTestService(ctx context.Context, + t *testing.T, + customSpecSupport bool, + params ...Parameter, +) *Service { + t.Helper() + + address := os.Getenv("HTTP_ADDRESS") + if address == "" { + // Belt only, and worth knowing it is: the one TestMain governing both + // packages lives in main_test.go and returns without running anything at + // all when this is unset, so nothing currently reaches this skip. It + // starts mattering if that is ever fixed. + t.Skip("HTTP_ADDRESS not set") + } + + parameters := []Parameter{ + WithTimeout(timeout), + WithAddress(address), + WithCustomSpecSupport(customSpecSupport), + } + + if token := os.Getenv("HTTP_BEARER_TOKEN"); token != "" { + parameters = append(parameters, WithExtraHeaders(map[string]string{ + "Authorization": fmt.Sprintf("Bearer %s", token), + })) + } + + service, err := New(ctx, append(parameters, params...)...) + require.NoError(t, err) + + return service.(*Service) +} + +// TestInternalTestServicesAuthenticate pins the one thing every service this +// package's tests build against the live node must do: send the bearer token. +// +// It is asserted per constructor rather than once on the shared builder because +// what broke was a call site, not the builder — newTestService above lists which +// ones and why. A missing token fails only against a node that requires auth, and +// then only as "client is not active", which names nothing. +// +// The rows are therefore redundant by design while all three constructors route +// through newTestService, since they exercise one header assembly between them. +// They stop being redundant the moment one drifts back off it, which is the whole +// regression here: re-hand-rolling customSpecService fails its own row and leaves +// the other two green. Do not collapse them into a single assertion on the +// builder — that is the test this bug already got past. +func TestInternalTestServicesAuthenticate(t *testing.T) { + tests := []struct { + name string + build func(context.Context, *testing.T) *Service + }{ + { + // Called directly, as the event-handler test does. + name: "NewTestService", + build: func(ctx context.Context, t *testing.T) *Service { + return newTestService(ctx, t, false) + }, + }, + { + name: "CustomSpecService", + build: customSpecService, + }, + { + // Its handler never runs here; only the service it hands back is + // under inspection. + name: "DisagreeingService", + build: func(ctx context.Context, t *testing.T) *Service { + return disagreeingService(ctx, t, "/never-requested", func(http.ResponseWriter, *http.Request) {}) + }, + }, + } + + // An ambient token is used as-is rather than overridden: against an + // authenticated node a token of this test's own invention would be rejected + // by New()'s ping, so the assertion would fail for the wrong reason. One is + // synthesised only when the suite is running unauthenticated, where the node + // ignores it. + token := os.Getenv("HTTP_BEARER_TOKEN") + if token == "" { + token = "token-of-this-test's-own" + t.Setenv("HTTP_BEARER_TOKEN", token) + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + s := test.build(ctx, t) + + require.Equal(t, "Bearer "+token, s.extraHeaders["Authorization"]) + }) + } +} + +// TestNewTestServiceOmitsAnAbsentBearerToken guards the other arm: no token in the +// environment must mean no Authorization header at all, not a bare "Bearer ". +// +// No node takes part — a closed port plus WithAllowDelayedStart is enough, and it +// avoids emptying HTTP_BEARER_TOKEN against a node that would reject the ping. +func TestNewTestServiceOmitsAnAbsentBearerToken(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + t.Setenv("HTTP_ADDRESS", "http://127.0.0.1:1") + t.Setenv("HTTP_BEARER_TOKEN", "") + + s := newTestService(ctx, t, false, WithAllowDelayedStart(true)) + + require.NotContains(t, s.extraHeaders, "Authorization") +} diff --git a/http/main_test.go b/http/main_test.go index ac84cebf..34c7e84d 100644 --- a/http/main_test.go +++ b/http/main_test.go @@ -24,6 +24,7 @@ import ( "time" client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" "github.com/attestantio/go-eth2-client/http" "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/rs/zerolog" @@ -40,6 +41,8 @@ var globalHTTPService interface{} // This is configured via HTTP_TEST_CONCURRENCY (default: 1 for sequential execution). var testCoordinator *semaphore.Weighted +var requireGloas bool + func TestMain(m *testing.M) { if logLevel := os.Getenv("HTTP_DEBUG_LOG_ENABLED"); strings.ToLower(logLevel) == "true" { zerolog.SetGlobalLevel(zerolog.DebugLevel) @@ -57,12 +60,39 @@ func TestMain(m *testing.M) { } } testCoordinator = semaphore.NewWeighted(concurrency) + requireGloas = strings.EqualFold(os.Getenv("HTTP_REQUIRE_GLOAS"), "true") if os.Getenv("HTTP_ADDRESS") != "" { // Initialize global HTTP service for all tests to share initGlobalHTTPService() - os.Exit(m.Run()) } + + os.Exit(m.Run()) +} + +func newTestService(ctx context.Context, customSpecSupport bool, params ...http.Parameter) (client.Service, error) { + parameters := []http.Parameter{ + http.WithTimeout(timeout), + http.WithAddress(os.Getenv("HTTP_ADDRESS")), + http.WithCustomSpecSupport(customSpecSupport), + } + + if token := os.Getenv("HTTP_BEARER_TOKEN"); token != "" { + parameters = append(parameters, http.WithExtraHeaders(map[string]string{ + "Authorization": fmt.Sprintf("Bearer %s", token), + })) + } + + return http.New(ctx, append(parameters, params...)...) +} + +func headSlot(ctx context.Context, t *testing.T, service client.Service) phase0.Slot { + t.Helper() + response, err := service.(client.NodeSyncingProvider).NodeSyncing(ctx, &api.NodeSyncingOpts{}) + if err != nil { + t.Fatalf("failed to fetch head slot: %v", err) + } + return response.Data.HeadSlot } // initGlobalHTTPService creates a single HTTP service instance that all tests will share. @@ -73,31 +103,9 @@ func initGlobalHTTPService() { } ctx := context.Background() - var service client.Service - var err error - if os.Getenv("HTTP_BEARER_TOKEN") != "" { - service, err = http.New(ctx, - http.WithTimeout(timeout), - http.WithAddress(os.Getenv("HTTP_ADDRESS")), - http.WithAllowDelayedStart(true), - http.WithExtraHeaders(map[string]string{ - "Authorization": fmt.Sprintf("Bearer %s", os.Getenv("HTTP_BEARER_TOKEN")), - }), - ) - } else { - service, err = http.New(ctx, - http.WithTimeout(timeout), - http.WithAddress(os.Getenv("HTTP_ADDRESS")), - http.WithAllowDelayedStart(true), - ) - } - - if err != nil { - // If we can't create the service, tests will fail anyway - // Just log and continue - individual tests will handle the error - return + if service, err := newTestService(ctx, false, http.WithAllowDelayedStart(true)); err == nil { + globalHTTPService = service } - globalHTTPService = service } // testService returns an HTTP service for testing. @@ -108,6 +116,10 @@ func initGlobalHTTPService() { // test execution, preventing endpoint overload. The semaphore is automatically // released when the test completes via t.Cleanup(). func testService(ctx context.Context, t *testing.T) any { + if os.Getenv("HTTP_ADDRESS") == "" { + t.Skip("HTTP_ADDRESS not set") + } + // Acquire test coordinator semaphore to limit concurrent tests if testCoordinator != nil { if err := testCoordinator.Acquire(ctx, 1); err != nil { @@ -124,21 +136,7 @@ func testService(ctx context.Context, t *testing.T) any { } // Fallback: create a new service if global service is not available - var service client.Service - var err error - if os.Getenv("HTTP_BEARER_TOKEN") != "" { - service, err = http.New(ctx, - http.WithTimeout(timeout), - http.WithAddress(os.Getenv("HTTP_ADDRESS")), - http.WithExtraHeaders(map[string]string{"Authorization": fmt.Sprintf("Bearer %s", os.Getenv("HTTP_BEARER_TOKEN"))}), - ) - } else { - service, err = http.New(ctx, - http.WithTimeout(timeout), - http.WithAddress(os.Getenv("HTTP_ADDRESS")), - ) - } - + service, err := newTestService(ctx, false) if err != nil { t.Fatalf("Failed to create HTTP service: %v", err) } diff --git a/http/marshalrequestbody.go b/http/marshalrequestbody.go new file mode 100644 index 00000000..b4f583cf --- /dev/null +++ b/http/marshalrequestbody.go @@ -0,0 +1,60 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "context" + "encoding/json" + "errors" +) + +// marshalRequestBody marshals a request body to the negotiated content type: +// SSZ, using the dynamic codec for the service's spec, unless JSON is enforced. +// It returns the encoded body and the content type to declare for it, or +// ContentTypeUnknown alongside any error. +func (s *Service) marshalRequestBody(ctx context.Context, value any) ([]byte, ContentType, error) { + if s.enforceJSON { + body, err := json.Marshal(value) + if err != nil { + return nil, ContentTypeUnknown, errors.Join(errors.New("failed to marshal JSON"), err) + } + + return body, ContentTypeJSON, nil + } + if !s.customSpecSupport { + marshaler, isSSZMarshaler := value.(interface{ MarshalSSZ() ([]byte, error) }) + if !isSSZMarshaler { + return nil, ContentTypeUnknown, errors.New("failed to marshal SSZ: value does not support static SSZ marshaling") + } + + body, err := marshaler.MarshalSSZ() + if err != nil { + return nil, ContentTypeUnknown, errors.Join(errors.New("failed to marshal SSZ"), err) + } + + return body, ContentTypeSSZ, nil + } + + ds, err := s.dynSSZForRequest(ctx) + if err != nil { + return nil, ContentTypeUnknown, err + } + + body, err := ds.MarshalSSZ(value) + if err != nil { + return nil, ContentTypeUnknown, errors.Join(errors.New("failed to marshal SSZ"), err) + } + + return body, ContentTypeSSZ, nil +} diff --git a/http/marshalrequestbody_internal_test.go b/http/marshalrequestbody_internal_test.go new file mode 100644 index 00000000..b50187d6 --- /dev/null +++ b/http/marshalrequestbody_internal_test.go @@ -0,0 +1,86 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "context" + "testing" + + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/stretchr/testify/require" +) + +// TestMarshalRequestBody pins the request-body content-type negotiation shared +// by the ePBS submitters: SSZ by default, JSON when enforced, and the error +// wrapping of each arm. +// +// This needs no beacon node. The negotiation is pure logic given a Service: +// with customSpecSupport false, dynSSZForRequest returns the process-global +// codec and makes no network call, and neither arm touches any other field. +func TestMarshalRequestBody(t *testing.T) { + ctx := context.Background() + + bid := &gloas.SignedExecutionPayloadBid{ + Message: &gloas.ExecutionPayloadBid{}, + } + + // Expected SSZ from the type's own generated static codec: fastssz, a + // different implementation from the reflective dynssz codec under test. + wantSSZ, err := bid.MarshalSSZ() + require.NoError(t, err) + + t.Run("SSZByDefault", func(t *testing.T) { + s := &Service{} + + body, contentType, err := s.marshalRequestBody(ctx, bid) + require.NoError(t, err) + require.Equal(t, ContentTypeSSZ, contentType) + require.Equal(t, wantSSZ, body) + }) + + t.Run("JSONWhenEnforced", func(t *testing.T) { + s := &Service{enforceJSON: true} + + body, contentType, err := s.marshalRequestBody(ctx, bid) + require.NoError(t, err) + require.Equal(t, ContentTypeJSON, contentType) + + // JSONEq rather than a decode-and-deep-equal round trip: this type's + // JSON codec normalises a nil BlobKZGCommitments to an empty slice, so + // a round trip would fail on the codec, not on the negotiation. + wantJSON, err := bid.MarshalJSON() + require.NoError(t, err) + require.JSONEq(t, string(wantJSON), string(body)) + }) + + t.Run("JSONMarshalFailure", func(t *testing.T) { + s := &Service{enforceJSON: true} + + body, contentType, err := s.marshalRequestBody(ctx, make(chan int)) + require.EqualError(t, err, "failed to marshal JSON\njson: unsupported type: chan int") + require.Nil(t, body) + require.Equal(t, ContentTypeUnknown, contentType) + }) + + t.Run("SSZMarshalFailure", func(t *testing.T) { + s := &Service{} + + body, contentType, err := s.marshalRequestBody(ctx, make(chan int)) + // ErrorContains, not EqualError: the text past our own wrapper comes + // from dynssz, whose wording this package does not own. + require.ErrorContains(t, err, "failed to marshal SSZ") + require.Nil(t, body) + require.Equal(t, ContentTypeUnknown, contentType) + }) +} diff --git a/http/payloadattestationdata.go b/http/payloadattestationdata.go new file mode 100644 index 00000000..8930e8b6 --- /dev/null +++ b/http/payloadattestationdata.go @@ -0,0 +1,148 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "bytes" + "context" + "errors" + "fmt" + "maps" + "net/http" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" +) + +// PayloadAttestationData obtains payload attestation data for the given options. +// +// A node only produces this for its current slot; if it has seen no block for +// that slot it answers 204, which surfaces as client.ErrNoPayloadAttestationData +// and means the validator must not cast a payload attestation. +func (s *Service) PayloadAttestationData(ctx context.Context, + opts *api.PayloadAttestationDataOpts, +) ( + *api.Response[*spec.VersionedPayloadAttestationData], + error, +) { + if err := s.assertIsSynced(ctx); err != nil { + return nil, err + } + + if opts == nil { + return nil, client.ErrNoOptions + } + + // The slot is a required query parameter, not a path segment; the endpoint was + // aligned with produceAttestationData in beacon-APIs #626. + endpoint := "/eth/v1/validator/payload_attestation_data" + query := fmt.Sprintf("slot=%d", opts.Slot) + + httpResponse, err := s.getWithResponseLimit(ctx, endpoint, query, &opts.Common, true, maxEPBSResponseSize) + if err != nil { + return nil, err + } + + response, err := payloadAttestationDataFromResponse(httpResponse) + if err != nil { + return nil, err + } + + slot, err := response.Data.Slot() + if err != nil { + return nil, err + } + + if slot != opts.Slot { + return nil, errors.Join( + fmt.Errorf("payload attestation data for slot %d; expected %d", slot, opts.Slot), + client.ErrInconsistentResult, + ) + } + + return response, nil +} + +// payloadAttestationDataFromResponse decodes a fetched payload attestation +// data response. It is separate from the fetch so that the paths a live node +// will not produce on demand — a 204, or an SSZ body — remain testable. +func payloadAttestationDataFromResponse(httpResponse *httpResponse) ( + *api.Response[*spec.VersionedPayloadAttestationData], + error, +) { + if httpResponse.statusCode == http.StatusNoContent { + // The node has seen no block for this slot, so there is nothing to + // attest to. This is a defined outcome of the endpoint, not a + // failure, but it must not be mistaken for a signable datum. + return nil, client.ErrNoPayloadAttestationData + } + + if httpResponse.consensusVersion != spec.DataVersionGloas { + return nil, fmt.Errorf("payload attestation data not available for version %s", httpResponse.consensusVersion) + } + + // Payload attestation data is a gloas-onwards container, so the wire bytes + // always parse into *gloas.PayloadAttestationData. + data := &gloas.PayloadAttestationData{} + metadata := metadataFromHeaders(httpResponse.headers) + + switch httpResponse.contentType { + case ContentTypeSSZ: + // The container is a fixed-size root, slot and two flags, with no + // preset-derived sizes, so the generated codec decodes it correctly at + // any preset and no dynamic SSZ codec is required. + if err := data.UnmarshalSSZ(httpResponse.body); err != nil { + return nil, errors.Join( + fmt.Errorf("failed to decode %s payload attestation data", httpResponse.consensusVersion), + err, + ) + } + case ContentTypeJSON: + // The decoder only writes the keys it finds, so seeding it with a nil + // pointer rather than the empty datum above is what makes a body with + // no data key in it distinguishable from one carrying a zero-valued + // datum: only the former leaves the seed as it was. + decoded, jsonMetadata, err := decodeJSONResponse(bytes.NewReader(httpResponse.body), (*gloas.PayloadAttestationData)(nil)) + if err != nil { + return nil, errors.Join( + fmt.Errorf("failed to decode %s payload attestation data", httpResponse.consensusVersion), + err, + ) + } + + if decoded == nil { + // Neither an absent data key nor an explicit null leaves anything + // to attest to. Returning a success here would hand back a datum + // whose accessors all answer zero without error, which a caller + // cannot tell apart from a real vote for the zero root. + return nil, fmt.Errorf("no %s payload attestation data in response", httpResponse.consensusVersion) + } + + data = decoded + + maps.Copy(metadata, jsonMetadata) + default: + return nil, fmt.Errorf("unhandled content type %v", httpResponse.contentType) + } + + return &api.Response[*spec.VersionedPayloadAttestationData]{ + Data: &spec.VersionedPayloadAttestationData{ + Version: httpResponse.consensusVersion, + Gloas: data, + }, + Metadata: metadata, + }, nil +} diff --git a/http/payloadattestationdata_internal_test.go b/http/payloadattestationdata_internal_test.go new file mode 100644 index 00000000..d4fca04d --- /dev/null +++ b/http/payloadattestationdata_internal_test.go @@ -0,0 +1,191 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// TestPayloadAttestationDataFromResponse covers the decode half of the +// endpoint, which takes an already-fetched response and so needs no beacon +// node. Two of the paths here are unreachable against a live node — a 204 is +// only emitted when no block has been seen for the requested slot, and neither +// client serves this endpoint as SSZ — so this is the only place they get +// exercised at all. +func TestPayloadAttestationDataFromResponse(t *testing.T) { + datum := &gloas.PayloadAttestationData{ + BeaconBlockRoot: phase0.Root{0x01, 0x02, 0x03}, + Slot: 60300, + PayloadPresent: true, + BlobDataAvailable: false, + } + + sszBody, err := datum.MarshalSSZ() + require.NoError(t, err) + + jsonBody := []byte(`{"version":"gloas","data":{"beacon_block_root":"0x0102030000000000000000000000000000000000000000000000000000000000","slot":"60300","payload_present":true,"blob_data_available":false}}`) + + t.Run("JSON", func(t *testing.T) { + response, err := payloadAttestationDataFromResponse(&httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionGloas, + body: jsonBody, + headers: map[string]string{}, + }) + require.NoError(t, err) + require.Equal(t, spec.DataVersionGloas, response.Data.Version) + require.Equal(t, datum, response.Data.Gloas) + }) + + t.Run("SSZ", func(t *testing.T) { + response, err := payloadAttestationDataFromResponse(&httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeSSZ, + consensusVersion: spec.DataVersionGloas, + body: sszBody, + headers: map[string]string{}, + }) + require.NoError(t, err) + require.Equal(t, spec.DataVersionGloas, response.Data.Version) + require.Equal(t, datum, response.Data.Gloas) + }) + + // A 204 means the node has seen no block for the slot and the validator + // must not attest. Returning an empty-but-valid response here would let a + // caller that forgets to check sign a zero-valued datum, so it fails + // closed with a sentinel the caller can match on. + t.Run("NoContent", func(t *testing.T) { + _, err := payloadAttestationDataFromResponse(&httpResponse{ + statusCode: http.StatusNoContent, + headers: map[string]string{}, + }) + require.ErrorIs(t, err, client.ErrNoPayloadAttestationData) + }) + + t.Run("PreGloasVersion", func(t *testing.T) { + _, err := payloadAttestationDataFromResponse(&httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionElectra, + body: jsonBody, + headers: map[string]string{}, + }) + require.ErrorContains(t, err, "payload attestation data not available for version electra") + }) + + t.Run("UnknownContentType", func(t *testing.T) { + _, err := payloadAttestationDataFromResponse(&httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeUnknown, + consensusVersion: spec.DataVersionGloas, + body: jsonBody, + headers: map[string]string{}, + }) + require.ErrorContains(t, err, "unhandled content type") + }) + + // The JSON decoder fills in only the keys it finds, so a body carrying no + // data key at all leaves the seeded datum untouched. That must be an + // error rather than a success wrapping a zero-valued datum, which a caller + // cannot tell apart from a genuine vote for the zero root. + t.Run("MissingDataKey", func(t *testing.T) { + _, err := payloadAttestationDataFromResponse(&httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionGloas, + body: []byte(`{"version":"gloas"}`), + headers: map[string]string{}, + }) + require.ErrorContains(t, err, "no gloas payload attestation data in response") + }) + + t.Run("NullData", func(t *testing.T) { + _, err := payloadAttestationDataFromResponse(&httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionGloas, + body: []byte(`{"version":"gloas","data":null}`), + headers: map[string]string{}, + }) + require.ErrorContains(t, err, "no gloas payload attestation data in response") + }) + + t.Run("CorruptJSON", func(t *testing.T) { + _, err := payloadAttestationDataFromResponse(&httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionGloas, + body: jsonBody[:len(jsonBody)-1], + headers: map[string]string{}, + }) + require.ErrorContains(t, err, "failed to decode gloas payload attestation data") + }) + + t.Run("CorruptSSZ", func(t *testing.T) { + _, err := payloadAttestationDataFromResponse(&httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeSSZ, + consensusVersion: spec.DataVersionGloas, + body: sszBody[:len(sszBody)-1], + headers: map[string]string{}, + }) + require.ErrorContains(t, err, "failed to decode gloas payload attestation data") + }) +} + +func TestPayloadAttestationDataPath(t *testing.T) { + var requestPath, requestQuery string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestQuery = r.URL.RawQuery + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Eth-Consensus-Version", "gloas") + _, err := w.Write([]byte(`{"data":{"beacon_block_root":"0x0102030000000000000000000000000000000000000000000000000000000000","slot":"42","payload_present":true,"blob_data_available":false}}`)) + require.NoError(t, err) + })) + defer server.Close() + + base, address, err := parseAddress(server.URL) + require.NoError(t, err) + service := &Service{ + base: base, + address: address.String(), + client: server.Client(), + timeout: time.Second, + extraHeaders: map[string]string{}, + connectionActive: true, + connectionSynced: true, + } + + response, err := service.PayloadAttestationData(context.Background(), + &api.PayloadAttestationDataOpts{Slot: 42}, + ) + require.NoError(t, err) + require.Equal(t, phase0.Slot(42), response.Data.Gloas.Slot) + // The slot is a required query parameter, not a path segment. + require.Equal(t, "/eth/v1/validator/payload_attestation_data", requestPath) + require.Equal(t, "slot=42", requestQuery) +} diff --git a/http/payloadattestationdata_test.go b/http/payloadattestationdata_test.go new file mode 100644 index 00000000..d51a0aa8 --- /dev/null +++ b/http/payloadattestationdata_test.go @@ -0,0 +1,57 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http_test + +import ( + "context" + "errors" + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/stretchr/testify/require" +) + +// TestPayloadAttestationData exercises the endpoint against a live node. +// +// A beacon node only produces payload attestation data for its current slot, +// so the success path is not reachable on demand; it is covered in-process by +// TestPayloadAttestationDataFromResponse, which also covers the SSZ body and +// the 204 "no block seen" signal that a node will not emit to order. What is +// verified here is that a structurally-valid request reaches the server and +// that its rejection surfaces as a typed api.Error rather than a decode +// failure. +func TestPayloadAttestationData(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + service := testService(ctx, t).(client.Service) + + t.Run("NilOpts", func(t *testing.T) { + _, err := service.(client.PayloadAttestationDataProvider).PayloadAttestationData(ctx, nil) + require.ErrorIs(t, err, client.ErrNoOptions) + }) + + t.Run("PastSlot", func(t *testing.T) { + // Slot 1 is long past, so the node must refuse to produce data for it. + _, err := service.(client.PayloadAttestationDataProvider).PayloadAttestationData(ctx, + &api.PayloadAttestationDataOpts{Slot: 1}, + ) + require.Error(t, err) + + var apiErr *api.Error + require.True(t, errors.As(err, &apiErr), "expected a typed api.Error, got %v", err) + require.Equal(t, 400, apiErr.StatusCode) + }) +} diff --git a/http/payloadattestationpool.go b/http/payloadattestationpool.go new file mode 100644 index 00000000..1821587a --- /dev/null +++ b/http/payloadattestationpool.go @@ -0,0 +1,140 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "bytes" + "context" + "errors" + "fmt" + "maps" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// PayloadAttestationPool obtains the payload attestation pool for the given options. +func (s *Service) PayloadAttestationPool(ctx context.Context, + opts *api.PayloadAttestationPoolOpts, +) ( + *api.Response[[]*spec.VersionedPayloadAttestation], + error, +) { + if err := s.assertIsSynced(ctx); err != nil { + return nil, err + } + + if opts == nil { + return nil, client.ErrNoOptions + } + + endpoint := "/eth/v1/beacon/pool/payload_attestations" + + query := "" + if opts.Slot != nil { + query = fmt.Sprintf("slot=%d", *opts.Slot) + } + + // The endpoint advertises SSZ, but as a bare SSZ list with no container to + // decode into, so JSON is requested here. + httpResponse, err := s.getWithResponseLimit(ctx, endpoint, query, &opts.Common, false, maxEPBSResponseSize) + if err != nil { + return nil, err + } + + response, err := payloadAttestationPoolFromResponse(httpResponse) + if err != nil { + return nil, err + } + + if err := verifyPayloadAttestationPool(opts.Slot, response.Data); err != nil { + return nil, errors.Join(err, client.ErrInconsistentResult) + } + + return response, nil +} + +// payloadAttestationPoolFromResponse decodes a fetched payload attestation +// pool response. It is separate from the fetch so that decoding can be +// exercised without a beacon node. +func payloadAttestationPoolFromResponse(httpResponse *httpResponse) ( + *api.Response[[]*spec.VersionedPayloadAttestation], + error, +) { + if httpResponse.consensusVersion != spec.DataVersionGloas { + return nil, fmt.Errorf("payload attestations not available for version %s", httpResponse.consensusVersion) + } + + metadata := metadataFromHeaders(httpResponse.headers) + + var attestations []*gloas.PayloadAttestation + + switch httpResponse.contentType { + case ContentTypeJSON: + decoded, jsonMetadata, err := decodeJSONResponse(bytes.NewReader(httpResponse.body), []*gloas.PayloadAttestation{}) + if err != nil { + return nil, errors.Join( + fmt.Errorf("failed to decode %s payload attestations", httpResponse.consensusVersion), + err, + ) + } + + attestations = decoded + + maps.Copy(metadata, jsonMetadata) + default: + return nil, fmt.Errorf("unhandled content type %v", httpResponse.contentType) + } + + data := make([]*spec.VersionedPayloadAttestation, len(attestations)) + for i := range attestations { + if attestations[i] == nil { + return nil, errors.New("nil payload attestation in response") + } + + data[i] = &spec.VersionedPayloadAttestation{ + Version: httpResponse.consensusVersion, + Gloas: attestations[i], + } + } + + return &api.Response[[]*spec.VersionedPayloadAttestation]{ + Data: data, + Metadata: metadata, + }, nil +} + +// verifyPayloadAttestationPool confirms that every attestation returned by the +// node holds an attestation to read, and is for the requested slot when one +// was requested. +func verifyPayloadAttestationPool(slot *phase0.Slot, attestations []*spec.VersionedPayloadAttestation) error { + for _, attestation := range attestations { + // Data rejects a missing version arm as well as a missing datum, and + // is worth calling even with no slot to filter on: requesting the + // whole pool waives the filter, not the contents of what comes back. + data, err := attestation.Data() + if err != nil { + return err + } + + if slot != nil && data.Slot != *slot { + return fmt.Errorf("payload attestation for slot %d; expected %d", data.Slot, *slot) + } + } + + return nil +} diff --git a/http/payloadattestationpool_internal_test.go b/http/payloadattestationpool_internal_test.go new file mode 100644 index 00000000..20a80dfd --- /dev/null +++ b/http/payloadattestationpool_internal_test.go @@ -0,0 +1,182 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// poolAttestationJSON builds one wire-format payload attestation for the given +// slot. Aggregation bits are a 64-byte bitvector and the signature 96 bytes. +func poolAttestationJSON(slot phase0.Slot) string { + return fmt.Sprintf( + `{"aggregation_bits":"0x08%s","data":{"beacon_block_root":"0x0102030000000000000000000000000000000000000000000000000000000000","slot":"%d","payload_present":true,"blob_data_available":true},"signature":"0xaa%s"}`, + strings.Repeat("00", 63), + slot, + strings.Repeat("00", 95), + ) +} + +func TestPayloadAttestationPoolFromResponse(t *testing.T) { + body := func(attestations ...string) []byte { + return []byte(`{"version":"gloas","data":[` + strings.Join(attestations, ",") + `]}`) + } + + t.Run("JSON", func(t *testing.T) { + response, err := payloadAttestationPoolFromResponse(&httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionGloas, + body: body(poolAttestationJSON(60300), poolAttestationJSON(60301)), + headers: map[string]string{}, + }) + require.NoError(t, err) + require.Len(t, response.Data, 2) + + for i, expectedSlot := range []phase0.Slot{60300, 60301} { + require.Equal(t, spec.DataVersionGloas, response.Data[i].Version) + + data, err := response.Data[i].Data() + require.NoError(t, err) + require.Equal(t, expectedSlot, data.Slot) + + // Every element must carry its own bits, not just the first: a loop that + // set them once outside the body would still pass a length check. Read + // through the container's accessor, not the bitvector's mainnet-only BitAt, + // which this mainnet-width fixture would hide. + require.NotNil(t, response.Data[i].Gloas) + attested, err := response.Data[i].Gloas.AttestedAt(3) + require.NoError(t, err) + require.True(t, attested) + } + }) + + t.Run("EmptyPool", func(t *testing.T) { + response, err := payloadAttestationPoolFromResponse(&httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionGloas, + body: body(), + headers: map[string]string{}, + }) + require.NoError(t, err) + require.Empty(t, response.Data) + }) + + t.Run("PreGloasVersion", func(t *testing.T) { + _, err := payloadAttestationPoolFromResponse(&httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionFulu, + body: body(), + headers: map[string]string{}, + }) + require.ErrorContains(t, err, "payload attestations not available for version fulu") + }) + + t.Run("UnknownContentType", func(t *testing.T) { + _, err := payloadAttestationPoolFromResponse(&httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeUnknown, + consensusVersion: spec.DataVersionGloas, + body: body(), + headers: map[string]string{}, + }) + require.ErrorContains(t, err, "unhandled content type") + }) + + t.Run("NilElement", func(t *testing.T) { + _, err := payloadAttestationPoolFromResponse(&httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionGloas, + body: []byte(`{"data":[null]}`), + headers: map[string]string{}, + }) + require.EqualError(t, err, "nil payload attestation in response") + }) +} + +// TestVerifyPayloadAttestationPool covers the filter check applied when a slot +// was requested. It is pure, so it needs no beacon node. +func TestVerifyPayloadAttestationPool(t *testing.T) { + attestation := func(slot phase0.Slot) *spec.VersionedPayloadAttestation { + return &spec.VersionedPayloadAttestation{ + Version: spec.DataVersionGloas, + Gloas: &gloas.PayloadAttestation{ + Data: &gloas.PayloadAttestationData{Slot: slot}, + }, + } + } + slot := phase0.Slot(60300) + other := phase0.Slot(60301) + + tests := []struct { + name string + slot *phase0.Slot + attestations []*spec.VersionedPayloadAttestation + err string + }{ + { + name: "NoSlotRequested", + attestations: []*spec.VersionedPayloadAttestation{attestation(60300), attestation(60301)}, + }, + { + name: "SlotMatches", + slot: &slot, + attestations: []*spec.VersionedPayloadAttestation{attestation(60300)}, + }, + { + name: "SlotDiffers", + slot: &other, + attestations: []*spec.VersionedPayloadAttestation{attestation(60300)}, + err: "payload attestation for slot 60300; expected 60301", + }, + { + // A datum-less attestation must not read as "matches whatever was + // asked for"; the filter has nothing to compare against. + name: "NilData", + slot: &slot, + attestations: []*spec.VersionedPayloadAttestation{{Version: spec.DataVersionGloas, Gloas: &gloas.PayloadAttestation{}}}, + err: "no gloas payload attestation data", + }, + { + // Asking for the whole pool waives the slot filter, not the check + // that there is an attestation there at all: a nil arm reaches the + // caller as an element whose every accessor errors. + name: "NilArmNoSlotRequested", + attestations: []*spec.VersionedPayloadAttestation{{Version: spec.DataVersionGloas}}, + err: "no gloas payload attestation", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := verifyPayloadAttestationPool(test.slot, test.attestations) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/http/proposerduties.go b/http/proposerduties.go index d921dd45..f71868dc 100644 --- a/http/proposerduties.go +++ b/http/proposerduties.go @@ -31,6 +31,26 @@ func (s *Service) ProposerDuties(ctx context.Context, ) ( *api.Response[[]*apiv1.ProposerDuty], error, +) { + return s.proposerDutiesAtEndpoint(ctx, opts, "/eth/v1/validator/duties/proposer") +} + +// ProposerDutiesV2 obtains proposer duties for the given options using the v2 API. +func (s *Service) ProposerDutiesV2(ctx context.Context, + opts *api.ProposerDutiesOpts, +) ( + *api.Response[[]*apiv1.ProposerDuty], + error, +) { + return s.proposerDutiesAtEndpoint(ctx, opts, "/eth/v2/validator/duties/proposer") +} + +func (s *Service) proposerDutiesAtEndpoint(ctx context.Context, + opts *api.ProposerDutiesOpts, + baseEndpoint string, +) ( + *api.Response[[]*apiv1.ProposerDuty], + error, ) { if err := s.assertIsActive(ctx); err != nil { return nil, err @@ -40,7 +60,7 @@ func (s *Service) ProposerDuties(ctx context.Context, return nil, client.ErrNoOptions } - endpoint := fmt.Sprintf("/eth/v1/validator/duties/proposer/%d", opts.Epoch) + endpoint := fmt.Sprintf("%s/%d", baseEndpoint, opts.Epoch) httpResponse, err := s.get(ctx, endpoint, "", &opts.Common, false) if err != nil { diff --git a/http/proposerduties_test.go b/http/proposerduties_test.go index 9b94d6a6..2c164301 100644 --- a/http/proposerduties_test.go +++ b/http/proposerduties_test.go @@ -15,15 +15,108 @@ package http_test import ( "context" + "fmt" + "net/http" + "net/http/httptest" "testing" "time" client "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/api" + apiv1 "github.com/attestantio/go-eth2-client/api/v1" + eth2http "github.com/attestantio/go-eth2-client/http" "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/rs/zerolog" "github.com/stretchr/testify/require" ) +func TestProposerDutiesV2Transport(t *testing.T) { + ctx := context.Background() + currentEpochRoot := phase0.Root{0xaa} + nextEpochRoot := phase0.Root{0xbb} + headRoot := phase0.Root{0xcc} + legacyRoot := phase0.Root{0xdd} + pubkey := phase0.BLSPubKey{0xee} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Path { + case "/eth/v1/node/version": + fmt.Fprint(w, `{"data":{"version":"stub"}}`) + case "/eth/v1/node/syncing": + fmt.Fprint(w, `{"data":{"head_slot":"64","sync_distance":"0","is_syncing":false,"is_optimistic":false}}`) + case "/eth/v1/config/spec": + fmt.Fprint(w, `{"data":{"SLOTS_PER_EPOCH":"32"}}`) + case "/eth/v2/validator/duties/proposer/2": + if r.Method != http.MethodGet { + t.Errorf("unexpected request method %s", r.Method) + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + fmt.Fprintf(w, `{"dependent_root":"%s","execution_optimistic":true,"data":[{"pubkey":"%s","validator_index":"7","slot":"65"}]}`, + currentEpochRoot.String(), pubkey.String()) + case "/eth/v2/validator/duties/proposer/3": + if r.Method != http.MethodGet { + t.Errorf("unexpected request method %s", r.Method) + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + fmt.Fprintf(w, `{"dependent_root":"%s","execution_optimistic":true,"data":[{"pubkey":"%s","validator_index":"7","slot":"97"}]}`, + nextEpochRoot.String(), pubkey.String()) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + service, err := eth2http.New(ctx, + eth2http.WithAddress(server.URL), + eth2http.WithLogLevel(zerolog.Disabled), + ) + require.NoError(t, err) + + provider, supported := service.(client.ProposerDutiesV2Provider) + require.True(t, supported) + + tests := []struct { + name string + epoch phase0.Epoch + slot phase0.Slot + root phase0.Root + }{ + { + name: "CurrentEpoch", + epoch: 2, + slot: 65, + root: currentEpochRoot, + }, + { + name: "NextEpoch", + epoch: 3, + slot: 97, + root: nextEpochRoot, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response, err := provider.ProposerDutiesV2(ctx, &api.ProposerDutiesOpts{Epoch: test.epoch}) + require.NoError(t, err) + require.Equal(t, []*apiv1.ProposerDuty{{ + PubKey: pubkey, + ValidatorIndex: 7, + Slot: test.slot, + }}, response.Data) + require.Equal(t, test.root, response.Metadata["dependent_root"]) + require.NotEqual(t, phase0.Root{}, response.Metadata["dependent_root"]) + require.NotEqual(t, headRoot, response.Metadata["dependent_root"]) + require.NotEqual(t, legacyRoot, response.Metadata["dependent_root"]) + require.Equal(t, true, response.Metadata["execution_optimistic"]) + }) + } +} + func TestProposerDuties(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/http/ptcduties.go b/http/ptcduties.go new file mode 100644 index 00000000..353861d9 --- /dev/null +++ b/http/ptcduties.go @@ -0,0 +1,118 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + apiv1 "github.com/attestantio/go-eth2-client/api/v1" + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// PTCDuties obtains payload timeliness committee duties. +func (s *Service) PTCDuties(ctx context.Context, + opts *api.PTCDutiesOpts, +) ( + *api.Response[[]*apiv1.PTCDuty], + error, +) { + if err := s.assertIsSynced(ctx); err != nil { + return nil, err + } + + if opts == nil { + return nil, client.ErrNoOptions + } + + if len(opts.Indices) == 0 { + return nil, errors.Join(errors.New("no validator indices specified"), client.ErrInvalidOptions) + } + + // The endpoint takes an array of validator indices, which the beacon API + // encodes as quoted decimal strings. + indices := make([]string, len(opts.Indices)) + for i := range opts.Indices { + indices[i] = strconv.FormatUint(uint64(opts.Indices[i]), 10) + } + + reqBody, err := json.Marshal(indices) + if err != nil { + return nil, errors.Join(errors.New("failed to marshal validator indices"), err) + } + + endpoint := fmt.Sprintf("/eth/v1/validator/duties/ptc/%d", opts.Epoch) + + httpResponse, err := s.post(ctx, + endpoint, + "", + &opts.Common, + bytes.NewReader(reqBody), + ContentTypeJSON, + map[string]string{}, + ) + if err != nil { + return nil, errors.Join(errors.New("failed to request PTC duties"), err) + } + + data, metadata, err := decodeJSONResponse(bytes.NewReader(httpResponse.body), []*apiv1.PTCDuty{}) + if err != nil { + return nil, err + } + + slotsPerEpoch, err := s.SlotsPerEpoch(ctx) + if err != nil { + return nil, errors.Join(errors.New("failed to obtain slots per epoch"), err) + } + + if err := verifyPTCDuties(opts.Epoch, slotsPerEpoch, data); err != nil { + return nil, err + } + + return &api.Response[[]*apiv1.PTCDuty]{ + Metadata: metadata, + Data: data, + }, nil +} + +// verifyPTCDuties confirms that every duty returned by the node falls in the +// epoch that was requested. +func verifyPTCDuties(epoch phase0.Epoch, slotsPerEpoch uint64, duties []*apiv1.PTCDuty) error { + if slotsPerEpoch == 0 { + // Without this the end of the range underflows to MaxUint64 and the + // check below accepts every duty the node cares to return. + return errors.New("invalid slots per epoch 0") + } + + startSlot := phase0.Slot(uint64(epoch) * slotsPerEpoch) + endSlot := startSlot + phase0.Slot(slotsPerEpoch) - 1 + + for _, duty := range duties { + if duty == nil { + return errors.New("received nil PTC duty") + } + + if duty.Slot < startSlot || duty.Slot > endSlot { + return fmt.Errorf("received PTC duty for slot %d outside of range [%d,%d]", duty.Slot, startSlot, endSlot) + } + } + + return nil +} diff --git a/http/ptcduties_internal_test.go b/http/ptcduties_internal_test.go new file mode 100644 index 00000000..6ed0338c --- /dev/null +++ b/http/ptcduties_internal_test.go @@ -0,0 +1,94 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "testing" + + apiv1 "github.com/attestantio/go-eth2-client/api/v1" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// TestVerifyPTCDuties covers the epoch-range check applied to duties returned +// by the node. It is a pure function over plain Go values, so it needs no +// beacon node. +func TestVerifyPTCDuties(t *testing.T) { + duty := func(slot phase0.Slot) *apiv1.PTCDuty { + return &apiv1.PTCDuty{ValidatorIndex: 8, Slot: slot} + } + + tests := []struct { + name string + epoch phase0.Epoch + slotsPerEpoch uint64 + duties []*apiv1.PTCDuty + err string + }{ + { + name: "Empty", + epoch: 7537, + slotsPerEpoch: 8, + }, + { + name: "InRange", + epoch: 7537, + slotsPerEpoch: 8, + // Epoch 7537 at 8 slots per epoch spans [60296,60303]. + duties: []*apiv1.PTCDuty{duty(60296), duty(60300), duty(60303)}, + }, + { + name: "BeforeEpoch", + epoch: 7537, + slotsPerEpoch: 8, + duties: []*apiv1.PTCDuty{duty(60295)}, + err: "received PTC duty for slot 60295 outside of range [60296,60303]", + }, + { + name: "AfterEpoch", + epoch: 7537, + slotsPerEpoch: 8, + duties: []*apiv1.PTCDuty{duty(60300), duty(60304)}, + err: "received PTC duty for slot 60304 outside of range [60296,60303]", + }, + { + // A node reporting SLOTS_PER_EPOCH of 0 would otherwise underflow + // the end of the range to MaxUint64 and accept every duty, so the + // check would silently pass anything. + name: "ZeroSlotsPerEpoch", + epoch: 7537, + slotsPerEpoch: 0, + duties: []*apiv1.PTCDuty{duty(60300)}, + err: "invalid slots per epoch 0", + }, + { + name: "NilDuty", + epoch: 7537, + slotsPerEpoch: 8, + duties: []*apiv1.PTCDuty{nil}, + err: "received nil PTC duty", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := verifyPTCDuties(test.epoch, test.slotsPerEpoch, test.duties) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/http/responsebody_internal_test.go b/http/responsebody_internal_test.go new file mode 100644 index 00000000..11c2edc5 --- /dev/null +++ b/http/responsebody_internal_test.go @@ -0,0 +1,77 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestReadResponseBody(t *testing.T) { + tests := []struct { + name string + body string + limit int + err string + }{ + {name: "WithinLimit", body: "1234", limit: 4}, + {name: "ExceedsLimit", body: "12345", limit: 4, err: "response body exceeds 4 bytes"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body, err := readResponseBody(strings.NewReader(test.body), test.limit) + if test.err != "" { + require.EqualError(t, err, test.err) + require.Nil(t, body) + } else { + require.NoError(t, err) + require.Equal(t, []byte(test.body), body) + } + }) + } +} + +// TestEPBSProposalResponseLimitsAreSurvivable pins the property that makes the +// block-production limits a defence rather than a comment. readResponseBody +// reaches its length check only after io.ReadAll has buffered the whole body, +// and ReadAll grows its buffer geometrically, so peak allocation is close to +// twice the limit. A limit taken from the protocol's theoretical maximum +// (~2GiB of SSZ, ~5GiB of JSON hex) is therefore never reached -- the process +// dies first -- and a body admitted just under one is equally fatal. +// +// The ceiling below is an allocation budget, not a protocol bound: every live +// preset produces a payload-included response orders of magnitude smaller, so +// the JSON limit sitting exactly at half the budget is the deliberate ceiling +// rather than a coincidence. Raising either limit past it is the regression +// this test exists to catch. +func TestEPBSProposalResponseLimitsAreSurvivable(t *testing.T) { + const maxProposalResponsePeakAllocation = 1024 * 1024 * 1024 + + tests := []struct { + name string + limit int + }{ + {name: "SSZ", limit: maxEPBSProposalResponseSize}, + {name: "JSON", limit: maxEPBSProposalJSONResponseSize}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.LessOrEqual(t, 2*test.limit, maxProposalResponsePeakAllocation) + }) + } +} diff --git a/http/service_test.go b/http/service_test.go index 5011c191..57e85507 100644 --- a/http/service_test.go +++ b/http/service_test.go @@ -15,7 +15,6 @@ package http_test import ( "context" - "os" "testing" "time" @@ -49,7 +48,7 @@ func TestService(t *testing.T) { { name: "TimeoutZero", parameters: []v1.Parameter{ - v1.WithAddress(os.Getenv("HTTP_ADDRESS")), + v1.WithAddress("http://localhost:5052"), v1.WithTimeout(0), }, err: "problem with parameters\nno timeout specified", @@ -65,7 +64,7 @@ func TestService(t *testing.T) { { name: "IndexChunkSizeZero", parameters: []v1.Parameter{ - v1.WithAddress(os.Getenv("HTTP_ADDRESS")), + v1.WithAddress("http://localhost:5052"), v1.WithTimeout(5 * time.Second), v1.WithIndexChunkSize(0), }, @@ -74,7 +73,7 @@ func TestService(t *testing.T) { { name: "PubKeyChunkSizeZero", parameters: []v1.Parameter{ - v1.WithAddress(os.Getenv("HTTP_ADDRESS")), + v1.WithAddress("http://localhost:5052"), v1.WithTimeout(5 * time.Second), v1.WithPubKeyChunkSize(0), }, @@ -83,7 +82,7 @@ func TestService(t *testing.T) { { name: "HooksMissing", parameters: []v1.Parameter{ - v1.WithAddress(os.Getenv("HTTP_ADDRESS")), + v1.WithAddress("http://localhost:5052"), v1.WithTimeout(5 * time.Second), v1.WithHooks(nil), }, @@ -92,7 +91,7 @@ func TestService(t *testing.T) { { name: "Good", parameters: []v1.Parameter{ - v1.WithAddress(os.Getenv("HTTP_ADDRESS")), + v1.WithAddress("http://localhost:5052"), v1.WithTimeout(5 * time.Second), v1.WithAllowDelayedStart(true), }, diff --git a/http/signedbeaconblock.go b/http/signedbeaconblock.go index ce57d668..5479d19e 100644 --- a/http/signedbeaconblock.go +++ b/http/signedbeaconblock.go @@ -1,4 +1,4 @@ -// Copyright © 2020 - 2024 Attestant Limited. +// Copyright © 2020 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -27,6 +27,7 @@ import ( "github.com/attestantio/go-eth2-client/spec/capella" "github.com/attestantio/go-eth2-client/spec/deneb" "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" dynssz "github.com/pk910/dynamic-ssz" ) @@ -179,6 +180,17 @@ func (s *Service) signedBeaconBlockFromSSZ(ctx context.Context, if err != nil { return nil, errors.Join(errors.New("failed to decode fulu signed block contents"), err) } + case spec.DataVersionGloas: + response.Data.Gloas = &gloas.SignedBeaconBlock{} + if s.customSpecSupport { + err = dynSSZ.UnmarshalSSZ(response.Data.Gloas, res.body) + } else { + err = response.Data.Gloas.UnmarshalSSZ(res.body) + } + + if err != nil { + return nil, errors.Join(errors.New("failed to decode gloas signed beacon block"), err) + } default: return nil, fmt.Errorf("unhandled block version %s", res.consensusVersion) } @@ -224,6 +236,10 @@ func (*Service) signedBeaconBlockFromJSON(res *httpResponse) (*api.Response[*spe response.Data.Fulu, response.Metadata, err = decodeJSONResponse(bytes.NewReader(res.body), &electra.SignedBeaconBlock{}, ) + case spec.DataVersionGloas: + response.Data.Gloas, response.Metadata, err = decodeJSONResponse(bytes.NewReader(res.body), + &gloas.SignedBeaconBlock{}, + ) default: return nil, fmt.Errorf("unhandled version %s", res.consensusVersion) } diff --git a/http/signedbeaconblock_customspec_test.go b/http/signedbeaconblock_customspec_test.go new file mode 100644 index 00000000..93eb984f --- /dev/null +++ b/http/signedbeaconblock_customspec_test.go @@ -0,0 +1,102 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http_test + +import ( + "context" + "encoding/binary" + "net/http" + "net/http/httptest" + "sync" + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + eth2http "github.com/attestantio/go-eth2-client/http" + "github.com/attestantio/go-eth2-client/spec/gloas" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" +) + +func TestSignedBeaconBlockCustomSpecGloas(t *testing.T) { + ctx := context.Background() + dynamicSSZ := dynssz.NewDynSsz(map[string]any{ + "SYNC_COMMITTEE_SIZE": uint64(32), + }) + block := &gloas.SignedBeaconBlock{ + Message: &gloas.BeaconBlock{ + Body: &gloas.BeaconBlockBody{}, + }, + } + encoded, err := dynamicSSZ.MarshalSSZ(block) + require.NoError(t, err) + require.Equal(t, uint32(100), binary.LittleEndian.Uint32(encoded[0:4])) + require.Equal(t, uint32(84), binary.LittleEndian.Uint32(encoded[180:184])) + require.Equal(t, uint32(336), binary.LittleEndian.Uint32(encoded[384:388])) + + var staticBlock gloas.SignedBeaconBlock + require.EqualError(t, staticBlock.UnmarshalSSZ(encoded), "Message.Body.ProposerSlashings:o: incorrect offset: first offset 336 does not match expected 396") + + var mu sync.Mutex + requests := make([]string, 0, 4) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requests = append(requests, r.Method+" "+r.URL.Path) + mu.Unlock() + + switch r.URL.Path { + case "/eth/v1/node/syncing": + require.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"data":{"head_slot":"0","sync_distance":"0","is_optimistic":false,"is_syncing":false}}`)) + require.NoError(t, err) + case "/eth/v1/node/version": + require.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"data":{"version":"test"}}`)) + require.NoError(t, err) + case "/eth/v2/beacon/blocks/head": + require.Equal(t, http.MethodGet, r.Method) + require.Contains(t, r.Header.Get("Accept"), "application/octet-stream") + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Eth-Consensus-Version", "gloas") + _, err := w.Write(encoded) + require.NoError(t, err) + case "/eth/v1/config/spec": + require.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"data":{"SYNC_COMMITTEE_SIZE":"32"}}`)) + require.NoError(t, err) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + service, err := eth2http.New(ctx, + eth2http.WithAddress(server.URL), + eth2http.WithCustomSpecSupport(true), + eth2http.WithLogLevel(zerolog.Disabled), + ) + require.NoError(t, err) + + response, err := service.(client.SignedBeaconBlockProvider).SignedBeaconBlock(ctx, &api.SignedBeaconBlockOpts{Block: "head"}) + require.NoError(t, err) + require.NotNil(t, response) + require.NotNil(t, response.Data.Gloas) + mu.Lock() + require.Equal(t, []string{"GET /eth/v1/node/syncing", "GET /eth/v1/node/version", "GET /eth/v2/beacon/blocks/head", "GET /eth/v1/config/spec"}, requests) + mu.Unlock() +} diff --git a/http/signedexecutionpayloadenvelope.go b/http/signedexecutionpayloadenvelope.go new file mode 100644 index 00000000..fda59088 --- /dev/null +++ b/http/signedexecutionpayloadenvelope.go @@ -0,0 +1,154 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "bytes" + "context" + "errors" + "fmt" + "maps" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + dynssz "github.com/pk910/dynamic-ssz" +) + +// SignedExecutionPayloadEnvelope fetches a signed execution payload envelope +// given a block ID and decodes it directly into the per-fork view stored on a +// *spec.VersionedSignedExecutionPayloadEnvelope. No intermediate copy. +func (s *Service) SignedExecutionPayloadEnvelope(ctx context.Context, + opts *api.SignedExecutionPayloadEnvelopeOpts, +) ( + *api.Response[*spec.VersionedSignedExecutionPayloadEnvelope], + error, +) { + httpResponse, err := s.fetchSignedExecutionPayloadEnvelope(ctx, opts) + if err != nil { + return nil, err + } + + return s.signedExecutionPayloadEnvelopeFromResponse(ctx, httpResponse) +} + +// signedExecutionPayloadEnvelopeFromResponse decodes a fetched signed envelope +// response. It is separate from the fetch so that both encodings, and the +// empty-response guard, stay testable without a node serving the endpoint. +func (s *Service) signedExecutionPayloadEnvelopeFromResponse(ctx context.Context, + httpResponse *httpResponse, +) ( + *api.Response[*spec.VersionedSignedExecutionPayloadEnvelope], + error, +) { + // The envelope is a gloas-onwards container, so the wire bytes always + // parse into *gloas.SignedExecutionPayloadEnvelope. + envelope := &gloas.SignedExecutionPayloadEnvelope{} + metadata := metadataFromHeaders(httpResponse.headers) + + switch httpResponse.contentType { + case ContentTypeSSZ: + ds, err := s.dynSSZForRequest(ctx) + if err != nil { + return nil, err + } + + if err := ds.UnmarshalSSZ(envelope, httpResponse.body); err != nil { + return nil, errors.Join( + fmt.Errorf("failed to decode %s signed execution payload envelope", httpResponse.consensusVersion), + err, + ) + } + case ContentTypeJSON: + // Seeded with a typed nil pointer so that a body with no data key in it + // is distinguishable from one carrying a zero-valued envelope, matching + // the sibling unsigned-envelope endpoint. decodeJSONResponse only + // unmarshals when a data key is present and does not error when it is + // absent, so a non-nil seed would return a zero-valued envelope as + // success and every downstream accessor would read garbage. + decoded, jsonMetadata, err := decodeJSONResponse(bytes.NewReader(httpResponse.body), + (*gloas.SignedExecutionPayloadEnvelope)(nil)) + if err != nil { + return nil, errors.Join( + fmt.Errorf("failed to decode %s signed execution payload envelope", httpResponse.consensusVersion), + err, + ) + } + + if decoded == nil { + return nil, fmt.Errorf("no %s signed execution payload envelope in response", httpResponse.consensusVersion) + } + + envelope = decoded + + maps.Copy(metadata, jsonMetadata) + default: + return nil, fmt.Errorf("unhandled content type %v", httpResponse.contentType) + } + + return &api.Response[*spec.VersionedSignedExecutionPayloadEnvelope]{ + Data: &spec.VersionedSignedExecutionPayloadEnvelope{ + Version: httpResponse.consensusVersion, + Gloas: envelope, + }, + Metadata: metadata, + }, nil +} + +// fetchSignedExecutionPayloadEnvelope performs the GET request for +// SignedExecutionPayloadEnvelope: validates opts, hits the endpoint, and +// rejects responses for forks where the envelope doesn't apply. +func (s *Service) fetchSignedExecutionPayloadEnvelope(ctx context.Context, + opts *api.SignedExecutionPayloadEnvelopeOpts, +) (*httpResponse, error) { + if err := s.assertIsActive(ctx); err != nil { + return nil, err + } + + if opts == nil { + return nil, client.ErrNoOptions + } + + if opts.Block == "" { + return nil, errors.Join(errors.New("no block specified"), client.ErrInvalidOptions) + } + + endpoint := fmt.Sprintf("/eth/v1/beacon/execution_payload_envelopes/%s", opts.Block) + + httpResponse, err := s.getWithResponseLimit(ctx, endpoint, "", &opts.Common, true, maxEPBSResponseSize) + if err != nil { + return nil, err + } + + if httpResponse.consensusVersion != spec.DataVersionGloas { + return nil, fmt.Errorf("execution payload envelope not available for block version %s", httpResponse.consensusVersion) + } + + return httpResponse, nil +} + +// dynSSZForRequest returns a codec for a custom preset or the default global codec. +func (s *Service) dynSSZForRequest(ctx context.Context) (*dynssz.DynSsz, error) { + if !s.customSpecSupport { + return dynssz.GetGlobalDynSsz(), nil + } + + specs, err := s.Spec(ctx, &api.SpecOpts{}) + if err != nil { + return nil, errors.Join(errors.New("failed to request specs"), err) + } + + return dynssz.NewDynSsz(specs.Data), nil +} diff --git a/http/signedexecutionpayloadenvelope_internal_test.go b/http/signedexecutionpayloadenvelope_internal_test.go new file mode 100644 index 00000000..304eda63 --- /dev/null +++ b/http/signedexecutionpayloadenvelope_internal_test.go @@ -0,0 +1,135 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// validSignedExecutionPayloadEnvelope wraps the package's shared valid envelope +// (defined alongside the unsigned endpoint's test) in a signature so its own +// marshalers run. +func validSignedExecutionPayloadEnvelope() *gloas.SignedExecutionPayloadEnvelope { + return &gloas.SignedExecutionPayloadEnvelope{ + Message: validExecutionPayloadEnvelope(), + Signature: phase0.BLSSignature{0x01, 0x02}, + } +} + +// TestSignedExecutionPayloadEnvelopeFromResponse covers the decode half of the +// endpoint. Splitting decode from the fetch is what lets the SSZ body and the +// error paths be exercised without a node serving the route. +func TestSignedExecutionPayloadEnvelopeFromResponse(t *testing.T) { + ctx := context.Background() + s := &Service{} + + envelope := validSignedExecutionPayloadEnvelope() + + envelopeJSON, err := json.Marshal(envelope) + require.NoError(t, err) + + jsonBody := fmt.Appendf(nil, `{"version":"gloas","data":%s}`, envelopeJSON) + + t.Run("JSON", func(t *testing.T) { + response, err := s.signedExecutionPayloadEnvelopeFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionGloas, + body: jsonBody, + headers: map[string]string{}, + }) + require.NoError(t, err) + require.Equal(t, spec.DataVersionGloas, response.Data.Version) + require.NotNil(t, response.Data.Gloas) + require.False(t, response.Data.IsEmpty()) + }) + + t.Run("SSZ", func(t *testing.T) { + sszBody, err := envelope.MarshalSSZ() + require.NoError(t, err) + + response, err := s.signedExecutionPayloadEnvelopeFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeSSZ, + consensusVersion: spec.DataVersionGloas, + body: sszBody, + headers: map[string]string{}, + }) + require.NoError(t, err) + + // SSZ carries no field names, so a wrongly ordered codec shows up as a + // differing hash tree root rather than a decode error. + want, err := envelope.HashTreeRoot() + require.NoError(t, err) + got, err := response.Data.Gloas.HashTreeRoot() + require.NoError(t, err) + require.Equal(t, want, got) + }) + + // The regression this file exists for. decodeJSONResponse only unmarshals + // when a data key is present and does not error when it is absent, so a body + // with no data key (or an explicit null) must be rejected rather than + // returned as a zero-valued envelope whose every accessor reads garbage. + // Before the typed-nil seed and this guard, the non-nil seed made both + // bodies decode as success. + t.Run("NoDatum", func(t *testing.T) { + for _, body := range []string{ + `{"version":"gloas"}`, + `{"version":"gloas","data":null}`, + } { + _, err := s.signedExecutionPayloadEnvelopeFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeJSON, + consensusVersion: spec.DataVersionGloas, + body: []byte(body), + headers: map[string]string{}, + }) + require.ErrorContains(t, err, "no gloas signed execution payload envelope in response") + } + }) + + t.Run("CorruptSSZ", func(t *testing.T) { + sszBody, err := envelope.MarshalSSZ() + require.NoError(t, err) + + _, err = s.signedExecutionPayloadEnvelopeFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeSSZ, + consensusVersion: spec.DataVersionGloas, + body: sszBody[:len(sszBody)-1], + headers: map[string]string{}, + }) + require.ErrorContains(t, err, "failed to decode gloas signed execution payload envelope") + }) + + t.Run("UnknownContentType", func(t *testing.T) { + _, err := s.signedExecutionPayloadEnvelopeFromResponse(ctx, &httpResponse{ + statusCode: http.StatusOK, + contentType: ContentTypeUnknown, + consensusVersion: spec.DataVersionGloas, + body: jsonBody, + headers: map[string]string{}, + }) + require.ErrorContains(t, err, "unhandled content type") + }) +} diff --git a/http/signedexecutionpayloadenvelope_test.go b/http/signedexecutionpayloadenvelope_test.go new file mode 100644 index 00000000..13c424a0 --- /dev/null +++ b/http/signedexecutionpayloadenvelope_test.go @@ -0,0 +1,72 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http_test + +import ( + "context" + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" + "github.com/stretchr/testify/require" +) + +func TestSignedExecutionPayloadEnvelope(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + service := testService(ctx, t).(client.Service) + + tests := []struct { + name string + opts *api.SignedExecutionPayloadEnvelopeOpts + err string + }{ + { + name: "NilOpts", + err: "no options specified", + }, + { + name: "NoBlock", + opts: &api.SignedExecutionPayloadEnvelopeOpts{}, + err: "no block specified", + }, + { + name: "Head", + opts: &api.SignedExecutionPayloadEnvelopeOpts{Block: "head"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response, err := service.(client.ExecutionPayloadProvider).SignedExecutionPayloadEnvelope(ctx, test.opts) + switch { + case test.err != "": + require.ErrorContains(t, err, test.err) + default: + if err != nil { + // The execution payload envelope endpoint is Gloas-only and + // the envelope for a given block may not yet be revealed, so + // tolerate a node that cannot serve it. + t.Skipf("execution payload envelope not available: %v", err) + } + + require.NotNil(t, response) + require.Equal(t, spec.DataVersionGloas, response.Data.Version) + require.NotNil(t, response.Data.Gloas) + } + }) + } +} diff --git a/http/ssztest_internal_test.go b/http/ssztest_internal_test.go new file mode 100644 index 00000000..daeb9be7 --- /dev/null +++ b/http/ssztest_internal_test.go @@ -0,0 +1,30 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// mustMarshalSSZ marshals v with its generated static codec. +func mustMarshalSSZ(t *testing.T, v interface{ MarshalSSZ() ([]byte, error) }) []byte { + t.Helper() + + body, err := v.MarshalSSZ() + require.NoError(t, err) + + return body +} diff --git a/http/submitaggregateattestations.go b/http/submitaggregateattestations.go index d17368fe..e1619915 100644 --- a/http/submitaggregateattestations.go +++ b/http/submitaggregateattestations.go @@ -1,4 +1,4 @@ -// Copyright © 2020 - 2024 Attestant Limited. +// Copyright © 2020 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -92,19 +92,53 @@ func createUnversionedAggregates(aggregateAndProofs []*spec.VersionedSignedAggre // Append to unversionedAggregates. switch aggregateAndProofs[i].Version { case spec.DataVersionPhase0: + if aggregateAndProofs[i].Phase0 == nil { + return nil, errors.Join(errors.New("nil phase0 aggregate and proof supplied"), client.ErrInvalidOptions) + } + unversionedAggregates = append(unversionedAggregates, aggregateAndProofs[i].Phase0) case spec.DataVersionAltair: + if aggregateAndProofs[i].Altair == nil { + return nil, errors.Join(errors.New("nil altair aggregate and proof supplied"), client.ErrInvalidOptions) + } + unversionedAggregates = append(unversionedAggregates, aggregateAndProofs[i].Altair) case spec.DataVersionBellatrix: + if aggregateAndProofs[i].Bellatrix == nil { + return nil, errors.Join(errors.New("nil bellatrix aggregate and proof supplied"), client.ErrInvalidOptions) + } + unversionedAggregates = append(unversionedAggregates, aggregateAndProofs[i].Bellatrix) case spec.DataVersionCapella: + if aggregateAndProofs[i].Capella == nil { + return nil, errors.Join(errors.New("nil capella aggregate and proof supplied"), client.ErrInvalidOptions) + } + unversionedAggregates = append(unversionedAggregates, aggregateAndProofs[i].Capella) case spec.DataVersionDeneb: + if aggregateAndProofs[i].Deneb == nil { + return nil, errors.Join(errors.New("nil deneb aggregate and proof supplied"), client.ErrInvalidOptions) + } + unversionedAggregates = append(unversionedAggregates, aggregateAndProofs[i].Deneb) case spec.DataVersionElectra: + if aggregateAndProofs[i].Electra == nil { + return nil, errors.Join(errors.New("nil electra aggregate and proof supplied"), client.ErrInvalidOptions) + } + unversionedAggregates = append(unversionedAggregates, aggregateAndProofs[i].Electra) case spec.DataVersionFulu: + if aggregateAndProofs[i].Fulu == nil { + return nil, errors.Join(errors.New("nil fulu aggregate and proof supplied"), client.ErrInvalidOptions) + } + unversionedAggregates = append(unversionedAggregates, aggregateAndProofs[i].Fulu) + case spec.DataVersionGloas: + if aggregateAndProofs[i].Gloas == nil { + return nil, errors.Join(errors.New("nil gloas aggregate and proof supplied"), client.ErrInvalidOptions) + } + + unversionedAggregates = append(unversionedAggregates, aggregateAndProofs[i].Gloas) default: return nil, errors.Join(errors.New("unknown aggregate and proof version"), client.ErrInvalidOptions) } diff --git a/http/submitaggregateattestations_internal_test.go b/http/submitaggregateattestations_internal_test.go new file mode 100644 index 00000000..e4ad8e8f --- /dev/null +++ b/http/submitaggregateattestations_internal_test.go @@ -0,0 +1,223 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// TestCreateUnversionedAggregates verifies that every version arm unwraps the field matching +// its version for submission, that mixed versions are rejected, and that an unrecognised +// version does not fall through silently. +func TestCreateUnversionedAggregates(t *testing.T) { + phase0Aggregate := func(index phase0.ValidatorIndex) *phase0.SignedAggregateAndProof { + return &phase0.SignedAggregateAndProof{ + Message: &phase0.AggregateAndProof{AggregatorIndex: index}, + } + } + electraAggregate := func(index phase0.ValidatorIndex) *electra.SignedAggregateAndProof { + return &electra.SignedAggregateAndProof{ + Message: &electra.AggregateAndProof{AggregatorIndex: index}, + } + } + gloasAggregate := func(index phase0.ValidatorIndex) *gloas.SignedAggregateAndProof { + return &gloas.SignedAggregateAndProof{ + Message: &gloas.AggregateAndProof{AggregatorIndex: index}, + } + } + + // A distinct aggregator index per fork, so forwarding the wrong field produces a legible diff. + phase0Value := phase0Aggregate(1) + altairValue := phase0Aggregate(2) + bellatrixValue := phase0Aggregate(3) + capellaValue := phase0Aggregate(4) + denebValue := phase0Aggregate(5) + electraValue := electraAggregate(6) + fuluValue := electraAggregate(7) + gloasValue := gloasAggregate(8) + + first, second, third := gloasAggregate(42), gloasAggregate(43), gloasAggregate(44) + + tests := []struct { + name string + aggregateAndProofs []*spec.VersionedSignedAggregateAndProof + expected []any + expectedErr string + }{ + { + name: "Phase0", + aggregateAndProofs: []*spec.VersionedSignedAggregateAndProof{ + {Version: spec.DataVersionPhase0, Phase0: phase0Value}, + }, + expected: []any{phase0Value}, + }, + { + name: "Phase0NilAggregate", + aggregateAndProofs: []*spec.VersionedSignedAggregateAndProof{ + {Version: spec.DataVersionPhase0, Phase0: nil}, + }, + expectedErr: "nil phase0 aggregate and proof supplied\ninvalid options", + }, + { + name: "Altair", + aggregateAndProofs: []*spec.VersionedSignedAggregateAndProof{ + {Version: spec.DataVersionAltair, Altair: altairValue}, + }, + expected: []any{altairValue}, + }, + { + name: "AltairNilAggregate", + aggregateAndProofs: []*spec.VersionedSignedAggregateAndProof{ + {Version: spec.DataVersionAltair, Altair: nil}, + }, + expectedErr: "nil altair aggregate and proof supplied\ninvalid options", + }, + { + name: "Bellatrix", + aggregateAndProofs: []*spec.VersionedSignedAggregateAndProof{ + {Version: spec.DataVersionBellatrix, Bellatrix: bellatrixValue}, + }, + expected: []any{bellatrixValue}, + }, + { + name: "BellatrixNilAggregate", + aggregateAndProofs: []*spec.VersionedSignedAggregateAndProof{ + {Version: spec.DataVersionBellatrix, Bellatrix: nil}, + }, + expectedErr: "nil bellatrix aggregate and proof supplied\ninvalid options", + }, + { + name: "Capella", + aggregateAndProofs: []*spec.VersionedSignedAggregateAndProof{ + {Version: spec.DataVersionCapella, Capella: capellaValue}, + }, + expected: []any{capellaValue}, + }, + { + name: "CapellaNilAggregate", + aggregateAndProofs: []*spec.VersionedSignedAggregateAndProof{ + {Version: spec.DataVersionCapella, Capella: nil}, + }, + expectedErr: "nil capella aggregate and proof supplied\ninvalid options", + }, + { + name: "Deneb", + aggregateAndProofs: []*spec.VersionedSignedAggregateAndProof{ + {Version: spec.DataVersionDeneb, Deneb: denebValue}, + }, + expected: []any{denebValue}, + }, + { + name: "DenebNilAggregate", + aggregateAndProofs: []*spec.VersionedSignedAggregateAndProof{ + {Version: spec.DataVersionDeneb, Deneb: nil}, + }, + expectedErr: "nil deneb aggregate and proof supplied\ninvalid options", + }, + { + name: "Electra", + aggregateAndProofs: []*spec.VersionedSignedAggregateAndProof{ + {Version: spec.DataVersionElectra, Electra: electraValue}, + }, + expected: []any{electraValue}, + }, + { + name: "ElectraNilAggregate", + aggregateAndProofs: []*spec.VersionedSignedAggregateAndProof{ + {Version: spec.DataVersionElectra, Electra: nil}, + }, + expectedErr: "nil electra aggregate and proof supplied\ninvalid options", + }, + { + name: "Fulu", + aggregateAndProofs: []*spec.VersionedSignedAggregateAndProof{ + {Version: spec.DataVersionFulu, Fulu: fuluValue}, + }, + expected: []any{fuluValue}, + }, + { + name: "FuluNilAggregate", + aggregateAndProofs: []*spec.VersionedSignedAggregateAndProof{ + {Version: spec.DataVersionFulu, Fulu: nil}, + }, + expectedErr: "nil fulu aggregate and proof supplied\ninvalid options", + }, + { + name: "Gloas", + aggregateAndProofs: []*spec.VersionedSignedAggregateAndProof{ + {Version: spec.DataVersionGloas, Gloas: gloasValue}, + }, + expected: []any{gloasValue}, + }, + { + name: "GloasSeveralAggregates", + aggregateAndProofs: []*spec.VersionedSignedAggregateAndProof{ + {Version: spec.DataVersionGloas, Gloas: first}, + {Version: spec.DataVersionGloas, Gloas: second}, + {Version: spec.DataVersionGloas, Gloas: third}, + }, + expected: []any{first, second, third}, + }, + { + name: "GloasNilAggregate", + aggregateAndProofs: []*spec.VersionedSignedAggregateAndProof{ + {Version: spec.DataVersionGloas}, + }, + expectedErr: "nil gloas aggregate and proof supplied\ninvalid options", + }, + { + name: "NilVersionedAggregate", + aggregateAndProofs: []*spec.VersionedSignedAggregateAndProof{ + nil, + }, + expectedErr: "nil aggregate and proof version supplied\ninvalid options", + }, + { + name: "MixedVersions", + aggregateAndProofs: []*spec.VersionedSignedAggregateAndProof{ + {Version: spec.DataVersionElectra, Electra: electraValue}, + {Version: spec.DataVersionGloas, Gloas: gloasValue}, + }, + expectedErr: "aggregate and proofs must all be of the same version\ninvalid options", + }, + { + name: "UnknownVersion", + aggregateAndProofs: []*spec.VersionedSignedAggregateAndProof{ + {Version: spec.DataVersion(999)}, + }, + expectedErr: "unknown aggregate and proof version\ninvalid options", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + unversioned, err := createUnversionedAggregates(test.aggregateAndProofs) + if test.expectedErr != "" { + require.ErrorIs(t, err, client.ErrInvalidOptions) + require.EqualError(t, err, test.expectedErr) + + return + } + require.NoError(t, err) + require.Equal(t, test.expected, unversioned) + }) + } +} diff --git a/http/submitattestations.go b/http/submitattestations.go index ee8d806b..4a55e4f9 100644 --- a/http/submitattestations.go +++ b/http/submitattestations.go @@ -1,4 +1,4 @@ -// Copyright © 2020 - 2024 Attestant Limited. +// Copyright © 2020 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -118,6 +118,19 @@ func (s *Service) createUnversionedAttestations(attestations []*spec.VersionedAt continue } + unversionedAttestations = append(unversionedAttestations, singleAttestation) + case spec.DataVersionGloas: + if attestations[i].Gloas == nil { + return nil, errors.Join(errors.New("nil gloas attestation supplied"), client.ErrInvalidOptions) + } + + singleAttestation, err := attestations[i].Gloas.ToSingleAttestation(attestations[i].ValidatorIndex) + if err != nil { + s.log.Warn().Err(err).Msg("Failed to convert attestation to single attestation") + + continue + } + unversionedAttestations = append(unversionedAttestations, singleAttestation) default: return nil, errors.Join(errors.New("unknown attestation version"), client.ErrInvalidOptions) diff --git a/http/submitattestations_internal_test.go b/http/submitattestations_internal_test.go new file mode 100644 index 00000000..7636d3b6 --- /dev/null +++ b/http/submitattestations_internal_test.go @@ -0,0 +1,70 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "testing" + + bitfield "github.com/OffchainLabs/go-bitfield" + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// TestCreateUnversionedAttestationsGloas verifies that a Gloas-versioned attestation is +// converted to the single-attestation submission form via gloas.Attestation.ToSingleAttestation, +// mirroring the Electra/Fulu arms; without the arm the switch hits its default and errors. +func TestCreateUnversionedAttestationsGloas(t *testing.T) { + committeeBits := bitfield.NewBitvector64() + committeeBits.SetBitAt(2, true) + aggregationBits := bitfield.NewBitlist(8) + aggregationBits.SetBitAt(1, true) + + attestationData := &phase0.AttestationData{ + Slot: 9, + Index: 0, + Source: &phase0.Checkpoint{}, + Target: &phase0.Checkpoint{}, + } + attestation := &gloas.Attestation{ + AggregationBits: aggregationBits, + Data: attestationData, + Signature: phase0.BLSSignature{0x0a}, + CommitteeBits: committeeBits, + } + validatorIndex := phase0.ValidatorIndex(7) + attestations := []*spec.VersionedAttestation{ + {Version: spec.DataVersionGloas, Gloas: attestation, ValidatorIndex: &validatorIndex}, + } + + s := &Service{} + unversioned, err := s.createUnversionedAttestations(attestations) + require.NoError(t, err) + require.Len(t, unversioned, 1) + + single, ok := unversioned[0].(*electra.SingleAttestation) + require.True(t, ok) + require.Equal(t, phase0.CommitteeIndex(2), single.CommitteeIndex) + require.Equal(t, validatorIndex, single.AttesterIndex) + require.Equal(t, attestationData, single.Data) +} + +func TestCreateUnversionedAttestationsGloasNil(t *testing.T) { + _, err := new(Service).createUnversionedAttestations([]*spec.VersionedAttestation{{Version: spec.DataVersionGloas}}) + require.ErrorIs(t, err, client.ErrInvalidOptions) + require.EqualError(t, err, "nil gloas attestation supplied\ninvalid options") +} diff --git a/http/submitexecutionpayloadbid.go b/http/submitexecutionpayloadbid.go new file mode 100644 index 00000000..9fee11cd --- /dev/null +++ b/http/submitexecutionpayloadbid.go @@ -0,0 +1,88 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "bytes" + "context" + "errors" + "fmt" + "strings" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" +) + +// SubmitExecutionPayloadBid submits a signed execution payload bid for +// gossip broadcast. +func (s *Service) SubmitExecutionPayloadBid(ctx context.Context, + opts *api.SubmitExecutionPayloadBidOpts, +) error { + if err := s.assertIsSynced(ctx); err != nil { + return err + } + + if opts == nil { + return client.ErrNoOptions + } + + if opts.SignedExecutionPayloadBid == nil { + return errors.Join(errors.New("no bid supplied"), client.ErrInvalidOptions) + } + + versioned := opts.SignedExecutionPayloadBid + + var bid any + + switch versioned.Version { + case spec.DataVersionGloas: + if versioned.Gloas == nil { + return errors.Join(errors.New("no gloas bid supplied"), client.ErrInvalidOptions) + } + + bid = versioned.Gloas + default: + return errors.Join( + fmt.Errorf("unsupported bid version %s", versioned.Version), + client.ErrInvalidOptions, + ) + } + + return s.postExecutionPayloadBid(ctx, &opts.Common, versioned.Version, bid) +} + +// postExecutionPayloadBid marshals the bid to the negotiated content type +// (SSZ unless JSON is enforced) and performs the POST. +func (s *Service) postExecutionPayloadBid(ctx context.Context, + common *api.CommonOpts, + consensusVersion spec.DataVersion, + bid any, +) error { + body, contentType, err := s.marshalRequestBody(ctx, bid) + if err != nil { + return err + } + + endpoint := "/eth/v1/beacon/execution_payload_bids" + + headers := make(map[string]string) + headers["Eth-Consensus-Version"] = strings.ToLower(consensusVersion.String()) + + if _, err := s.post(ctx, endpoint, "", common, bytes.NewBuffer(body), contentType, headers); err != nil { + return errors.Join(errors.New("failed to submit execution payload bid"), err) + } + + return nil +} diff --git a/http/submitexecutionpayloadbid_test.go b/http/submitexecutionpayloadbid_test.go new file mode 100644 index 00000000..f36e09d9 --- /dev/null +++ b/http/submitexecutionpayloadbid_test.go @@ -0,0 +1,117 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http_test + +import ( + "context" + "io" + nethttp "net/http" + "net/http/httptest" + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/http" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/stretchr/testify/require" +) + +func TestSubmitExecutionPayloadBid(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + service := testService(ctx, t).(client.Service) + + tests := []struct { + name string + opts *api.SubmitExecutionPayloadBidOpts + err string + }{ + { + name: "NilOpts", + err: "no options specified", + }, + { + name: "NilBid", + opts: &api.SubmitExecutionPayloadBidOpts{}, + err: "no bid supplied", + }, + { + name: "UnsupportedVersion", + opts: &api.SubmitExecutionPayloadBidOpts{ + SignedExecutionPayloadBid: &spec.VersionedSignedExecutionPayloadBid{ + Version: spec.DataVersionPhase0, + }, + }, + err: "unsupported bid version", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := service.(client.ExecutionPayloadBidSubmitter).SubmitExecutionPayloadBid(ctx, test.opts) + require.ErrorContains(t, err, test.err) + }) + } + + t.Run("PostsBid", func(t *testing.T) { + // The submission is recorded here and asserted on the test's own + // goroutine below. Asserting inside the handler would call + // runtime.Goexit from a goroutine that is not the test's, abandoning the + // request and reporting a wrong header as a transport error instead. + var ( + received bool + method string + headers nethttp.Header + body []byte + readErr error + ) + server := httptest.NewServer(nethttp.HandlerFunc(func(w nethttp.ResponseWriter, r *nethttp.Request) { + switch r.URL.Path { + case "/eth/v1/node/version": + _, _ = w.Write([]byte(`{"data":{"version":"test"}}`)) + case "/eth/v1/node/syncing": + _, _ = w.Write([]byte(`{"data":{"is_syncing":false,"is_optimistic":false,"el_offline":false,"head_slot":"1","sync_distance":"0"}}`)) + case "/eth/v1/beacon/execution_payload_bids": + received = true + method = r.Method + headers = r.Header.Clone() + body, readErr = io.ReadAll(r.Body) + w.WriteHeader(nethttp.StatusNoContent) + default: + w.WriteHeader(nethttp.StatusNotFound) + } + })) + defer server.Close() + + localService, err := http.New(ctx, http.WithAddress(server.URL)) + require.NoError(t, err) + err = localService.(client.ExecutionPayloadBidSubmitter).SubmitExecutionPayloadBid(ctx, &api.SubmitExecutionPayloadBidOpts{ + SignedExecutionPayloadBid: &spec.VersionedSignedExecutionPayloadBid{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedExecutionPayloadBid{ + Message: &gloas.ExecutionPayloadBid{}, + }, + }, + }) + require.NoError(t, err) + require.True(t, received) + require.Equal(t, nethttp.MethodPost, method) + require.Equal(t, "gloas", headers.Get("Eth-Consensus-Version")) + require.Equal(t, "application/octet-stream", headers.Get("Content-Type")) + require.NoError(t, readErr) + require.NotEmpty(t, body) + }) +} diff --git a/http/submitexecutionpayloadenvelope.go b/http/submitexecutionpayloadenvelope.go new file mode 100644 index 00000000..e7933311 --- /dev/null +++ b/http/submitexecutionpayloadenvelope.go @@ -0,0 +1,114 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "bytes" + "context" + "errors" + "fmt" + "strings" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + apiv1gloas "github.com/attestantio/go-eth2-client/api/v1/gloas" + apiv2 "github.com/attestantio/go-eth2-client/api/v2" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/deneb" +) + +// SubmitExecutionPayloadEnvelope submits a signed execution payload envelope +// using the stateless request form: a SignedExecutionPayloadEnvelopeContents +// body (signed envelope plus blobs and KZG proofs) with the +// Eth-Blob-Data-Included header set to true. The stateful form (blob data not +// included) only works when the beacon node cached the full envelope from its +// own block production, so builders publishing externally-built payloads must +// use the stateless form. +func (s *Service) SubmitExecutionPayloadEnvelope(ctx context.Context, + opts *api.SubmitExecutionPayloadEnvelopeOpts, +) error { + if err := s.assertIsSynced(ctx); err != nil { + return err + } + + if opts == nil { + return client.ErrNoOptions + } + + if opts.SignedExecutionPayloadEnvelope == nil { + return errors.Join(errors.New("no envelope supplied"), client.ErrInvalidOptions) + } + + versioned := opts.SignedExecutionPayloadEnvelope + + if versioned.Version != spec.DataVersionGloas { + return errors.Join( + fmt.Errorf("unsupported envelope version %s", versioned.Version), + client.ErrInvalidOptions, + ) + } + if versioned.Gloas == nil { + return errors.Join(errors.New("no gloas envelope supplied"), client.ErrInvalidOptions) + } + + contents := &apiv1gloas.SignedExecutionPayloadEnvelopeContents{ + SignedExecutionPayloadEnvelope: versioned.Gloas, + KZGProofs: opts.KZGProofs, + Blobs: opts.Blobs, + } + if contents.KZGProofs == nil { + contents.KZGProofs = []deneb.KZGProof{} + } + if contents.Blobs == nil { + contents.Blobs = []deneb.Blob{} + } + + body, contentType, err := s.marshalRequestBody(ctx, contents) + if err != nil { + return err + } + + return s.postExecutionPayloadEnvelope(ctx, &opts.Common, spec.DataVersionGloas, + opts.BroadcastValidation, body, contentType) +} + +// postExecutionPayloadEnvelope performs the POST, setting the consensus +// version and stateless-form headers. +func (s *Service) postExecutionPayloadEnvelope(ctx context.Context, + common *api.CommonOpts, + consensusVersion spec.DataVersion, + broadcastValidation *apiv2.BroadcastValidation, + body []byte, + contentType ContentType, +) error { + endpoint := "/eth/v1/beacon/execution_payload_envelopes" + + query := "" + if broadcastValidation != nil { + query = "broadcast_validation=" + broadcastValidation.String() + } + + headers := make(map[string]string) + headers["Eth-Consensus-Version"] = strings.ToLower(consensusVersion.String()) + // Always the stateless form: header "true" selects the Contents body + // schema (signed envelope with its blobs and KZG proofs). Strict consensus + // clients reject the request when the header is missing. + headers["Eth-Blob-Data-Included"] = "true" + + if _, err := s.post(ctx, endpoint, query, common, bytes.NewBuffer(body), contentType, headers); err != nil { + return errors.Join(errors.New("failed to submit execution payload envelope"), err) + } + + return nil +} diff --git a/http/submitexecutionpayloadenvelope_test.go b/http/submitexecutionpayloadenvelope_test.go new file mode 100644 index 00000000..c74e8bc4 --- /dev/null +++ b/http/submitexecutionpayloadenvelope_test.go @@ -0,0 +1,121 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http_test + +import ( + "context" + "io" + nethttp "net/http" + "net/http/httptest" + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/http" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/stretchr/testify/require" +) + +func TestSubmitExecutionPayloadEnvelope(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + service := testService(ctx, t).(client.Service) + + tests := []struct { + name string + opts *api.SubmitExecutionPayloadEnvelopeOpts + err string + }{ + { + name: "NilOpts", + err: "no options specified", + }, + { + name: "NilEnvelope", + opts: &api.SubmitExecutionPayloadEnvelopeOpts{}, + err: "no envelope supplied", + }, + { + name: "UnsupportedVersion", + opts: &api.SubmitExecutionPayloadEnvelopeOpts{ + SignedExecutionPayloadEnvelope: &spec.VersionedSignedExecutionPayloadEnvelope{ + Version: spec.DataVersionPhase0, + }, + }, + err: "unsupported envelope version", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := service.(client.ExecutionPayloadEnvelopeSubmitter).SubmitExecutionPayloadEnvelope(ctx, test.opts) + require.ErrorContains(t, err, test.err) + }) + } + + t.Run("PostsEnvelope", func(t *testing.T) { + // The submission is recorded here and asserted on the test's own + // goroutine below. Asserting inside the handler would call + // runtime.Goexit from a goroutine that is not the test's, abandoning the + // request and reporting a wrong header as a transport error instead. + var ( + received bool + method string + headers nethttp.Header + body []byte + readErr error + ) + server := httptest.NewServer(nethttp.HandlerFunc(func(w nethttp.ResponseWriter, r *nethttp.Request) { + switch r.URL.Path { + case "/eth/v1/node/version": + _, _ = w.Write([]byte(`{"data":{"version":"test"}}`)) + case "/eth/v1/node/syncing": + _, _ = w.Write([]byte(`{"data":{"is_syncing":false,"is_optimistic":false,"el_offline":false,"head_slot":"1","sync_distance":"0"}}`)) + case "/eth/v1/beacon/execution_payload_envelopes": + received = true + method = r.Method + headers = r.Header.Clone() + body, readErr = io.ReadAll(r.Body) + w.WriteHeader(nethttp.StatusNoContent) + default: + w.WriteHeader(nethttp.StatusNotFound) + } + })) + defer server.Close() + + localService, err := http.New(ctx, http.WithAddress(server.URL)) + require.NoError(t, err) + err = localService.(client.ExecutionPayloadEnvelopeSubmitter).SubmitExecutionPayloadEnvelope(ctx, &api.SubmitExecutionPayloadEnvelopeOpts{ + SignedExecutionPayloadEnvelope: &spec.VersionedSignedExecutionPayloadEnvelope{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedExecutionPayloadEnvelope{ + Message: &gloas.ExecutionPayloadEnvelope{ + Payload: &gloas.ExecutionPayload{}, + ExecutionRequests: &gloas.ExecutionRequests{}, + }, + }, + }, + }) + require.NoError(t, err) + require.True(t, received) + require.Equal(t, nethttp.MethodPost, method) + require.Equal(t, "gloas", headers.Get("Eth-Consensus-Version")) + require.Equal(t, "true", headers.Get("Eth-Blob-Data-Included")) + require.Equal(t, "application/octet-stream", headers.Get("Content-Type")) + require.NoError(t, readErr) + require.NotEmpty(t, body) + }) +} diff --git a/http/submitpayloadattestationmessages.go b/http/submitpayloadattestationmessages.go new file mode 100644 index 00000000..5f048177 --- /dev/null +++ b/http/submitpayloadattestationmessages.go @@ -0,0 +1,125 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" +) + +// SubmitPayloadAttestationMessages submits payload attestation messages. +func (s *Service) SubmitPayloadAttestationMessages(ctx context.Context, + opts *api.SubmitPayloadAttestationMessagesOpts, +) error { + if err := s.assertIsSynced(ctx); err != nil { + return err + } + + if opts == nil { + return client.ErrNoOptions + } + + if len(opts.Messages) == 0 { + return errors.Join(errors.New("no payload attestation messages supplied"), client.ErrInvalidOptions) + } + + unversioned, version, err := createUnversionedPayloadAttestationMessages(opts.Messages) + if err != nil { + return err + } + + reqBody, err := json.Marshal(unversioned) + if err != nil { + return errors.Join(errors.New("failed to marshal JSON"), err) + } + + endpoint := "/eth/v1/beacon/pool/payload_attestations" + + // The endpoint requires the consensus version of the messages being + // submitted, and rejects the request outright without it. + headers := map[string]string{ + "Eth-Consensus-Version": strings.ToLower(version.String()), + } + + if _, err := s.post(ctx, + endpoint, + "", + &opts.Common, + bytes.NewReader(reqBody), + ContentTypeJSON, + headers, + ); err != nil { + return errors.Join(errors.New("failed to submit payload attestation messages"), err) + } + + return nil +} + +// createUnversionedPayloadAttestationMessages strips the version wrapper from +// the messages, returning the bare messages to place on the wire along with +// the version they share. +func createUnversionedPayloadAttestationMessages(messages []*spec.VersionedPayloadAttestationMessage) ( + []any, + spec.DataVersion, + error, +) { + var ( + version spec.DataVersion + unversioned = make([]any, 0, len(messages)) + ) + + for i := range messages { + if messages[i] == nil { + return nil, spec.DataVersionUnknown, + errors.Join(errors.New("nil payload attestation message supplied"), client.ErrInvalidOptions) + } + + // Ensure consistent versioning. + if version == spec.DataVersionUnknown { + version = messages[i].Version + } else if version != messages[i].Version { + return nil, spec.DataVersionUnknown, errors.Join( + errors.New("payload attestation messages must all be of the same version"), + client.ErrInvalidOptions, + ) + } + + switch messages[i].Version { + case spec.DataVersionGloas: + if messages[i].Gloas == nil { + // Appending an unchecked arm here would put a null in the + // request body rather than a message. + return nil, spec.DataVersionUnknown, + errors.Join(errors.New("no gloas payload attestation message supplied"), client.ErrInvalidOptions) + } + + unversioned = append(unversioned, messages[i].Gloas) + default: + return nil, spec.DataVersionUnknown, errors.Join( + fmt.Errorf("unsupported payload attestation message version %s", messages[i].Version), + client.ErrInvalidOptions, + ) + } + } + + return unversioned, version, nil +} diff --git a/http/submitpayloadattestationmessages_internal_test.go b/http/submitpayloadattestationmessages_internal_test.go new file mode 100644 index 00000000..04cac122 --- /dev/null +++ b/http/submitpayloadattestationmessages_internal_test.go @@ -0,0 +1,118 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "encoding/json" + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +func gloasMessage(validatorIndex phase0.ValidatorIndex) *spec.VersionedPayloadAttestationMessage { + return &spec.VersionedPayloadAttestationMessage{ + Version: spec.DataVersionGloas, + Gloas: &gloas.PayloadAttestationMessage{ + ValidatorIndex: validatorIndex, + Data: &gloas.PayloadAttestationData{ + BeaconBlockRoot: phase0.Root{0x01}, + Slot: 60300, + PayloadPresent: true, + BlobDataAvailable: true, + }, + Signature: phase0.BLSSignature{0xaa}, + }, + } +} + +// TestCreateUnversionedPayloadAttestationMessages covers the step that strips +// the version wrapper before the messages go on the wire. It is pure, so it +// needs no beacon node. +func TestCreateUnversionedPayloadAttestationMessages(t *testing.T) { + tests := []struct { + name string + messages []*spec.VersionedPayloadAttestationMessage + expected int + err string + }{ + { + name: "Single", + messages: []*spec.VersionedPayloadAttestationMessage{gloasMessage(8)}, + expected: 1, + }, + { + name: "Multiple", + messages: []*spec.VersionedPayloadAttestationMessage{gloasMessage(8), gloasMessage(18)}, + expected: 2, + }, + { + name: "NilMessage", + messages: []*spec.VersionedPayloadAttestationMessage{nil}, + err: "nil payload attestation message supplied", + }, + { + // Naming a version whose arm is nil must be rejected rather than + // appended: an unchecked arm marshals to a body of [null], which + // the node accepts as well-formed JSON and then mis-handles. + name: "NilArm", + messages: []*spec.VersionedPayloadAttestationMessage{ + {Version: spec.DataVersionGloas}, + }, + err: "no gloas payload attestation message supplied", + }, + { + name: "MixedVersions", + messages: []*spec.VersionedPayloadAttestationMessage{ + gloasMessage(8), + {Version: spec.DataVersionElectra}, + }, + err: "payload attestation messages must all be of the same version", + }, + { + name: "UnsupportedVersion", + messages: []*spec.VersionedPayloadAttestationMessage{ + {Version: spec.DataVersionElectra}, + }, + err: "unsupported payload attestation message version electra", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + unversioned, version, err := createUnversionedPayloadAttestationMessages(test.messages) + if test.err != "" { + require.ErrorContains(t, err, test.err) + require.ErrorIs(t, err, client.ErrInvalidOptions) + + return + } + + require.NoError(t, err) + require.Len(t, unversioned, test.expected) + require.Equal(t, spec.DataVersionGloas, version) + + // The body must be an array of bare messages, with no version + // wrapper and no nulls in it. + encoded, err := json.Marshal(unversioned) + require.NoError(t, err) + require.NotContains(t, string(encoded), "null") + require.NotContains(t, string(encoded), "\"version\"") + require.Contains(t, string(encoded), "\"validator_index\":\"8\"") + }) + } +} diff --git a/http/submitproposal.go b/http/submitproposal.go index b81e96ad..cbf22135 100644 --- a/http/submitproposal.go +++ b/http/submitproposal.go @@ -1,4 +1,4 @@ -// Copyright © 2023, 2024 Attestant Limited. +// Copyright © 2023 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -16,7 +16,6 @@ package http import ( "bytes" "context" - "encoding/json" "errors" "strings" @@ -55,6 +54,9 @@ func (s *Service) SubmitProposal(ctx context.Context, headers := make(map[string]string) headers["Eth-Consensus-Version"] = strings.ToLower(opts.Proposal.Version.String()) + if opts.BuilderURL != "" { + headers["Eth-Builder-Url"] = opts.BuilderURL + } _, err = s.post(ctx, endpoint, query, &opts.Common, bytes.NewBuffer(body), contentType, headers) if err != nil { @@ -71,103 +73,37 @@ func (s *Service) submitProposalData(ctx context.Context, ContentType, error, ) { - var ( - body []byte - contentType ContentType - err error - ) - - if s.enforceJSON { - contentType = ContentTypeJSON - body, err = s.submitProposalJSON(ctx, proposal) - } else { - contentType = ContentTypeSSZ - body, err = s.submitProposalSSZ(ctx, proposal) - } - - if err != nil { - return nil, ContentTypeUnknown, err - } - - return body, contentType, nil -} - -func (*Service) submitProposalJSON(_ context.Context, - proposal *api.VersionedSignedProposal, -) ( - []byte, - error, -) { - var ( - specJSON []byte - err error - ) if err := proposal.AssertPresent(); err != nil { - return nil, err - } - - switch proposal.Version { - case spec.DataVersionPhase0: - specJSON, err = json.Marshal(proposal.Phase0) - case spec.DataVersionAltair: - specJSON, err = json.Marshal(proposal.Altair) - case spec.DataVersionBellatrix: - specJSON, err = json.Marshal(proposal.Bellatrix) - case spec.DataVersionCapella: - specJSON, err = json.Marshal(proposal.Capella) - case spec.DataVersionDeneb: - specJSON, err = json.Marshal(proposal.Deneb) - case spec.DataVersionElectra: - specJSON, err = json.Marshal(proposal.Electra) - case spec.DataVersionFulu: - specJSON, err = json.Marshal(proposal.Fulu) - default: - err = errors.New("unknown proposal version") - } - - if err != nil { - return nil, errors.Join(errors.New("failed to marshal JSON"), err) + return nil, ContentTypeUnknown, err } - return specJSON, nil -} - -func (*Service) submitProposalSSZ(_ context.Context, - proposal *api.VersionedSignedProposal, -) ( - []byte, - error, -) { - var ( - specSSZ []byte - err error - ) - if err := proposal.AssertPresent(); err != nil { - return nil, err - } + var container any switch proposal.Version { case spec.DataVersionPhase0: - specSSZ, err = proposal.Phase0.MarshalSSZ() + container = proposal.Phase0 case spec.DataVersionAltair: - specSSZ, err = proposal.Altair.MarshalSSZ() + container = proposal.Altair case spec.DataVersionBellatrix: - specSSZ, err = proposal.Bellatrix.MarshalSSZ() + container = proposal.Bellatrix case spec.DataVersionCapella: - specSSZ, err = proposal.Capella.MarshalSSZ() + container = proposal.Capella case spec.DataVersionDeneb: - specSSZ, err = proposal.Deneb.MarshalSSZ() + container = proposal.Deneb case spec.DataVersionElectra: - specSSZ, err = proposal.Electra.MarshalSSZ() + container = proposal.Electra case spec.DataVersionFulu: - specSSZ, err = proposal.Fulu.MarshalSSZ() + container = proposal.Fulu + case spec.DataVersionGloas: + // A plain signed block, unlike the contents wrappers Deneb through Fulu + // publish: post-Gloas the blobs travel in the execution payload + // envelope, which is published through its own endpoint. + container = proposal.Gloas default: - err = errors.New("unknown proposal version") - } - - if err != nil { - return nil, errors.Join(errors.New("failed to marshal SSZ"), err) + // Unreachable: AssertPresent above rejects every version this switch + // does not name. Kept so the two cannot drift apart silently. + return nil, ContentTypeUnknown, errors.New("unknown proposal version") } - return specSSZ, nil + return s.marshalRequestBody(ctx, container) } diff --git a/http/submitproposal_builderurl_test.go b/http/submitproposal_builderurl_test.go new file mode 100644 index 00000000..a3377300 --- /dev/null +++ b/http/submitproposal_builderurl_test.go @@ -0,0 +1,65 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/attestantio/go-eth2-client/api" + eth2http "github.com/attestantio/go-eth2-client/http" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" +) + +func TestSubmitProposalEchoesBuilderURL(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/eth/v1/node/syncing": + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"data":{"head_slot":"0","sync_distance":"0","is_optimistic":false,"is_syncing":false}}`)) + require.NoError(t, err) + case "/eth/v2/beacon/blocks": + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "https://builder.example", r.Header.Get("Eth-Builder-Url")) + w.WriteHeader(http.StatusOK) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + service, err := eth2http.New(context.Background(), + eth2http.WithAddress(server.URL), + eth2http.WithAllowDelayedStart(true), + eth2http.WithEnforceJSON(true), + eth2http.WithLogLevel(zerolog.Disabled), + ) + require.NoError(t, err) + + err = service.(interface { + SubmitProposal(context.Context, *api.SubmitProposalOpts) error + }).SubmitProposal(context.Background(), &api.SubmitProposalOpts{ + Proposal: &api.VersionedSignedProposal{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedBeaconBlock{Message: &gloas.BeaconBlock{}}, + }, + BuilderURL: "https://builder.example", + }) + require.NoError(t, err) +} diff --git a/http/submitproposal_internal_test.go b/http/submitproposal_internal_test.go new file mode 100644 index 00000000..48fb0ce6 --- /dev/null +++ b/http/submitproposal_internal_test.go @@ -0,0 +1,271 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "context" + "encoding/json" + "testing" + + bitfield "github.com/OffchainLabs/go-bitfield" + "github.com/attestantio/go-eth2-client/api" + apiv1deneb "github.com/attestantio/go-eth2-client/api/v1/deneb" + apiv1electra "github.com/attestantio/go-eth2-client/api/v1/electra" + apiv1fulu "github.com/attestantio/go-eth2-client/api/v1/fulu" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// gloasSignedProposal wraps a block in the shape the publish endpoint takes for +// gloas. +// +// Note this is a plain SignedBeaconBlock, not a contents wrapper as Deneb +// through Fulu use. Post-Gloas the blobs travel in the execution payload +// envelope, which is published separately, so there is nothing to bundle with +// the block. +func gloasSignedProposal() *api.VersionedSignedProposal { + return &api.VersionedSignedProposal{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedBeaconBlock{ + Message: validEPBSBeaconBlock(), + Signature: phase0.BLSSignature{0x0a, 0x0b, 0x0c}, + }, + } +} + +// TestSubmitProposalDataStaticCodecParity pins two things about the default +// service, the one that has not opted in to WithCustomSpecSupport and so encodes +// against the compiled-in mainnet preset. +// +// The first is that routing the body through the dynamic codec left the wire +// format alone: for a type whose sizes are all static the dynamic codec +// delegates to the very generated codec this used to call, so every arm must +// still produce the bytes that codec produces, byte for byte. The second is +// that each version reaches for the right container, which nothing else checks — +// the arms are eight hand-written lines and a wrong one still compiles. +func TestSubmitProposalDataStaticCodecParity(t *testing.T) { + ctx := context.Background() + s := &Service{} + + // Each fixture sits at a slot unique to its fork, so an arm reaching for the + // wrong container encodes a different valid block rather than a nil one -- + // two empty containers could encode identically and let a mis-wire pass. + proposal := api.VersionedSignedProposal{ + Phase0: &phase0.SignedBeaconBlock{Message: &phase0.BeaconBlock{Slot: 100}}, + Altair: &altair.SignedBeaconBlock{Message: &altair.BeaconBlock{Slot: 200}}, + Bellatrix: &bellatrix.SignedBeaconBlock{Message: &bellatrix.BeaconBlock{Slot: 300}}, + Capella: &capella.SignedBeaconBlock{Message: &capella.BeaconBlock{Slot: 400}}, + Deneb: &apiv1deneb.SignedBlockContents{ + SignedBlock: &deneb.SignedBeaconBlock{Message: &deneb.BeaconBlock{Slot: 500}}, + }, + Electra: &apiv1electra.SignedBlockContents{ + SignedBlock: &electra.SignedBeaconBlock{Message: &electra.BeaconBlock{Slot: 600}}, + }, + Fulu: &apiv1fulu.SignedBlockContents{ + SignedBlock: &electra.SignedBeaconBlock{Message: &electra.BeaconBlock{Slot: 700}}, + }, + Gloas: &gloas.SignedBeaconBlock{Message: &gloas.BeaconBlock{Slot: 800}}, + } + + tests := []struct { + name string + version spec.DataVersion + want []byte + }{ + {name: "Phase0", version: spec.DataVersionPhase0, want: mustMarshalSSZ(t, proposal.Phase0)}, + {name: "Altair", version: spec.DataVersionAltair, want: mustMarshalSSZ(t, proposal.Altair)}, + {name: "Bellatrix", version: spec.DataVersionBellatrix, want: mustMarshalSSZ(t, proposal.Bellatrix)}, + {name: "Capella", version: spec.DataVersionCapella, want: mustMarshalSSZ(t, proposal.Capella)}, + {name: "Deneb", version: spec.DataVersionDeneb, want: mustMarshalSSZ(t, proposal.Deneb)}, + {name: "Electra", version: spec.DataVersionElectra, want: mustMarshalSSZ(t, proposal.Electra)}, + {name: "Fulu", version: spec.DataVersionFulu, want: mustMarshalSSZ(t, proposal.Fulu)}, + {name: "Gloas", version: spec.DataVersionGloas, want: mustMarshalSSZ(t, proposal.Gloas)}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + versioned := proposal + versioned.Version = test.version + + body, contentType, err := s.submitProposalData(ctx, &versioned) + require.NoError(t, err) + require.Equal(t, ContentTypeSSZ, contentType) + require.Equal(t, test.want, body) + }) + } +} + +// TestSubmitProposalDataAtTheNodesPreset covers the request body a service that +// has opted in to WithCustomSpecSupport produces. +// +// A block body holds a preset-sized sync committee bitvector from Altair +// onwards — 512 bits at the mainnet preset, 32 at minimal — and SSZ addresses +// the variable-size fields that follow it by offsets, so the whole body shifts +// by 60 bytes between the two. Encoding with the generated codec, which has +// the mainnet preset baked in, therefore produces a body the node cannot read. +// It does so silently: the codec pads a short bitvector out to the mainnet +// length and returns no error, so the only place the mismatch is observable is +// at a decoder holding the node's own spec. That is what this asserts against. +func TestSubmitProposalDataAtTheNodesPreset(t *testing.T) { + ctx := context.Background() + custom := customSpecService(ctx, t) + + // The bitvector is the preset-derived field that shifts the offsets, so the + // fixture has to be sized from the node's spec rather than from the + // compiled-in preset to be a block that node would accept at all. + specResponse, err := custom.Spec(ctx, &api.SpecOpts{}) + require.NoError(t, err) + syncCommitteeSize, isUint64 := specResponse.Data["SYNC_COMMITTEE_SIZE"].(uint64) + require.True(t, isUint64, "SYNC_COMMITTEE_SIZE missing from spec") + + proposal := gloasSignedProposal() + proposal.Gloas.Message.Body.SyncAggregate.SyncCommitteeBits = make(bitfield.Bitvector512, syncCommitteeSize/8) + + body, contentType, err := custom.submitProposalData(ctx, proposal) + require.NoError(t, err) + require.Equal(t, ContentTypeSSZ, contentType) + + // Decode with a codec built from the node's spec -- the same one the node + // uses, so a body it rejects is a body the node rejects. + ds, err := custom.dynSSZForRequest(ctx) + require.NoError(t, err) + + var decoded gloas.SignedBeaconBlock + require.NoError(t, ds.UnmarshalSSZ(&decoded, body)) + require.Equal(t, phase0.Slot(60300), decoded.Message.Slot) + require.Len(t, decoded.Message.Body.SyncAggregate.SyncCommitteeBits, int(syncCommitteeSize/8)) +} + +// TestSubmitProposalGloasMarshaling covers the gloas arm of the request-body +// switch, on both content types. Without that arm the version falls through to +// "unknown proposal version", so the block never leaves the process and no +// server-side test can tell why. +func TestSubmitProposalGloasMarshaling(t *testing.T) { + ctx := context.Background() + + // Both services are zero-valued but for the flag under test, so neither + // reaches a beacon node: the SSZ one has customSpecSupport false, which is + // the branch of dynSSZForRequest that reads nothing from the service. + s := &Service{} + jsonService := &Service{enforceJSON: true} + + t.Run("JSON", func(t *testing.T) { + body, contentType, err := jsonService.submitProposalData(ctx, gloasSignedProposal()) + require.NoError(t, err) + require.Equal(t, ContentTypeJSON, contentType) + + // The body is the signed block itself, unwrapped, so it must carry the + // block's own keys rather than a contents envelope's. + var decoded map[string]json.RawMessage + require.NoError(t, json.Unmarshal(body, &decoded)) + require.Contains(t, decoded, "message") + require.Contains(t, decoded, "signature") + require.NotContains(t, decoded, "signed_block") + }) + + t.Run("SSZ", func(t *testing.T) { + body, contentType, err := s.submitProposalData(ctx, gloasSignedProposal()) + require.NoError(t, err) + require.Equal(t, ContentTypeSSZ, contentType) + require.NotEmpty(t, body) + + // Round-trips back to the same block, which is what tells us the arm + // marshaled the right container. + var decoded gloas.SignedBeaconBlock + require.NoError(t, decoded.UnmarshalSSZ(body)) + require.Equal(t, phase0.Slot(60300), decoded.Message.Slot) + }) + + // A version claiming gloas with no gloas arm populated must be refused + // before anything is marshaled: the switch runs on Version alone, so an + // unchecked nil arm marshals as JSON null, and as a zero-valued block on the + // SSZ path, where the generated codecs treat a nil container as a zero one. + // + // Only the SSZ service is exercised here, and in the two subtests below: the + // presence check runs ahead of the content-type branch, so both services + // travel the same code to reach this error. + t.Run("MissingArm", func(t *testing.T) { + proposal := &api.VersionedSignedProposal{Version: spec.DataVersionGloas} + + require.ErrorContains(t, proposal.AssertPresent(), "gloas proposal not present") + + _, _, err := s.submitProposalData(ctx, proposal) + require.ErrorContains(t, err, "gloas proposal not present") + }) + + // There is no blinded proposal post-Gloas, so a proposal marked blinded is a + // caller that believes it is withholding a payload the block never carried. + // Ignoring the flag would publish a plain block while the caller thought it + // had published a blinded one, so it is refused instead. + t.Run("MarkedBlinded", func(t *testing.T) { + proposal := gloasSignedProposal() + proposal.Blinded = true + + require.ErrorContains(t, proposal.AssertPresent(), "gloas proposals are never blinded") + + _, _, err := s.submitProposalData(ctx, proposal) + require.ErrorContains(t, err, "gloas proposals are never blinded") + }) + + // An unrecognised version has to be refused before encoding as well, and it + // is the presence check rather than the switch that does it. + t.Run("UnknownVersion", func(t *testing.T) { + proposal := &api.VersionedSignedProposal{Version: spec.DataVersion(99)} + + _, _, err := s.submitProposalData(ctx, proposal) + require.ErrorContains(t, err, "unsupported version") + }) + + // The wrapper's accessors have to know about the arm as well as its + // marshalers do. Nothing in this repository calls them — the submitter + // itself does not — so a missing arm compiles, passes, and surfaces only in a + // consumer that inspects a proposal the same way it does for every other + // fork, and then reports the fork as unsupported while holding a valid block. + t.Run("Accessors", func(t *testing.T) { + proposal := gloasSignedProposal() + + slot, err := proposal.Slot() + require.NoError(t, err) + require.Equal(t, phase0.Slot(60300), slot) + + proposerIndex, err := proposal.ProposerIndex() + require.NoError(t, err) + require.Equal(t, phase0.ValidatorIndex(403), proposerIndex) + + require.NotEmpty(t, proposal.String()) + require.Contains(t, proposal.String(), "60300") + }) + + // The accessors funnel through a presence check, so a version claiming gloas + // with an empty arm must report the data as missing rather than the fork as + // unsupported. + t.Run("AccessorsWithMissingArm", func(t *testing.T) { + proposal := &api.VersionedSignedProposal{Version: spec.DataVersionGloas} + + _, err := proposal.Slot() + require.ErrorIs(t, err, api.ErrDataMissing) + + _, err = proposal.ProposerIndex() + require.ErrorIs(t, err, api.ErrDataMissing) + + require.Empty(t, proposal.String()) + }) +} diff --git a/http/submitproposal_test.go b/http/submitproposal_test.go index 51a21d7d..bb1ea9a2 100644 --- a/http/submitproposal_test.go +++ b/http/submitproposal_test.go @@ -15,6 +15,7 @@ package http_test import ( "context" + "strings" "testing" "time" @@ -23,16 +24,98 @@ import ( apiv1deneb "github.com/attestantio/go-eth2-client/api/v1/deneb" apiv1electra "github.com/attestantio/go-eth2-client/api/v1/electra" apiv1fulu "github.com/attestantio/go-eth2-client/api/v1/fulu" + "github.com/attestantio/go-eth2-client/http" "github.com/attestantio/go-eth2-client/spec" "github.com/attestantio/go-eth2-client/spec/altair" "github.com/attestantio/go-eth2-client/spec/bellatrix" "github.com/attestantio/go-eth2-client/spec/capella" "github.com/attestantio/go-eth2-client/spec/deneb" "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/stretchr/testify/require" ) +// TestSubmitProposalGloas verifies that a gloas block produced by the node comes +// back to it through the publish endpoint, in a body the node can read. +// +// The block cannot actually be accepted: it is unsigned, and a node only builds +// for a slot that has not started yet, so by the time it is submitted the node +// refuses it as being from the future. Neither matters here. What is being +// checked is where the refusal comes from — the node, having decoded the block +// and carried it as far as state transition, rather than the client declining to +// encode a version it does not know, or the node rejecting a body it cannot +// parse. Both of those are what a missing or wrong arm would produce. +// +// Only the JSON body is exercised. The SSZ encoder marshals with the generated +// codec, which is mainnet-preset only, and this devnet runs the minimal preset +// (see the note on that arm). +func TestSubmitProposalGloas(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + service := testService(ctx, t).(client.Service) + + requireOnGloas(ctx, t, service) + + jsonService, err := newTestService(ctx, true, http.WithEnforceJSON(true)) + require.NoError(t, err) + + // Produced with the payload excluded, so the block travels alone: the + // envelope would be published through its own endpoint. + includePayload := false + infinity := infinitySignature() + + // Produced through jsonService rather than the shared one: the shared service + // has no custom spec support, so on the minimal preset this devnet runs it + // would fail to decode the block the node sends back over SSZ, and this test + // would never reach the publish path it exists to exercise. + produced, err := jsonService.(client.EPBSProposalProvider).EPBSProposal(ctx, &api.EPBSProposalOpts{ + Slot: headSlot(ctx, t, service) + 1, + RandaoReveal: infinity, + IncludePayload: &includePayload, + SkipRandaoVerification: true, + // Only a block to submit is wanted here, so no builder bids are + // solicited; the builder-request body is covered by TestEPBSProposal. + BuilderConfig: localPreferredBuilderConfig(), + }) + require.NoError(t, err) + require.NotNil(t, produced.Data.Gloas) + + err = jsonService.(client.ProposalSubmitter).SubmitProposal(ctx, &api.SubmitProposalOpts{ + Proposal: &api.VersionedSignedProposal{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedBeaconBlock{ + Message: produced.Data.Gloas, + // The point at infinity, not an arbitrary byte pattern: this + // has to be a decodable compressed G2 point or the node fails + // in deserialisation, which is the one outcome the assertions + // below are there to rule out. It is still not a valid + // signature for the block, so the refusal arrives from + // verification, having decoded it. + Signature: infinitySignature(), + }, + }, + }) + require.Error(t, err) + + // A typed api.Error means the request was made and answered. The client + // refusing to marshal an unknown version never reaches the network, so it + // cannot produce one. + var apiErr *api.Error + require.ErrorAs(t, err, &apiErr, "expected the node to refuse the block, got a client-side error: %v", err) + + // And the node's complaint must be about the block rather than about the + // bytes. A body it could not parse — the shape a wrong arm would send, for + // instance a contents wrapper where a plain signed block belongs — is + // rejected during deserialisation, naming the field it choked on. + rejection := strings.ToLower(string(apiErr.Data)) + for _, parseFailure := range []string{"unmarshal", "could not decode", "invalid json", "malformed"} { + require.NotContains(t, rejection, parseFailure, + "node could not parse the submitted block: %s", string(apiErr.Data)) + } +} + func TestSubmitProposal(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/http/submitproposerpreferences.go b/http/submitproposerpreferences.go new file mode 100644 index 00000000..fc7fe194 --- /dev/null +++ b/http/submitproposerpreferences.go @@ -0,0 +1,134 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "bytes" + "context" + "errors" + "fmt" + nethttp "net/http" + "strings" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" +) + +type proposerPreferencesList []*gloas.SignedProposerPreferences + +// staticProposerPreferencesLimit is the mainnet-preset proposer lookahead length, +// used whenever the chain's own values are not available. +const staticProposerPreferencesLimit uint64 = 64 + +// proposerPreferencesLimit returns the maximum number of proposer preferences that +// may be submitted at once: the proposer lookahead length, +// (MIN_SEED_LOOKAHEAD + 1) * SLOTS_PER_EPOCH. +// +// This is a client-side sanity bound and the node remains the authority on what it +// accepts, so every way of failing to derive the chain's own value falls back to the +// mainnet preset rather than failing the submission. That is what allows the limit +// to be derived unconditionally: Spec() is cached, and a node that does not publish +// the spec, or publishes it without these keys, costs a fallback rather than an error. +func (s *Service) proposerPreferencesLimit(ctx context.Context) uint64 { + response, err := s.Spec(ctx, &api.SpecOpts{}) + if err != nil { + return staticProposerPreferencesLimit + } + + minSeedLookahead, ok := response.Data["MIN_SEED_LOOKAHEAD"].(uint64) + if !ok { + return staticProposerPreferencesLimit + } + slotsPerEpoch, ok := response.Data["SLOTS_PER_EPOCH"].(uint64) + if !ok { + return staticProposerPreferencesLimit + } + if minSeedLookahead == ^uint64(0) || slotsPerEpoch > ^uint64(0)/(minSeedLookahead+1) { + return staticProposerPreferencesLimit + } + + return (minSeedLookahead + 1) * slotsPerEpoch +} + +// MarshalSSZ encodes the list as an SSZ list of fixed-size elements, which is the +// concatenation of the elements' own encodings. +func (p proposerPreferencesList) MarshalSSZ() ([]byte, error) { + body := make([]byte, 0, len(p)*(&gloas.SignedProposerPreferences{}).SizeSSZ()) + for _, preference := range p { + encoded, err := preference.MarshalSSZ() + if err != nil { + return nil, err + } + body = append(body, encoded...) + } + + return body, nil +} + +// SubmitProposerPreferences submits signed proposer preferences. +func (s *Service) SubmitProposerPreferences(ctx context.Context, opts *api.SubmitProposerPreferencesOpts) error { + if err := s.assertIsSynced(ctx); err != nil { + return err + } + + if opts == nil { + return client.ErrNoOptions + } + + if uint64(len(opts.Preferences)) > s.proposerPreferencesLimit(ctx) { + return errors.Join(errors.New("too many proposer preferences"), client.ErrInvalidOptions) + } + for _, preference := range opts.Preferences { + if preference == nil { + return errors.Join(errors.New("nil proposer preference supplied"), client.ErrInvalidOptions) + } + // SSZ marshaling substitutes a zero value for a nil message, so without this + // check the same input would silently submit zeroed preferences over SSZ and + // be rejected by the node as a null message over JSON. + if preference.Message == nil { + return errors.Join(errors.New("nil proposer preference message supplied"), client.ErrInvalidOptions) + } + } + + requestPreferences := proposerPreferencesList(opts.Preferences) + if requestPreferences == nil { + requestPreferences = proposerPreferencesList{} + } + body, contentType, err := s.marshalRequestBody(ctx, requestPreferences) + if err != nil { + return err + } + + response, err := s.post(ctx, + "/eth/v1/validator/proposer_preferences", + "", + &opts.Common, + bytes.NewReader(body), + contentType, + map[string]string{"Eth-Consensus-Version": strings.ToLower(spec.DataVersionGloas.String())}, + ) + if err != nil { + return errors.Join(errors.New("failed to submit proposer preferences"), err) + } + if response.statusCode != nethttp.StatusOK { + return errors.Join( + errors.New("failed to submit proposer preferences"), + fmt.Errorf("unexpected status code %d", response.statusCode), + ) + } + + return nil +} diff --git a/http/submitproposerpreferences_test.go b/http/submitproposerpreferences_test.go new file mode 100644 index 00000000..873560a8 --- /dev/null +++ b/http/submitproposerpreferences_test.go @@ -0,0 +1,401 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http_test + +import ( + "context" + "encoding/json" + "fmt" + "io" + nethttp "net/http" + "net/http/httptest" + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + clienthttp "github.com/attestantio/go-eth2-client/http" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/stretchr/testify/require" +) + +func TestSubmitProposerPreferencesPosts(t *testing.T) { + tests := []struct { + name string + enforceJSON bool + }{ + {name: "SSZ"}, + {name: "JSON", enforceJSON: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx := context.Background() + received := false + server := httptest.NewServer(nethttp.HandlerFunc(func(w nethttp.ResponseWriter, r *nethttp.Request) { + switch r.URL.Path { + case "/eth/v1/node/version": + _, _ = w.Write([]byte(`{"data":{"version":"test"}}`)) + case "/eth/v1/node/syncing": + _, _ = w.Write([]byte(`{"data":{"is_syncing":false,"is_optimistic":false,"el_offline":false,"head_slot":"1","sync_distance":"0"}}`)) + case "/eth/v1/validator/proposer_preferences": + received = true + require.Equal(t, nethttp.MethodPost, r.Method) + require.Equal(t, "gloas", r.Header.Get("Eth-Consensus-Version")) + if test.enforceJSON { + require.Equal(t, "application/json", r.Header.Get("Content-Type")) + var body []json.RawMessage + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + require.Len(t, body, 1) + } else { + require.Equal(t, "application/octet-stream", r.Header.Get("Content-Type")) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.Len(t, body, 172) + decoded := &gloas.SignedProposerPreferences{} + require.NoError(t, decoded.UnmarshalSSZ(body)) + } + w.WriteHeader(nethttp.StatusOK) + default: + w.WriteHeader(nethttp.StatusNotFound) + } + })) + defer server.Close() + + params := []clienthttp.Parameter{clienthttp.WithAddress(server.URL)} + if test.enforceJSON { + params = append(params, clienthttp.WithEnforceJSON(true)) + } + service, err := clienthttp.New(ctx, params...) + require.NoError(t, err) + err = service.(client.ProposerPreferencesSubmitter). + SubmitProposerPreferences(ctx, preferencesOpts(onePreference())) + require.NoError(t, err) + require.True(t, received) + }) + } +} + +func TestSubmitProposerPreferencesReportsTransportError(t *testing.T) { + ctx := context.Background() + received := false + server := httptest.NewServer(nethttp.HandlerFunc(func(w nethttp.ResponseWriter, r *nethttp.Request) { + switch r.URL.Path { + case "/eth/v1/node/version": + _, _ = w.Write([]byte(`{"data":{"version":"test"}}`)) + case "/eth/v1/node/syncing": + _, _ = w.Write([]byte(`{"data":{"is_syncing":false,"is_optimistic":false,"el_offline":false,"head_slot":"1","sync_distance":"0"}}`)) + case "/eth/v1/validator/proposer_preferences": + received = true + require.Equal(t, nethttp.MethodPost, r.Method) + require.Equal(t, "gloas", r.Header.Get("Eth-Consensus-Version")) + w.WriteHeader(nethttp.StatusBadRequest) + default: + w.WriteHeader(nethttp.StatusNotFound) + } + })) + defer server.Close() + + service, err := clienthttp.New(ctx, clienthttp.WithAddress(server.URL)) + require.NoError(t, err) + err = service.(client.ProposerPreferencesSubmitter). + SubmitProposerPreferences(ctx, preferencesOpts(onePreference())) + require.ErrorContains(t, err, "failed to submit proposer preferences") + require.True(t, received) +} + +func TestSubmitProposerPreferencesRequiresOptions(t *testing.T) { + received := false + server := proposerPreferencesServer(t, nethttp.StatusOK, &received) + defer server.Close() + + service, err := clienthttp.New(context.Background(), clienthttp.WithAddress(server.URL)) + require.NoError(t, err) + err = service.(client.ProposerPreferencesSubmitter).SubmitProposerPreferences(context.Background(), nil) + require.ErrorIs(t, err, client.ErrNoOptions) + require.False(t, received) +} + +func TestSubmitProposerPreferencesRequiresExactStatusOK(t *testing.T) { + ctx := context.Background() + server := proposerPreferencesServer(t, nethttp.StatusNoContent, nil) + defer server.Close() + + service, err := clienthttp.New(ctx, clienthttp.WithAddress(server.URL)) + require.NoError(t, err) + err = service.(client.ProposerPreferencesSubmitter). + SubmitProposerPreferences(ctx, preferencesOpts(onePreference())) + require.EqualError(t, err, "failed to submit proposer preferences\nunexpected status code 204") +} + +// TestSubmitProposerPreferencesEnforcesStaticLimit covers a node that does not +// publish the spec at all: the mainnet preset stands in for the chain's own +// lookahead length rather than the submission failing. +func TestSubmitProposerPreferencesEnforcesStaticLimit(t *testing.T) { + for _, test := range []struct { + name string + count int + err string + }{ + {name: "AtLimit", count: 64}, + {name: "OneOverLimit", count: 65, err: "too many proposer preferences"}, + } { + t.Run(test.name, func(t *testing.T) { + received := false + server := proposerPreferencesServer(t, nethttp.StatusOK, &received) + defer server.Close() + service, err := clienthttp.New(context.Background(), clienthttp.WithAddress(server.URL)) + require.NoError(t, err) + err = service.(client.ProposerPreferencesSubmitter). + SubmitProposerPreferences(context.Background(), preferencesOpts(makePreferences(test.count))) + if test.err != "" { + require.ErrorContains(t, err, test.err) + require.False(t, received) + } else { + require.NoError(t, err) + require.True(t, received) + } + }) + } +} + +func TestSubmitProposerPreferencesChecksLimitBeforeNilElements(t *testing.T) { + received := false + server := proposerPreferencesServer(t, nethttp.StatusOK, &received) + defer server.Close() + + preferences := makePreferences(65) + preferences[0] = nil + service, err := clienthttp.New(context.Background(), clienthttp.WithAddress(server.URL)) + require.NoError(t, err) + err = service.(client.ProposerPreferencesSubmitter). + SubmitProposerPreferences(context.Background(), preferencesOpts(preferences)) + require.ErrorContains(t, err, "too many proposer preferences") + require.ErrorIs(t, err, client.ErrInvalidOptions) + require.False(t, received) +} + +// TestSubmitProposerPreferencesUsesSpecDerivedLimit covers a minimal-preset chain +// whose lookahead is shorter than mainnet's. The limit comes from the spec +// without WithCustomSpecSupport, which selects SSZ codecs rather than governing +// whether the spec is available. +func TestSubmitProposerPreferencesUsesSpecDerivedLimit(t *testing.T) { + for _, test := range []struct { + name string + count int + err string + }{ + {name: "AtLimit", count: 4}, + {name: "OneOverLimit", count: 5, err: "too many proposer preferences"}, + } { + t.Run(test.name, func(t *testing.T) { + received := false + server := proposerPreferencesServerWithSpec(t, nethttp.StatusOK, &received, 1, 2) + defer server.Close() + service, err := clienthttp.New(context.Background(), clienthttp.WithAddress(server.URL)) + require.NoError(t, err) + err = service.(client.ProposerPreferencesSubmitter). + SubmitProposerPreferences(context.Background(), preferencesOpts(makePreferences(test.count))) + if test.err != "" { + require.ErrorContains(t, err, test.err) + require.False(t, received) + } else { + require.NoError(t, err) + require.True(t, received) + } + }) + } +} + +func TestSubmitProposerPreferencesRejectsNilElement(t *testing.T) { + for _, test := range []struct { + name string + enforceJSON bool + }{ + {name: "SSZ"}, + {name: "JSON", enforceJSON: true}, + } { + t.Run(test.name, func(t *testing.T) { + received := false + server := proposerPreferencesServer(t, nethttp.StatusOK, &received) + defer server.Close() + + params := []clienthttp.Parameter{clienthttp.WithAddress(server.URL)} + if test.enforceJSON { + params = append(params, clienthttp.WithEnforceJSON(true)) + } + service, err := clienthttp.New(context.Background(), params...) + require.NoError(t, err) + err = service.(client.ProposerPreferencesSubmitter). + SubmitProposerPreferences(context.Background(), preferencesOpts([]*gloas.SignedProposerPreferences{nil})) + require.ErrorContains(t, err, "nil proposer preference supplied") + require.ErrorIs(t, err, client.ErrInvalidOptions) + require.False(t, received) + }) + } +} + +func TestSubmitProposerPreferencesRejectsNilMessage(t *testing.T) { + received := false + server := proposerPreferencesServer(t, nethttp.StatusOK, &received) + defer server.Close() + + service, err := clienthttp.New(context.Background(), clienthttp.WithAddress(server.URL)) + require.NoError(t, err) + err = service.(client.ProposerPreferencesSubmitter). + SubmitProposerPreferences(context.Background(), preferencesOpts([]*gloas.SignedProposerPreferences{{}})) + require.ErrorContains(t, err, "nil proposer preference message supplied") + require.ErrorIs(t, err, client.ErrInvalidOptions) + require.False(t, received) +} + +// TestSubmitProposerPreferencesFallsBackWhenSpecIncomplete covers a node that +// publishes the spec but omits one of the two keys the limit is derived from. +func TestSubmitProposerPreferencesFallsBackWhenSpecIncomplete(t *testing.T) { + received := false + server := preferencesServer(t, nethttp.StatusOK, &received, `{"data":{"SLOTS_PER_EPOCH":"32"}}`) + defer server.Close() + + service, err := clienthttp.New(context.Background(), clienthttp.WithAddress(server.URL)) + require.NoError(t, err) + err = service.(client.ProposerPreferencesSubmitter). + SubmitProposerPreferences(context.Background(), preferencesOpts(makePreferences(64))) + require.NoError(t, err) + require.True(t, received) +} + +func TestSubmitProposerPreferencesAllowsEmptyList(t *testing.T) { + for _, test := range []struct { + name string + enforceJSON bool + }{ + {name: "SSZ"}, + {name: "JSON", enforceJSON: true}, + } { + t.Run(test.name, func(t *testing.T) { + received := false + server := emptyPreferencesServer(t, test.enforceJSON, &received) + defer server.Close() + + params := []clienthttp.Parameter{clienthttp.WithAddress(server.URL)} + if test.enforceJSON { + params = append(params, clienthttp.WithEnforceJSON(true)) + } + service, err := clienthttp.New(context.Background(), params...) + require.NoError(t, err) + err = service.(client.ProposerPreferencesSubmitter). + SubmitProposerPreferences(context.Background(), &api.SubmitProposerPreferencesOpts{}) + require.NoError(t, err) + require.True(t, received) + }) + } +} + +func preferencesOpts(preferences []*gloas.SignedProposerPreferences) *api.SubmitProposerPreferencesOpts { + return &api.SubmitProposerPreferencesOpts{Preferences: preferences} +} + +func onePreference() []*gloas.SignedProposerPreferences { + return []*gloas.SignedProposerPreferences{{Message: &gloas.ProposerPreferences{}}} +} + +func makePreferences(count int) []*gloas.SignedProposerPreferences { + preferences := make([]*gloas.SignedProposerPreferences, count) + for i := range preferences { + preferences[i] = onePreference()[0] + } + + return preferences +} + +// proposerPreferencesServer serves a node that does not publish the spec, so the +// static limit applies. +func proposerPreferencesServer(t *testing.T, status int, received *bool) *httptest.Server { + t.Helper() + + return preferencesServer(t, status, received, "") +} + +// proposerPreferencesServerWithSpec serves a node publishing both of the keys the +// limit is derived from. +func proposerPreferencesServerWithSpec(t *testing.T, + status int, + received *bool, + minSeedLookahead, slotsPerEpoch uint64, +) *httptest.Server { + t.Helper() + + return preferencesServer(t, status, received, + fmt.Sprintf(`{"data":{"MIN_SEED_LOOKAHEAD":"%d","SLOTS_PER_EPOCH":"%d"}}`, minSeedLookahead, slotsPerEpoch)) +} + +// preferencesServer serves the submission endpoint with the given status. An +// empty specResponse serves a node with no spec endpoint at all. +func preferencesServer(t *testing.T, status int, received *bool, specResponse string) *httptest.Server { + t.Helper() + + return httptest.NewServer(nethttp.HandlerFunc(func(w nethttp.ResponseWriter, r *nethttp.Request) { + switch r.URL.Path { + case "/eth/v1/node/version": + _, _ = w.Write([]byte(`{"data":{"version":"test"}}`)) + case "/eth/v1/node/syncing": + _, _ = w.Write([]byte(`{"data":{"is_syncing":false,"is_optimistic":false,"el_offline":false,"head_slot":"1","sync_distance":"0"}}`)) + case "/eth/v1/config/spec": + if specResponse == "" { + w.WriteHeader(nethttp.StatusNotFound) + + return + } + _, _ = w.Write([]byte(specResponse)) + case "/eth/v1/validator/proposer_preferences": + if received != nil { + *received = true + } + w.WriteHeader(status) + default: + w.WriteHeader(nethttp.StatusNotFound) + } + })) +} + +func emptyPreferencesServer(t *testing.T, enforceJSON bool, received *bool) *httptest.Server { + t.Helper() + + return httptest.NewServer(nethttp.HandlerFunc(func(w nethttp.ResponseWriter, r *nethttp.Request) { + switch r.URL.Path { + case "/eth/v1/node/version": + _, _ = w.Write([]byte(`{"data":{"version":"test"}}`)) + case "/eth/v1/node/syncing": + _, _ = w.Write([]byte(`{"data":{"is_syncing":false,"is_optimistic":false,"el_offline":false,"head_slot":"1","sync_distance":"0"}}`)) + case "/eth/v1/validator/proposer_preferences": + *received = true + require.Equal(t, nethttp.MethodPost, r.Method) + if enforceJSON { + require.Equal(t, "application/json", r.Header.Get("Content-Type")) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.Equal(t, "[]", string(body)) + var decoded []json.RawMessage + require.NoError(t, json.Unmarshal(body, &decoded)) + require.Empty(t, decoded) + } else { + require.Equal(t, "application/octet-stream", r.Header.Get("Content-Type")) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.Empty(t, body) + } + w.WriteHeader(nethttp.StatusOK) + default: + w.WriteHeader(nethttp.StatusNotFound) + } + })) +} diff --git a/internal/eventdispatch/eventdispatch.go b/internal/eventdispatch/eventdispatch.go new file mode 100644 index 00000000..57c00886 --- /dev/null +++ b/internal/eventdispatch/eventdispatch.go @@ -0,0 +1,238 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package eventdispatch passes the events of each topic to its handler in api.EventsOpts. +package eventdispatch + +import ( + "context" + "errors" + "fmt" + "slices" + + "github.com/attestantio/go-eth2-client/api" + apiv1 "github.com/attestantio/go-eth2-client/api/v1" + "github.com/attestantio/go-eth2-client/internal/eventtopic" + "github.com/rs/zerolog" +) + +// topicHandler is the handling in api.EventsOpts of the events of a single topic. +type topicHandler struct { + // isSet reports whether the options carry a handler specific to the topic. + isSet func(opts *api.EventsOpts) bool + // handle decodes the data of an event of the topic and passes it to the topic's handler in + // the options, or failing that to their generic handler. + handle func(ctx context.Context, opts *api.EventsOpts, data []byte) error + // filter sets the topic's handler in dst to one that passes events to the handler in src + // when forward allows it, leaving it nil if src has none. + filter func(dst *api.EventsOpts, src *api.EventsOpts, forward func(topic string) bool) +} + +// topicHandlers binds each field of api.EventsOpts holding the handler for a topic to that +// topic, by the name of the topic. It is the only list of those fields. Each field's topic is +// the one whose data decodes into the type its handler takes, so no topic is named here. +// +// Building it looks the topics up in eventtopic's registry, into which api/v1 registers them as +// it is initialised. Go initialises api/v1 first because this package imports it; were that +// import dropped, every lookup here would panic. +var topicHandlers = topicHandlersByName( + bind(func(o *api.EventsOpts) *api.AttestationEventHandlerFunc { return &o.AttestationHandler }), + bind(func(o *api.EventsOpts) *api.AttesterSlashingEventHandlerFunc { return &o.AttesterSlashingHandler }), + bind(func(o *api.EventsOpts) *api.BlobSidecarEventHandlerFunc { return &o.BlobSidecarHandler }), + bind(func(o *api.EventsOpts) *api.BlockEventHandlerFunc { return &o.BlockHandler }), + bind(func(o *api.EventsOpts) *api.BlockGossipEventHandlerFunc { return &o.BlockGossipHandler }), + bind(func(o *api.EventsOpts) *api.BLSToExecutionChangeEventHandlerFunc { + return &o.BLSToExecutionChangeHandler + }), + bind(func(o *api.EventsOpts) *api.ChainReorgEventHandlerFunc { return &o.ChainReorgHandler }), + bind(func(o *api.EventsOpts) *api.ContributionAndProofEventHandlerFunc { + return &o.ContributionAndProofHandler + }), + bind(func(o *api.EventsOpts) *api.DataColumnSidecarEventHandlerFunc { return &o.DataColumnSidecarHandler }), + bind(func(o *api.EventsOpts) *api.ExecutionPayloadEventHandlerFunc { return &o.ExecutionPayloadHandler }), + bind(func(o *api.EventsOpts) *api.ExecutionPayloadAvailableEventHandlerFunc { + return &o.ExecutionPayloadAvailableHandler + }), + bind(func(o *api.EventsOpts) *api.ExecutionPayloadBidEventHandlerFunc { return &o.ExecutionPayloadBidHandler }), + bind(func(o *api.EventsOpts) *api.ExecutionPayloadGossipEventHandlerFunc { + return &o.ExecutionPayloadGossipHandler + }), + bind(func(o *api.EventsOpts) *api.FastConfirmationEventHandlerFunc { return &o.FastConfirmationHandler }), + bind(func(o *api.EventsOpts) *api.FinalizedCheckpointEventHandlerFunc { return &o.FinalizedCheckpointHandler }), + bind(func(o *api.EventsOpts) *api.HeadEventHandlerFunc { return &o.HeadHandler }), + bind(func(o *api.EventsOpts) *api.HeadV2EventHandlerFunc { return &o.HeadV2Handler }), + bindGeneric[apiv1.LightClientFinalityUpdateEvent](), + bindGeneric[apiv1.LightClientOptimisticUpdateEvent](), + bind(func(o *api.EventsOpts) *api.PayloadAttestationMessageEventHandlerFunc { + return &o.PayloadAttestationMessageHandler + }), + bind(func(o *api.EventsOpts) *api.PayloadAttributesEventHandlerFunc { return &o.PayloadAttributesHandler }), + bind(func(o *api.EventsOpts) *api.ProposerPreferencesEventHandlerFunc { return &o.ProposerPreferencesHandler }), + bind(func(o *api.EventsOpts) *api.ProposerSlashingEventHandlerFunc { return &o.ProposerSlashingHandler }), + bind(func(o *api.EventsOpts) *api.SingleAttestationEventHandlerFunc { return &o.SingleAttestationHandler }), + bind(func(o *api.EventsOpts) *api.VoluntaryExitEventHandlerFunc { return &o.VoluntaryExitHandler }), +) + +// namedTopicHandler is a topicHandler with the name of its topic. +type namedTopicHandler struct { + name string + handler topicHandler +} + +func topicHandlersByName(handlers ...namedTopicHandler) map[string]topicHandler { + byName := make(map[string]topicHandler, len(handlers)) + for _, handler := range handlers { + if _, exists := byName[handler.name]; exists { + panic(fmt.Sprintf("event topic %s bound to more than one handler", handler.name)) + } + + byName[handler.name] = handler.handler + } + + return byName +} + +// bind binds the field of api.EventsOpts, returned by field, holding the handler for the topic +// whose data decodes into the type the handler takes. It panics if there is no such topic, so +// that a binding with none fails as the package is initialised. +func bind[T any, H ~func(context.Context, *T)](field func(opts *api.EventsOpts) *H) namedTopicHandler { + topic := eventtopic.Lookup[T]() + name := topic.Name() + + return namedTopicHandler{ + name: name, + handler: topicHandler{ + isSet: func(opts *api.EventsOpts) bool { + return *field(opts) != nil + }, + handle: func(ctx context.Context, opts *api.EventsOpts, input []byte) error { + data, err := topic.Decode(input) + if err != nil { + return err + } + + if handler := *field(opts); handler != nil { + handler(ctx, data) + + return nil + } + + if opts.Handler == nil { + zerolog.Ctx(ctx).Debug().Str("topic", name).Msg("No specific or generic handler supplied; ignoring") + + return nil + } + + opts.Handler(&apiv1.Event{ + Topic: name, + Data: data, + }) + + return nil + }, + filter: func(dst *api.EventsOpts, src *api.EventsOpts, forward func(topic string) bool) { + handler := *field(src) + if handler == nil { + return + } + + *field(dst) = H(func(ctx context.Context, data *T) { + if forward(name) { + handler(ctx, data) + } + }) + }, + }, + } +} + +// bindGeneric binds a topic that has only the generic handler. +func bindGeneric[T any]() namedTopicHandler { + topic := eventtopic.Lookup[T]() + name := topic.Name() + + return namedTopicHandler{ + name: name, + handler: topicHandler{ + isSet: func(*api.EventsOpts) bool { return false }, + handle: func(_ context.Context, opts *api.EventsOpts, input []byte) error { + data, err := topic.Decode(input) + if err != nil { + return err + } + + if opts.Handler != nil { + opts.Handler(&apiv1.Event{Topic: name, Data: data}) + } + + return nil + }, + filter: func(*api.EventsOpts, *api.EventsOpts, func(string) bool) {}, + }, + } +} + +// ErrUnsupportedTopic is returned by Handle for an event of a topic that is not supported. +var ErrUnsupportedTopic = errors.New("unsupported event topic") + +// Supports reports whether events of the given topic can be handled. It is to be used in place +// of apiv1.SupportedEventTopics, which is an exported map that callers can change. +func Supports(topic string) bool { + _, exists := topicHandlers[topic] + + return exists +} + +// HasTopicHandler reports whether the options carry a handler specific to the given topic. +func HasTopicHandler(opts *api.EventsOpts, topic string) bool { + handler, exists := topicHandlers[topic] + + return exists && handler.isSet(opts) +} + +// Handle decodes the data of an event with the given topic, as sent on the events stream, and +// passes it to the topic's specific handler in the options, or failing that to their generic +// handler. It returns an error if the topic is not supported or the data does not decode. +func Handle(ctx context.Context, opts *api.EventsOpts, topic string, data []byte) error { + handler, exists := topicHandlers[topic] + if !exists { + return fmt.Errorf("%w %s", ErrUnsupportedTopic, topic) + } + + return handler.handle(ctx, opts, data) +} + +// ForwardingGuarded returns a copy of opts whose handlers forward events only when forward +// allows their topic. It leaves missing topic handlers nil so events can reach the generic +// handler. It copies the topics slice and never changes opts, so guards for different clients +// do not stack. +func ForwardingGuarded(opts *api.EventsOpts, forward func(topic string) bool) *api.EventsOpts { + filtered := &api.EventsOpts{ + Common: opts.Common, + Topics: slices.Clone(opts.Topics), + } + + if handler := opts.Handler; handler != nil { + filtered.Handler = func(event *apiv1.Event) { + if forward(event.Topic) { + handler(event) + } + } + } + + for _, topicHandler := range topicHandlers { + topicHandler.filter(filtered, opts, forward) + } + + return filtered +} diff --git a/internal/eventdispatch/eventdispatch_test.go b/internal/eventdispatch/eventdispatch_test.go new file mode 100644 index 00000000..1ce2a812 --- /dev/null +++ b/internal/eventdispatch/eventdispatch_test.go @@ -0,0 +1,158 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package eventdispatch_test + +import ( + "bytes" + "context" + "maps" + "reflect" + "testing" + + "github.com/attestantio/go-eth2-client/api" + apiv1 "github.com/attestantio/go-eth2-client/api/v1" + "github.com/attestantio/go-eth2-client/internal/eventdispatch" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" +) + +// TestBindsEveryTopicHandler confirms that each specific handler field of +// api.EventsOpts is bound to exactly one supported topic. The two light-client topics have only +// the generic handler. The fields are walked reflectively, so a handler added to api.EventsOpts +// but not bound fails here. +func TestBindsEveryTopicHandler(t *testing.T) { + optsType := reflect.TypeFor[api.EventsOpts]() + + boundTopics := make(map[string]bool) + for i := range optsType.NumField() { + field := optsType.Field(i) + if field.Type.Kind() != reflect.Func || field.Name == "Handler" { + continue + } + + t.Run(field.Name, func(t *testing.T) { + opts := &api.EventsOpts{} + reflect.ValueOf(opts).Elem().Field(i).Set(reflect.MakeFunc(field.Type, + func([]reflect.Value) []reflect.Value { return nil })) + + var topics []string + for topic := range apiv1.SupportedEventTopics { + if eventdispatch.HasTopicHandler(opts, topic) { + topics = append(topics, topic) + } + } + require.Len(t, topics, 1, "handler is not bound to exactly one topic") + require.NotContains(t, boundTopics, topics[0], "topic bound to more than one handler") + boundTopics[topics[0]] = true + }) + } + + withSpecificHandlers := maps.Clone(apiv1.SupportedEventTopics) + delete(withSpecificHandlers, "light_client_finality_update") + delete(withSpecificHandlers, "light_client_optimistic_update") + require.Equal(t, withSpecificHandlers, boundTopics) +} + +func TestHandle(t *testing.T) { + ctx := context.Background() + data := []byte(`{"slot":"10","block_root":"0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf"}`) + + t.Run("Specific", func(t *testing.T) { + var received *apiv1.ExecutionPayloadAvailableEvent + opts := &api.EventsOpts{ + Handler: func(*apiv1.Event) { require.Fail(t, "generic handler called") }, + ExecutionPayloadAvailableHandler: func(_ context.Context, event *apiv1.ExecutionPayloadAvailableEvent) { + received = event + }, + } + require.NoError(t, eventdispatch.Handle(ctx, opts, "execution_payload_available", data)) + require.NotNil(t, received) + }) + + t.Run("Generic", func(t *testing.T) { + var received *apiv1.Event + opts := &api.EventsOpts{Handler: func(event *apiv1.Event) { received = event }} + require.NoError(t, eventdispatch.Handle(ctx, opts, "execution_payload_available", data)) + require.Equal(t, "execution_payload_available", received.Topic) + require.IsType(t, &apiv1.ExecutionPayloadAvailableEvent{}, received.Data) + }) + + t.Run("NoHandler", func(t *testing.T) { + var output bytes.Buffer + logCtx := zerolog.New(&output).Level(zerolog.DebugLevel).WithContext(ctx) + + require.NoError(t, eventdispatch.Handle(logCtx, &api.EventsOpts{}, "execution_payload_available", data)) + require.JSONEq(t, `{"level":"debug","topic":"execution_payload_available","message":"No specific or generic handler supplied; ignoring"}`, + output.String()) + }) + + t.Run("UnsupportedTopic", func(t *testing.T) { + err := eventdispatch.Handle(ctx, &api.EventsOpts{}, "unknown", data) + require.ErrorIs(t, err, eventdispatch.ErrUnsupportedTopic) + require.EqualError(t, err, "unsupported event topic unknown") + }) + + t.Run("Malformed", func(t *testing.T) { + opts := &api.EventsOpts{Handler: func(*apiv1.Event) { require.Fail(t, "handler called") }} + require.Error(t, eventdispatch.Handle(ctx, opts, "execution_payload_available", []byte(`invalid`))) + }) +} + +func TestForwardingGuarded(t *testing.T) { + ctx := context.Background() + data := []byte(`{"slot":"10","block_root":"0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf"}`) + + specific := 0 + generic := 0 + opts := &api.EventsOpts{ + Topics: []string{"execution_payload_available", "head"}, + Handler: func(*apiv1.Event) { generic++ }, + ExecutionPayloadAvailableHandler: func(context.Context, *apiv1.ExecutionPayloadAvailableEvent) { + specific++ + }, + } + + forward := false + var forwardedTopics []string + filtered := eventdispatch.ForwardingGuarded(opts, func(topic string) bool { + forwardedTopics = append(forwardedTopics, topic) + + return forward + }) + + require.NotSame(t, opts, filtered) + require.Equal(t, opts.Topics, filtered.Topics) + require.Nil(t, filtered.HeadHandler, "unsupplied handler was filled in") + + require.NoError(t, eventdispatch.Handle(ctx, filtered, "execution_payload_available", data)) + require.Zero(t, specific, "event forwarded although forward refused it") + + forward = true + require.NoError(t, eventdispatch.Handle(ctx, filtered, "execution_payload_available", data)) + require.Equal(t, 1, specific) + filtered.Handler(&apiv1.Event{Topic: "head"}) + require.Equal(t, 1, generic) + require.Equal(t, []string{"execution_payload_available", "execution_payload_available", "head"}, forwardedTopics) + + filtered.Topics[0] = "changed" + require.Equal(t, "execution_payload_available", opts.Topics[0], "topics shared with the original options") +} + +func TestSupports(t *testing.T) { + for topic := range apiv1.SupportedEventTopics { + require.True(t, eventdispatch.Supports(topic), topic) + } + require.False(t, eventdispatch.Supports("unknown")) + require.False(t, eventdispatch.Supports("")) +} diff --git a/internal/eventtopic/eventtopic.go b/internal/eventtopic/eventtopic.go new file mode 100644 index 00000000..ff9431f2 --- /dev/null +++ b/internal/eventtopic/eventtopic.go @@ -0,0 +1,154 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package eventtopic describes event topics: the name of each, and the type into which the data +// of its events decodes. The topics themselves are listed in api/v1, which registers them here +// so that the rest of the module can reach them by type without their being public. +package eventtopic + +import ( + "encoding/json" + "fmt" + "reflect" + + "github.com/attestantio/go-eth2-client/spec" +) + +// Descriptor is a Topic of any type. +type Descriptor interface { + // Name returns the name of the topic. + Name() string + // NewData returns a new value of the type into which the data of the topic's events decodes. + NewData() any + // DecodeData decodes the data sent on the event stream. + DecodeData(input []byte) (any, error) + // Version returns the fork of wrapped events, or DataVersionUnknown for bare events. + Version() spec.DataVersion + // dataType returns that type. + dataType() reflect.Type +} + +var _ Descriptor = (*Topic[struct{}])(nil) + +// Topic describes an event topic: its name, and T, the type into which the data of its events +// decodes. +type Topic[T any] struct { //nolint:attgo // Checked above; attgo cannot see a check with a type argument. + name string + // version is the fork of the data of the topic's events, for a topic whose events the + // beacon-API spec wraps as {"version": "...", "data": {...}}, and DataVersionUnknown + // otherwise. + version spec.DataVersion +} + +// New creates a topic whose events carry their data bare. +func New[T any](name string) Topic[T] { + return Topic[T]{name: name} +} + +// NewVersioned creates a topic whose events the beacon-API spec wraps as +// {"version": "...", "data": {...}}, with data of the given fork. +func NewVersioned[T any](name string, version spec.DataVersion) Topic[T] { + return Topic[T]{ + name: name, + version: version, + } +} + +// Name returns the name of the topic. +func (t Topic[T]) Name() string { + return t.name +} + +// NewData returns a new T. +func (Topic[T]) NewData() any { + return new(T) +} + +func (t Topic[T]) DecodeData(input []byte) (any, error) { + return t.Decode(input) +} + +func (t Topic[T]) Version() spec.DataVersion { + return t.version +} + +func (Topic[T]) dataType() reflect.Type { + return reflect.TypeFor[T]() +} + +// Decode decodes the data of an event of the topic as sent on the events stream. For a topic +// whose events the beacon-API spec wraps as {"version": "...", "data": {...}}, the version must +// be the topic's fork, as data of another fork could decode into T without error while dropping +// fields T does not have; supporting a later fork means adding it here. A bare, unwrapped +// object is accepted as well, for nodes that do not wrap it. +func (t Topic[T]) Decode(input []byte) (*T, error) { + if t.version != spec.DataVersionUnknown { + var wrapper struct { + Version string `json:"version"` + Data json.RawMessage `json:"data"` + } + + if err := json.Unmarshal(input, &wrapper); err == nil && len(wrapper.Data) > 0 && wrapper.Version != "" { + version, err := spec.DataVersionFromString(wrapper.Version) + if err != nil || version != t.version { + return nil, fmt.Errorf("unsupported version %q for %s event", wrapper.Version, t.name) + } + + input = wrapper.Data + } + } + + data := new(T) + if err := json.Unmarshal(input, data); err != nil { + return nil, err + } + + return data, nil +} + +// byType is each registered topic, by the type into which the data of its events decodes. +var byType = map[reflect.Type]Descriptor{} + +// Register registers topics, so that they can be looked up with Lookup. It panics if two +// topics decode into the same type, as Lookup could then not tell them apart. +func Register(topics ...Descriptor) { + for _, topic := range topics { + if existing, exists := byType[topic.dataType()]; exists { + panic(fmt.Sprintf("event topics %s and %s both decode into %v", existing.Name(), topic.Name(), topic.dataType())) + } + + byType[topic.dataType()] = topic + } +} + +// LookupType returns the registered topic whose data decodes into the given type, if any. +func LookupType(dataType reflect.Type) (Descriptor, bool) { + topic, exists := byType[dataType] + + return topic, exists +} + +// Lookup returns the registered topic whose data decodes into a T. It panics if there is none. +func Lookup[T any]() Topic[T] { + descriptor, _ := LookupType(reflect.TypeFor[T]()) + + // A topic can be registered by value or, as *Topic also implements Descriptor, by pointer. + switch topic := descriptor.(type) { + case Topic[T]: + return topic + case *Topic[T]: + return *topic + default: + panic(fmt.Sprintf("no event topic decodes into %v", reflect.TypeFor[T]())) + } +} diff --git a/internal/eventtopic/eventtopic_test.go b/internal/eventtopic/eventtopic_test.go new file mode 100644 index 00000000..50cf84e0 --- /dev/null +++ b/internal/eventtopic/eventtopic_test.go @@ -0,0 +1,110 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package eventtopic_test + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/attestantio/go-eth2-client/internal/eventtopic" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +func TestDecode(t *testing.T) { + topic := eventtopic.NewVersioned[gloas.PayloadAttestationMessage]("payload_attestation_message", spec.DataVersionGloas) + + message := &gloas.PayloadAttestationMessage{ + ValidatorIndex: 123, + Data: &gloas.PayloadAttestationData{Slot: 10}, + } + bare, err := json.Marshal(message) + require.NoError(t, err) + + wrapped := func(version string) []byte { + t.Helper() + + data, err := json.Marshal(map[string]any{"version": version, "data": json.RawMessage(bare)}) + require.NoError(t, err) + + return data + } + + tests := []struct { + name string + input []byte + err string + }{ + {name: "Wrapped", input: wrapped("gloas")}, + {name: "Bare", input: bare}, + {name: "OtherFork", input: wrapped("fulu"), err: `unsupported version "fulu" for payload_attestation_message event`}, + {name: "UnknownFork", input: wrapped("unknown"), err: `unsupported version "unknown" for payload_attestation_message event`}, + // The data type rejects null itself, so no check of Decode's own is needed. + {name: "WrappedNull", input: []byte(`{"version":"gloas","data":null}`), err: "validator index missing"}, + {name: "BareNull", input: []byte(`null`), err: "validator index missing"}, + {name: "BareNullWithWhitespace", input: []byte(` null `), err: "validator index missing"}, + {name: "Malformed", input: []byte(`invalid`), err: "invalid character 'i' looking for beginning of value"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + data, err := topic.Decode(test.input) + if test.err != "" { + require.EqualError(t, err, test.err) + + return + } + + require.NoError(t, err) + require.Equal(t, phase0.ValidatorIndex(123), data.ValidatorIndex) + require.Equal(t, phase0.Slot(10), data.Data.Slot) + }) + } +} + +// registerOnce registers a topic unless one with its type already is. The registry is global, so +// without this a test registering a topic would panic when run again in the same process, as +// with -count. +func registerOnce(topic eventtopic.Descriptor) { + if _, exists := eventtopic.LookupType(reflect.TypeOf(topic.NewData()).Elem()); !exists { + eventtopic.Register(topic) + } +} + +func TestRegisterRejectsSharedType(t *testing.T) { + type data struct{} + registerOnce(eventtopic.New[data]("first")) + + require.PanicsWithValue(t, "event topics first and second both decode into eventtopic_test.data", func() { + eventtopic.Register(eventtopic.New[data]("second")) + }) + require.Equal(t, "first", eventtopic.Lookup[data]().Name()) +} + +func TestLookupPanicsWithoutTopic(t *testing.T) { + type unregistered struct{} + + require.Panics(t, func() { eventtopic.Lookup[unregistered]() }) +} + +func TestLookupRegisteredByPointer(t *testing.T) { + type data struct{} + topic := eventtopic.New[data]("by_pointer") + registerOnce(&topic) + + require.Equal(t, "by_pointer", eventtopic.Lookup[data]().Name()) +} diff --git a/mock/epbsproposal.go b/mock/epbsproposal.go new file mode 100644 index 00000000..f6492c4b --- /dev/null +++ b/mock/epbsproposal.go @@ -0,0 +1,145 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mock + +import ( + "context" + "math/big" + + "github.com/OffchainLabs/go-bitfield" + "github.com/attestantio/go-eth2-client/api" + apiv1gloas "github.com/attestantio/go-eth2-client/api/v1/gloas" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/holiman/uint256" +) + +// EPBSProposal fetches an ePBS proposal for signing. +// +// The response follows opts.IncludePayload, since that is the axis a caller is +// most likely to be exercising: with it set the proposal carries the envelope, +// blobs and proofs needed to publish from anywhere, and without it only a block. +func (s *Service) EPBSProposal(ctx context.Context, + opts *api.EPBSProposalOpts, +) ( + *api.Response[*api.VersionedEPBSProposal], + error, +) { + if s.EPBSProposalFunc != nil { + return s.EPBSProposalFunc(ctx, opts) + } + + block := mockGloasBeaconBlock(opts) + + // The mock serves mainnet-preset data, so the generated Body.HashTreeRoot + // is the correct root here -- unlike the http client, which decodes + // against whatever preset the connected node runs and so cannot use it. + bodyRoot, err := block.Body.HashTreeRoot() + if err != nil { + return nil, err + } + root := phase0.Root(bodyRoot) + + proposal := &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ConsensusValue: big.NewInt(1), + ExecutionValue: big.NewInt(2), + BeaconBlockBodyRoot: &root, + } + + if opts.IncludePayload != nil && *opts.IncludePayload { + proposal.ExecutionPayloadIncluded = true + proposal.GloasContents = &apiv1gloas.BlockContents{ + Block: block, + ExecutionPayloadEnvelope: mockGloasExecutionPayloadEnvelope(), + KZGProofs: []deneb.KZGProof{}, + Blobs: []deneb.Blob{}, + } + } else { + proposal.Gloas = block + } + + return &api.Response[*api.VersionedEPBSProposal]{ + Data: proposal, + Metadata: make(map[string]any), + }, nil +} + +// mockGloasBeaconBlock returns a block for the requested slot, echoing back the +// RANDAO reveal and graffiti so that a caller's own consistency checks pass. +func mockGloasBeaconBlock(opts *api.EPBSProposalOpts) *gloas.BeaconBlock { + return &gloas.BeaconBlock{ + Slot: opts.Slot, + Body: &gloas.BeaconBlockBody{ + RANDAOReveal: opts.RandaoReveal, + Graffiti: opts.Graffiti, + ETH1Data: &phase0.ETH1Data{ + BlockHash: make([]byte, phase0.Hash32Length), + }, + ProposerSlashings: []*phase0.ProposerSlashing{}, + AttesterSlashings: []*gloas.AttesterSlashing{}, + Attestations: []*gloas.Attestation{}, + Deposits: []*phase0.Deposit{}, + VoluntaryExits: []*phase0.SignedVoluntaryExit{}, + SyncAggregate: &altair.SyncAggregate{ + SyncCommitteeBits: bitfield.NewBitvector512(), + }, + BLSToExecutionChanges: []*capella.SignedBLSToExecutionChange{}, + SignedExecutionPayloadBid: &gloas.SignedExecutionPayloadBid{ + Message: &gloas.ExecutionPayloadBid{ + Slot: opts.Slot, + BlobKZGCommitments: []deneb.KZGCommitment{}, + }, + }, + PayloadAttestations: []*gloas.PayloadAttestation{}, + ParentExecutionRequests: mockGloasExecutionRequests(), + }, + } +} + +// mockGloasExecutionPayloadEnvelope returns an envelope with every nilable field +// of its payload set. BaseFeePerGas matters most: it is a *uint256.Int, so a +// consumer that reads it rather than the marshaled JSON dereferences nil. The +// JSON marshaler itself substitutes a zero, which means an unset one would +// otherwise go unnoticed. +func mockGloasExecutionPayloadEnvelope() *gloas.ExecutionPayloadEnvelope { + return &gloas.ExecutionPayloadEnvelope{ + Payload: &gloas.ExecutionPayload{ + BaseFeePerGas: uint256.NewInt(1), + ExtraData: []byte{}, + Transactions: []bellatrix.Transaction{}, + Withdrawals: []*capella.Withdrawal{}, + BlockAccessList: []byte{}, + }, + ExecutionRequests: mockGloasExecutionRequests(), + } +} + +// mockGloasExecutionRequests returns execution requests with every list +// allocated, so that the container round-trips through JSON unchanged. +func mockGloasExecutionRequests() *gloas.ExecutionRequests { + return &gloas.ExecutionRequests{ + Deposits: []*electra.DepositRequest{}, + Withdrawals: []*electra.WithdrawalRequest{}, + Consolidations: []*electra.ConsolidationRequest{}, + BuilderDeposits: []*gloas.BuilderDepositRequest{}, + BuilderExits: []*gloas.BuilderExitRequest{}, + } +} diff --git a/mock/epbsproposal_test.go b/mock/epbsproposal_test.go new file mode 100644 index 00000000..d81f8583 --- /dev/null +++ b/mock/epbsproposal_test.go @@ -0,0 +1,180 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mock_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/mock" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// TestEPBSProposal verifies that the mock's default proposal follows the +// payload-inclusion option and that what it hands back can actually be used. +// +// The marshaling assertions are a smoke check that the mock's output is +// serialisable at all, which is easy to break by leaving one of the many +// pointers a gloas block body holds unset. They do not cover BaseFeePerGas: +// the payload's JSON marshaler substitutes a zero for a nil one, so a nil there +// serialises quietly rather than failing. That is asserted directly instead, +// where a consumer reading the field rather than the JSON would trip over it. +func TestEPBSProposal(t *testing.T) { + ctx := context.Background() + + service, err := mock.New(ctx) + require.NoError(t, err) + + includePayload := true + excludePayload := false + + t.Run("PayloadIncluded", func(t *testing.T) { + response, err := service.EPBSProposal(ctx, &api.EPBSProposalOpts{ + Slot: 123, + RandaoReveal: phase0.BLSSignature{0x01, 0x02}, + IncludePayload: &includePayload, + }) + require.NoError(t, err) + require.Equal(t, spec.DataVersionGloas, response.Data.Version) + require.True(t, response.Data.ExecutionPayloadIncluded) + require.NotNil(t, response.Data.GloasContents) + require.Nil(t, response.Data.Gloas) + + // The proposal must be consistent with what was asked for, since a + // caller's own checks reject it otherwise. + slot, err := response.Data.Slot() + require.NoError(t, err) + require.Equal(t, phase0.Slot(123), slot) + + reveal, err := response.Data.RandaoReveal() + require.NoError(t, err) + require.Equal(t, phase0.BLSSignature{0x01, 0x02}, reveal) + + // Everything needed to publish from any node must be reachable. + envelope, err := response.Data.ExecutionPayloadEnvelope() + require.NoError(t, err) + require.NotNil(t, envelope.Payload) + require.NotNil(t, envelope.Payload.BaseFeePerGas) + + // The mock serves mainnet-preset data, so the generated + // Body.HashTreeRoot() is the correct root here; BodyRoot() must + // return exactly that rather than erroring for want of a retained + // root. + wantBodyRoot, err := response.Data.GloasContents.Block.Body.HashTreeRoot() + require.NoError(t, err) + bodyRoot, err := response.Data.BodyRoot() + require.NoError(t, err) + require.Equal(t, phase0.Root(wantBodyRoot), bodyRoot) + + _, err = response.Data.Blobs() + require.NoError(t, err) + _, err = response.Data.KZGProofs() + require.NoError(t, err) + + var marshaled []byte + require.NotPanics(t, func() { + marshaled, err = json.Marshal(response.Data.GloasContents) + }) + require.NoError(t, err) + require.NotEmpty(t, marshaled) + }) + + t.Run("PayloadExcluded", func(t *testing.T) { + response, err := service.EPBSProposal(ctx, &api.EPBSProposalOpts{ + Slot: 123, + IncludePayload: &excludePayload, + }) + require.NoError(t, err) + require.False(t, response.Data.ExecutionPayloadIncluded) + require.NotNil(t, response.Data.Gloas) + require.Nil(t, response.Data.GloasContents) + + // There is no envelope to hand over in this mode; the caller has to + // fetch it from the node that produced the block. + _, err = response.Data.ExecutionPayloadEnvelope() + require.ErrorContains(t, err, "the execution payload was not included") + + wantBodyRoot, err := response.Data.Gloas.Body.HashTreeRoot() + require.NoError(t, err) + bodyRoot, err := response.Data.BodyRoot() + require.NoError(t, err) + require.Equal(t, phase0.Root(wantBodyRoot), bodyRoot) + + var marshaled []byte + require.NotPanics(t, func() { + marshaled, err = json.Marshal(response.Data.Gloas) + }) + require.NoError(t, err) + require.NotEmpty(t, marshaled) + }) + + t.Run("OverrideFunc", func(t *testing.T) { + mockService, err := mock.New(ctx) + require.NoError(t, err) + + mockService.EPBSProposalFunc = func(context.Context, *api.EPBSProposalOpts) ( + *api.Response[*api.VersionedEPBSProposal], error, + ) { + return &api.Response[*api.VersionedEPBSProposal]{ + Data: &api.VersionedEPBSProposal{Version: spec.DataVersionGloas}, + }, nil + } + + response, err := mockService.EPBSProposal(ctx, &api.EPBSProposalOpts{ + Slot: 123, + IncludePayload: &includePayload, + }) + require.NoError(t, err) + require.True(t, response.Data.IsEmpty()) + }) +} + +// TestExecutionPayloadEnvelope verifies that the mock's default envelope echoes +// the requested block root, which is what a caller checks to be sure the +// envelope belongs to the block it proposed. +func TestExecutionPayloadEnvelope(t *testing.T) { + ctx := context.Background() + + service, err := mock.New(ctx) + require.NoError(t, err) + + root := phase0.Root{0xaa, 0xbb} + + response, err := service.ExecutionPayloadEnvelope(ctx, &api.ExecutionPayloadEnvelopeOpts{ + Slot: 123, + BeaconBlockRoot: root, + }) + require.NoError(t, err) + require.Equal(t, spec.DataVersionGloas, response.Data.Version) + require.False(t, response.Data.IsEmpty()) + + got, err := response.Data.BeaconBlockRoot() + require.NoError(t, err) + require.Equal(t, root, got) + + payload, err := response.Data.Payload() + require.NoError(t, err) + require.NotNil(t, payload.BaseFeePerGas) + + var marshaled []byte + require.NotPanics(t, func() { + marshaled, err = json.Marshal(response.Data.Gloas) + }) + require.NoError(t, err) + require.NotEmpty(t, marshaled) +} diff --git a/mock/executionpayloadenvelope.go b/mock/executionpayloadenvelope.go new file mode 100644 index 00000000..1c4fa258 --- /dev/null +++ b/mock/executionpayloadenvelope.go @@ -0,0 +1,51 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mock + +import ( + "context" + + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" +) + +// ExecutionPayloadEnvelope obtains the cached execution payload envelope for the +// given slot and beacon block root. +// +// The envelope echoes back the requested block root, so a caller checking that +// what it got back belongs to the block it proposed is exercised. A test that +// needs the ErrNoExecutionPayloadEnvelope path — which is what a real node +// answers for any slot but the one it is proposing — should install +// ExecutionPayloadEnvelopeFunc. +func (s *Service) ExecutionPayloadEnvelope(ctx context.Context, + opts *api.ExecutionPayloadEnvelopeOpts, +) ( + *api.Response[*spec.VersionedExecutionPayloadEnvelope], + error, +) { + if s.ExecutionPayloadEnvelopeFunc != nil { + return s.ExecutionPayloadEnvelopeFunc(ctx, opts) + } + + envelope := mockGloasExecutionPayloadEnvelope() + envelope.BeaconBlockRoot = opts.BeaconBlockRoot + + return &api.Response[*spec.VersionedExecutionPayloadEnvelope]{ + Data: &spec.VersionedExecutionPayloadEnvelope{ + Version: spec.DataVersionGloas, + Gloas: envelope, + }, + Metadata: make(map[string]any), + }, nil +} diff --git a/mock/payloadattestationdata.go b/mock/payloadattestationdata.go new file mode 100644 index 00000000..8db7da62 --- /dev/null +++ b/mock/payloadattestationdata.go @@ -0,0 +1,46 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mock + +import ( + "context" + + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" +) + +// PayloadAttestationData obtains payload attestation data for the given options. +func (s *Service) PayloadAttestationData(ctx context.Context, + opts *api.PayloadAttestationDataOpts, +) ( + *api.Response[*spec.VersionedPayloadAttestationData], + error, +) { + if s.PayloadAttestationDataFunc != nil { + return s.PayloadAttestationDataFunc(ctx, opts) + } + + return &api.Response[*spec.VersionedPayloadAttestationData]{ + Data: &spec.VersionedPayloadAttestationData{ + Version: spec.DataVersionGloas, + Gloas: &gloas.PayloadAttestationData{ + Slot: opts.Slot, + PayloadPresent: true, + BlobDataAvailable: true, + }, + }, + Metadata: make(map[string]any), + }, nil +} diff --git a/mock/payloadattestationpool.go b/mock/payloadattestationpool.go new file mode 100644 index 00000000..5510763e --- /dev/null +++ b/mock/payloadattestationpool.go @@ -0,0 +1,57 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mock + +import ( + "context" + + bitfield "github.com/OffchainLabs/go-bitfield" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// PayloadAttestationPool obtains the payload attestation pool for the given options. +func (*Service) PayloadAttestationPool(_ context.Context, + opts *api.PayloadAttestationPoolOpts, +) ( + *api.Response[[]*spec.VersionedPayloadAttestation], + error, +) { + var slot phase0.Slot + if opts.Slot != nil { + slot = *opts.Slot + } + + data := make([]*spec.VersionedPayloadAttestation, 2) + for i := range data { + data[i] = &spec.VersionedPayloadAttestation{ + Version: spec.DataVersionGloas, + Gloas: &gloas.PayloadAttestation{ + AggregationBits: bitfield.NewBitvector512(), + Data: &gloas.PayloadAttestationData{ + Slot: slot, + PayloadPresent: true, + BlobDataAvailable: true, + }, + }, + } + } + + return &api.Response[[]*spec.VersionedPayloadAttestation]{ + Data: data, + Metadata: make(map[string]any), + }, nil +} diff --git a/mock/proposerduties.go b/mock/proposerduties.go index 0eb3f34e..be3c2d74 100644 --- a/mock/proposerduties.go +++ b/mock/proposerduties.go @@ -27,6 +27,21 @@ func (s *Service) ProposerDuties(ctx context.Context, opts *api.ProposerDutiesOp return s.ProposerDutiesFunc(ctx, opts) } + return proposerDuties(opts), nil +} + +// ProposerDutiesV2 obtains proposer duties for the given epoch using the v2 API. +func (s *Service) ProposerDutiesV2(ctx context.Context, + opts *api.ProposerDutiesOpts, +) (*api.Response[[]*apiv1.ProposerDuty], error) { + if s.ProposerDutiesV2Func != nil { + return s.ProposerDutiesV2Func(ctx, opts) + } + + return proposerDuties(opts), nil +} + +func proposerDuties(opts *api.ProposerDutiesOpts) *api.Response[[]*apiv1.ProposerDuty] { data := make([]*apiv1.ProposerDuty, len(opts.Indices)) for i := range opts.Indices { data[i] = &apiv1.ProposerDuty{ @@ -37,5 +52,5 @@ func (s *Service) ProposerDuties(ctx context.Context, opts *api.ProposerDutiesOp return &api.Response[[]*apiv1.ProposerDuty]{ Data: data, Metadata: make(map[string]any), - }, nil + } } diff --git a/mock/proposerduties_v2_test.go b/mock/proposerduties_v2_test.go new file mode 100644 index 00000000..1ac5cfc5 --- /dev/null +++ b/mock/proposerduties_v2_test.go @@ -0,0 +1,67 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mock_test + +import ( + "context" + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + apiv1 "github.com/attestantio/go-eth2-client/api/v1" + "github.com/attestantio/go-eth2-client/mock" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +func TestProposerDutiesV2Callback(t *testing.T) { + ctx := context.Background() + expectedOpts := &api.ProposerDutiesOpts{Epoch: 4} + expectedResponse := &api.Response[[]*apiv1.ProposerDuty]{ + Data: []*apiv1.ProposerDuty{{ValidatorIndex: 9, Slot: 128}}, + Metadata: map[string]any{ + "dependent_root": phase0.Root{0xaa}, + }, + } + + service, err := mock.New(ctx) + require.NoError(t, err) + service.ProposerDutiesV2Func = func(_ context.Context, opts *api.ProposerDutiesOpts) (*api.Response[[]*apiv1.ProposerDuty], error) { + require.Same(t, expectedOpts, opts) + return expectedResponse, nil + } + + response, err := service.ProposerDutiesV2(ctx, expectedOpts) + require.NoError(t, err) + require.Same(t, expectedResponse, response) +} + +func TestProposerDutiesV2Default(t *testing.T) { + ctx := context.Background() + service, err := mock.New(ctx) + require.NoError(t, err) + + provider, supported := any(service).(client.ProposerDutiesV2Provider) + require.True(t, supported) + + response, err := provider.ProposerDutiesV2(ctx, &api.ProposerDutiesOpts{ + Indices: []phase0.ValidatorIndex{3, 5}, + }) + require.NoError(t, err) + require.Equal(t, []*apiv1.ProposerDuty{ + {ValidatorIndex: 3}, + {ValidatorIndex: 5}, + }, response.Data) + require.Empty(t, response.Metadata) +} diff --git a/mock/ptcduties.go b/mock/ptcduties.go new file mode 100644 index 00000000..2c938eac --- /dev/null +++ b/mock/ptcduties.go @@ -0,0 +1,47 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mock + +import ( + "context" + + "github.com/attestantio/go-eth2-client/api" + apiv1 "github.com/attestantio/go-eth2-client/api/v1" + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// PTCDuties obtains payload timeliness committee duties. +func (s *Service) PTCDuties(ctx context.Context, + opts *api.PTCDutiesOpts, +) ( + *api.Response[[]*apiv1.PTCDuty], + error, +) { + if s.PTCDutiesFunc != nil { + return s.PTCDutiesFunc(ctx, opts) + } + + data := make([]*apiv1.PTCDuty, len(opts.Indices)) + for i := range opts.Indices { + data[i] = &apiv1.PTCDuty{ + ValidatorIndex: opts.Indices[i], + Slot: phase0.Slot(uint64(opts.Epoch) * 32), + } + } + + return &api.Response[[]*apiv1.PTCDuty]{ + Data: data, + Metadata: make(map[string]any), + }, nil +} diff --git a/mock/service.go b/mock/service.go index c48ae855..6e073d7b 100644 --- a/mock/service.go +++ b/mock/service.go @@ -1,4 +1,4 @@ -// Copyright © 2020 - 2025 Attestant Limited. +// Copyright © 2020 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -50,40 +50,46 @@ type Service struct { SyncDistance phase0.Slot // Functions that can be provided to mock specific responses from this client. - AggregateAttestationFunc func(context.Context, *api.AggregateAttestationOpts) (*api.Response[*spec.VersionedAttestation], error) - AttesterDutiesFunc func(context.Context, *api.AttesterDutiesOpts) (*api.Response[[]*apiv1.AttesterDuty], error) - AttestationDataFunc func(context.Context, *api.AttestationDataOpts) (*api.Response[*phase0.AttestationData], error) - AttestationRewardsFunc func(context.Context, *api.AttestationRewardsOpts) (*api.Response[*apiv1.AttestationRewards], error) - BeaconBlockHeaderFunc func(context.Context, *api.BeaconBlockHeaderOpts) (*api.Response[*apiv1.BeaconBlockHeader], error) - BeaconBlockRootFunc func(context.Context, *api.BeaconBlockRootOpts) (*api.Response[*phase0.Root], error) - BeaconStateFunc func(context.Context, *api.BeaconStateOpts) (*api.Response[*spec.VersionedBeaconState], error) - BeaconStateRandaoFunc func(context.Context, *api.BeaconStateRandaoOpts) (*api.Response[*phase0.Root], error) - BeaconStateRootFunc func(context.Context, *api.BeaconStateRootOpts) (*api.Response[*phase0.Root], error) - BlockRewardsFunc func(context.Context, *api.BlockRewardsOpts) (*api.Response[*apiv1.BlockRewards], error) - DepositContractFunc func(context.Context, *api.DepositContractOpts) (*api.Response[*apiv1.DepositContract], error) - EventsFunc func(context.Context, *api.EventsOpts) error - FinalityFunc func(context.Context, *api.FinalityOpts) (*api.Response[*apiv1.Finality], error) - ForkChoiceFunc func(context.Context, *api.ForkChoiceOpts) (*api.Response[*apiv1.ForkChoice], error) - ForkFunc func(context.Context, *api.ForkOpts) (*api.Response[*phase0.Fork], error) - ForkScheduleFunc func(context.Context, *api.ForkScheduleOpts) (*api.Response[[]*phase0.Fork], error) - GenesisFunc func(context.Context, *api.GenesisOpts) (*api.Response[*apiv1.Genesis], error) - NodePeersFunc func(context.Context, *api.NodePeersOpts) (*api.Response[[]*apiv1.Peer], error) - NodeSyncingFunc func(context.Context, *api.NodeSyncingOpts) (*api.Response[*apiv1.SyncState], error) - NodeVersionFunc func(context.Context, *api.NodeVersionOpts) (*api.Response[string], error) - PendingDepositsFunc func(context.Context, *api.PendingDepositsOpts) (*api.Response[[]*electra.PendingDeposit], error) - PendingConsolidationsFunc func(context.Context, *api.PendingConsolidationsOpts) (*api.Response[[]*electra.PendingConsolidation], error) - PendingPartialWithdrawalsFunc func(context.Context, *api.PendingPartialWithdrawalsOpts) (*api.Response[[]*electra.PendingPartialWithdrawal], error) - ProposalFunc func(context.Context, *api.ProposalOpts) (*api.Response[*api.VersionedProposal], error) - ProposerDutiesFunc func(context.Context, *api.ProposerDutiesOpts) (*api.Response[[]*apiv1.ProposerDuty], error) - SignedBeaconBlockFunc func(context.Context, *api.SignedBeaconBlockOpts) (*api.Response[*spec.VersionedSignedBeaconBlock], error) - SpecFunc func(context.Context, *api.SpecOpts) (*api.Response[map[string]any], error) - SyncCommitteeContributionFunc func(context.Context, *api.SyncCommitteeContributionOpts) (*api.Response[*altair.SyncCommitteeContribution], error) - SyncCommitteeDutiesFunc func(context.Context, *api.SyncCommitteeDutiesOpts) (*api.Response[[]*apiv1.SyncCommitteeDuty], error) - SyncCommitteeRewardsFunc func(context.Context, *api.SyncCommitteeRewardsOpts) (*api.Response[[]*apiv1.SyncCommitteeReward], error) - ValidatorBalancesFunc func(context.Context, *api.ValidatorBalancesOpts) (*api.Response[map[phase0.ValidatorIndex]phase0.Gwei], error) - ValidatorLivenessFunc func(context.Context, *api.ValidatorLivenessOpts) (*api.Response[[]*apiv1.ValidatorLiveness], error) - ValidatorsFunc func(context.Context, *api.ValidatorsOpts) (*api.Response[map[phase0.ValidatorIndex]*apiv1.Validator], error) - VoluntaryExitPoolFunc func(context.Context, *api.VoluntaryExitPoolOpts) (*api.Response[[]*phase0.SignedVoluntaryExit], error) + AggregateAttestationFunc func(context.Context, *api.AggregateAttestationOpts) (*api.Response[*spec.VersionedAttestation], error) + AttesterDutiesFunc func(context.Context, *api.AttesterDutiesOpts) (*api.Response[[]*apiv1.AttesterDuty], error) + AttestationDataFunc func(context.Context, *api.AttestationDataOpts) (*api.Response[*phase0.AttestationData], error) + AttestationRewardsFunc func(context.Context, *api.AttestationRewardsOpts) (*api.Response[*apiv1.AttestationRewards], error) + BeaconBlockHeaderFunc func(context.Context, *api.BeaconBlockHeaderOpts) (*api.Response[*apiv1.BeaconBlockHeader], error) + BeaconBlockRootFunc func(context.Context, *api.BeaconBlockRootOpts) (*api.Response[*phase0.Root], error) + BeaconStateFunc func(context.Context, *api.BeaconStateOpts) (*api.Response[*spec.VersionedBeaconState], error) + BeaconStateRandaoFunc func(context.Context, *api.BeaconStateRandaoOpts) (*api.Response[*phase0.Root], error) + BeaconStateRootFunc func(context.Context, *api.BeaconStateRootOpts) (*api.Response[*phase0.Root], error) + BlockRewardsFunc func(context.Context, *api.BlockRewardsOpts) (*api.Response[*apiv1.BlockRewards], error) + DepositContractFunc func(context.Context, *api.DepositContractOpts) (*api.Response[*apiv1.DepositContract], error) + EPBSProposalFunc func(context.Context, *api.EPBSProposalOpts) (*api.Response[*api.VersionedEPBSProposal], error) + EventsFunc func(context.Context, *api.EventsOpts) error + ExecutionPayloadEnvelopeFunc func(context.Context, *api.ExecutionPayloadEnvelopeOpts) (*api.Response[*spec.VersionedExecutionPayloadEnvelope], error) + FinalityFunc func(context.Context, *api.FinalityOpts) (*api.Response[*apiv1.Finality], error) + ForkChoiceFunc func(context.Context, *api.ForkChoiceOpts) (*api.Response[*apiv1.ForkChoice], error) + ForkFunc func(context.Context, *api.ForkOpts) (*api.Response[*phase0.Fork], error) + ForkScheduleFunc func(context.Context, *api.ForkScheduleOpts) (*api.Response[[]*phase0.Fork], error) + GenesisFunc func(context.Context, *api.GenesisOpts) (*api.Response[*apiv1.Genesis], error) + NodePeersFunc func(context.Context, *api.NodePeersOpts) (*api.Response[[]*apiv1.Peer], error) + NodeSyncingFunc func(context.Context, *api.NodeSyncingOpts) (*api.Response[*apiv1.SyncState], error) + NodeVersionFunc func(context.Context, *api.NodeVersionOpts) (*api.Response[string], error) + PTCDutiesFunc func(context.Context, *api.PTCDutiesOpts) (*api.Response[[]*apiv1.PTCDuty], error) + PayloadAttestationDataFunc func(context.Context, *api.PayloadAttestationDataOpts) (*api.Response[*spec.VersionedPayloadAttestationData], error) + PendingDepositsFunc func(context.Context, *api.PendingDepositsOpts) (*api.Response[[]*electra.PendingDeposit], error) + PendingConsolidationsFunc func(context.Context, *api.PendingConsolidationsOpts) (*api.Response[[]*electra.PendingConsolidation], error) + PendingPartialWithdrawalsFunc func(context.Context, *api.PendingPartialWithdrawalsOpts) (*api.Response[[]*electra.PendingPartialWithdrawal], error) + ProposalFunc func(context.Context, *api.ProposalOpts) (*api.Response[*api.VersionedProposal], error) + ProposerDutiesFunc func(context.Context, *api.ProposerDutiesOpts) (*api.Response[[]*apiv1.ProposerDuty], error) + ProposerDutiesV2Func func(context.Context, *api.ProposerDutiesOpts) (*api.Response[[]*apiv1.ProposerDuty], error) + SignedBeaconBlockFunc func(context.Context, *api.SignedBeaconBlockOpts) (*api.Response[*spec.VersionedSignedBeaconBlock], error) + SignedExecutionPayloadEnvelopeFunc func(context.Context, *api.SignedExecutionPayloadEnvelopeOpts) (*api.Response[*spec.VersionedSignedExecutionPayloadEnvelope], error) + SpecFunc func(context.Context, *api.SpecOpts) (*api.Response[map[string]any], error) + SyncCommitteeContributionFunc func(context.Context, *api.SyncCommitteeContributionOpts) (*api.Response[*altair.SyncCommitteeContribution], error) + SyncCommitteeDutiesFunc func(context.Context, *api.SyncCommitteeDutiesOpts) (*api.Response[[]*apiv1.SyncCommitteeDuty], error) + SyncCommitteeRewardsFunc func(context.Context, *api.SyncCommitteeRewardsOpts) (*api.Response[[]*apiv1.SyncCommitteeReward], error) + ValidatorBalancesFunc func(context.Context, *api.ValidatorBalancesOpts) (*api.Response[map[phase0.ValidatorIndex]phase0.Gwei], error) + ValidatorLivenessFunc func(context.Context, *api.ValidatorLivenessOpts) (*api.Response[[]*apiv1.ValidatorLiveness], error) + ValidatorsFunc func(context.Context, *api.ValidatorsOpts) (*api.Response[map[phase0.ValidatorIndex]*apiv1.Validator], error) + VoluntaryExitPoolFunc func(context.Context, *api.VoluntaryExitPoolOpts) (*api.Response[[]*phase0.SignedVoluntaryExit], error) } // log is a service-wide logger. diff --git a/mock/signedexecutionpayloadenvelope.go b/mock/signedexecutionpayloadenvelope.go new file mode 100644 index 00000000..21b7080f --- /dev/null +++ b/mock/signedexecutionpayloadenvelope.go @@ -0,0 +1,59 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mock + +import ( + "context" + + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" +) + +// SignedExecutionPayloadEnvelope fetches a signed execution payload envelope given a block ID. +func (s *Service) SignedExecutionPayloadEnvelope(ctx context.Context, + opts *api.SignedExecutionPayloadEnvelopeOpts, +) ( + *api.Response[*spec.VersionedSignedExecutionPayloadEnvelope], + error, +) { + if s.SignedExecutionPayloadEnvelopeFunc != nil { + return s.SignedExecutionPayloadEnvelopeFunc(ctx, opts) + } + + return &api.Response[*spec.VersionedSignedExecutionPayloadEnvelope]{ + Data: &spec.VersionedSignedExecutionPayloadEnvelope{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedExecutionPayloadEnvelope{ + Message: &gloas.ExecutionPayloadEnvelope{ + Payload: &gloas.ExecutionPayload{ + Transactions: []bellatrix.Transaction{}, + Withdrawals: []*capella.Withdrawal{}, + }, + ExecutionRequests: &gloas.ExecutionRequests{ + Deposits: []*electra.DepositRequest{}, + Withdrawals: []*electra.WithdrawalRequest{}, + Consolidations: []*electra.ConsolidationRequest{}, + BuilderDeposits: []*gloas.BuilderDepositRequest{}, + BuilderExits: []*gloas.BuilderExitRequest{}, + }, + }, + }, + }, + Metadata: make(map[string]any), + }, nil +} diff --git a/mock/submitexecutionpayloadbid.go b/mock/submitexecutionpayloadbid.go new file mode 100644 index 00000000..6828aa75 --- /dev/null +++ b/mock/submitexecutionpayloadbid.go @@ -0,0 +1,25 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mock + +import ( + "context" + + "github.com/attestantio/go-eth2-client/api" +) + +// SubmitExecutionPayloadBid submits an execution payload bid. +func (*Service) SubmitExecutionPayloadBid(_ context.Context, _ *api.SubmitExecutionPayloadBidOpts) error { + return nil +} diff --git a/mock/submitexecutionpayloadenvelope.go b/mock/submitexecutionpayloadenvelope.go new file mode 100644 index 00000000..9897ca3d --- /dev/null +++ b/mock/submitexecutionpayloadenvelope.go @@ -0,0 +1,25 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mock + +import ( + "context" + + "github.com/attestantio/go-eth2-client/api" +) + +// SubmitExecutionPayloadEnvelope submits a signed execution payload envelope. +func (*Service) SubmitExecutionPayloadEnvelope(_ context.Context, _ *api.SubmitExecutionPayloadEnvelopeOpts) error { + return nil +} diff --git a/mock/submitpayloadattestationmessages.go b/mock/submitpayloadattestationmessages.go new file mode 100644 index 00000000..08229f3c --- /dev/null +++ b/mock/submitpayloadattestationmessages.go @@ -0,0 +1,27 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mock + +import ( + "context" + + "github.com/attestantio/go-eth2-client/api" +) + +// SubmitPayloadAttestationMessages submits payload attestation messages. +func (*Service) SubmitPayloadAttestationMessages(_ context.Context, + _ *api.SubmitPayloadAttestationMessagesOpts, +) error { + return nil +} diff --git a/mock/submitproposerpreferences.go b/mock/submitproposerpreferences.go new file mode 100644 index 00000000..97007a6b --- /dev/null +++ b/mock/submitproposerpreferences.go @@ -0,0 +1,25 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mock + +import ( + "context" + + "github.com/attestantio/go-eth2-client/api" +) + +// SubmitProposerPreferences submits signed proposer preferences. +func (*Service) SubmitProposerPreferences(_ context.Context, _ *api.SubmitProposerPreferencesOpts) error { + return nil +} diff --git a/multi/epbsproposal.go b/multi/epbsproposal.go new file mode 100644 index 00000000..90efc104 --- /dev/null +++ b/multi/epbsproposal.go @@ -0,0 +1,48 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package multi + +import ( + "context" + + consensusclient "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" +) + +// EPBSProposal fetches an ePBS proposal for signing. +func (s *Service) EPBSProposal(ctx context.Context, + opts *api.EPBSProposalOpts, +) ( + *api.Response[*api.VersionedEPBSProposal], + error, +) { + res, err := s.doCall(ctx, func(ctx context.Context, client consensusclient.Service) (any, error) { + proposal, err := client.(consensusclient.EPBSProposalProvider).EPBSProposal(ctx, opts) + if err != nil { + return nil, err + } + + return proposal, nil + }, nil) + if err != nil { + return nil, err + } + + response, isResponse := res.(*api.Response[*api.VersionedEPBSProposal]) + if !isResponse { + return nil, ErrIncorrectType + } + + return response, nil +} diff --git a/multi/events.go b/multi/events.go index 4b75ad15..42290651 100644 --- a/multi/events.go +++ b/multi/events.go @@ -1,4 +1,4 @@ -// Copyright © 2021, 2025 Attestant Limited. +// Copyright © 2021 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -15,27 +15,40 @@ package multi import ( "context" + "errors" "fmt" "math/rand" "time" consensusclient "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/api" - apiv1 "github.com/attestantio/go-eth2-client/api/v1" - "github.com/attestantio/go-eth2-client/spec" - "github.com/attestantio/go-eth2-client/spec/altair" - "github.com/attestantio/go-eth2-client/spec/capella" - "github.com/attestantio/go-eth2-client/spec/electra" - "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/attestantio/go-eth2-client/http" + "github.com/attestantio/go-eth2-client/internal/eventdispatch" "github.com/rs/zerolog" ) -// Events feeds requested events with the given topics to the supplied handler. +// deferredEventsClient is a client whose subscription can be retried until it is synced. +type deferredEventsClient interface { + consensusclient.Service + consensusclient.EventsProvider + consensusclient.NodeSyncingProvider +} + +var ( + _ deferredEventsClient = (*Service)(nil) + // A client that is not a deferredEventsClient is never retried. This covers the HTTP + // clients WithAddresses creates; those supplied through WithClients are checked when Events + // is called. + _ deferredEventsClient = (*http.Service)(nil) +) + +// Events feeds requested events with the given topics to the supplied handler. It returns an +// error if no client is subscribed or awaiting a retry, as no event would then arrive. func (s *Service) Events(ctx context.Context, opts *api.EventsOpts, ) error { - if opts == nil { - return consensusclient.ErrNoOptions + if err := http.ValidateEventsOpts(opts); err != nil { + return err } // #nosec G404 @@ -45,310 +58,189 @@ func (s *Service) Events(ctx context.Context, // We listen to all active clients, and only pass along events from the currently active provider. // Grab local copy of both active and inactive clients in case it is updated whilst we are using it. + // The copies are only read. Clients to retry go on a list of Events' own, as the service's + // lists can have spare capacity, into which appending here, outside the lock, would write. s.clientsMu.RLock() activeClients := s.activeClients inactiveClients := s.inactiveClients s.clientsMu.RUnlock() - // Call all active clients immediately. + // Call all active clients immediately. Those that fail are retried with the inactive clients, + // but only after an interval, as they have just been tried. + subscribed := 0 + retries := make([]eventsRetry, 0, len(activeClients)+len(inactiveClients)) for _, client := range activeClients { - ah := &activeHandler{ - s: s, - log: log.With().Logger(), - address: client.Address(), - opts: &api.EventsOpts{ - Common: opts.Common, - }, - } - ah.opts.Handler = ah.genericHandler - ah.opts.AttestationHandler = ah.attestationHandler - ah.opts.AttesterSlashingHandler = ah.attesterSlashingHandler - ah.opts.BlobSidecarHandler = ah.blobSidecarHandler - ah.opts.BLSToExecutionChangeHandler = ah.blsToExecutionChangeHandler - ah.opts.ChainReorgHandler = ah.chainReorgHandler - ah.opts.ContributionAndProofHandler = ah.contributionAndProofHandler - ah.opts.FinalizedCheckpointHandler = ah.finalizedCheckpointHandler - ah.opts.HeadHandler = ah.headHandler - ah.opts.PayloadAttributesHandler = ah.payloadAttributesHandler - ah.opts.ProposerSlashingHandler = ah.proposerSlashingHandler - ah.opts.SingleAttestationHandler = ah.singleAttestationHandler - ah.opts.VoluntaryExitHandler = ah.voluntaryExitHandler - - if err := client.(consensusclient.EventsProvider).Events(ctx, ah.opts); err != nil { - inactiveClients = append(inactiveClients, client) - - continue + var ah *activeHandler + var err error + if provider, isProvider := client.(consensusclient.EventsProvider); isProvider { + ah = newActiveHandler(s, log, client.Address(), opts) + err = provider.Events(ctx, ah.clientOpts) + if err == nil { + subscribed++ + log.Trace().Str("address", ah.address).Strs("topics", opts.Topics).Msg("Events handler active") + + continue + } } - log.Trace().Str("address", ah.address).Strs("topics", opts.Topics).Msg("Events handler active") + if deferredClient, isRetryable := retryableEventsClient(log, client, opts.Topics, err); isRetryable { + retries = append(retries, eventsRetry{client: deferredClient, handler: ah, wait: true}) + } } - // Periodically try all inactive clients, quitting as they become active. for _, inactiveClient := range inactiveClients { - ah := &activeHandler{ - s: s, - log: log.With().Logger(), - address: inactiveClient.Address(), - opts: opts, + if deferredClient, isRetryable := retryableEventsClient(log, inactiveClient, opts.Topics, nil); isRetryable { + retries = append(retries, eventsRetry{ + client: deferredClient, + handler: newActiveHandler(s, log, deferredClient.Address(), opts), + }) } - go func(c consensusclient.Service, ah *activeHandler) { - for { - provider, isProvider := c.(consensusclient.NodeSyncingProvider) - if !isProvider { - ah.log.Error(). - Str("address", ah.address). - Strs("topics", opts.Topics). - Msg("Not a node syncing provider") + } + + // With no client subscribed or to be retried, no event would ever arrive. + if subscribed == 0 && len(retries) == 0 { + return errors.New("no client can provide events") + } - return + // Periodically try each client to retry, quitting as it becomes active. A failure to check + // sync state or to subscribe is retried rather than final: the client can still be made the + // active one later, and were it left unsubscribed its events would then never arrive. + for _, retry := range retries { + go func(c deferredEventsClient, ah *activeHandler, wait bool) { + for { + if wait { + select { + case <-ctx.Done(): + return + case <-time.After(s.retryInterval()): + } } + wait = true + + syncResponse, err := c.NodeSyncing(ctx, &api.NodeSyncingOpts{}) - syncResponse, err := provider.NodeSyncing(ctx, &api.NodeSyncingOpts{}) - if err != nil { - ah.log.Error(). + switch { + case err != nil: + ah.log.Warn(). Str("address", ah.address). - Strs("topics", opts.Topics). + Strs("topics", ah.clientOpts.Topics). Err(err). - Msg("Failed to obtain sync state from node") - - return - } - - if !syncResponse.Data.IsSyncing { - // Client is now synced, set up the events call. - if err := c.(consensusclient.EventsProvider).Events(ctx, opts); err != nil { - ah.log.Error(). - Str("address", ah.address). - Strs("topics", opts.Topics). - Err(err). - Msg("Failed to set up events handler") + Msg("Failed to obtain sync state from node; will retry") + case !syncResponse.Data.IsSyncing: + // Client is now synced, set up the events call. This uses the same filtered + // options as an initially-active client, so that events from it are subject to + // the same active-address filtering. + err := c.Events(ctx, ah.clientOpts) + if err == nil { + ah.log.Trace().Str("address", ah.address).Strs("topics", ah.clientOpts.Topics).Msg("Events handler active") + + return } - // Return either way. - return + ah.log.Warn(). + Str("address", ah.address). + Strs("topics", ah.clientOpts.Topics). + Err(err). + Msg("Failed to set up events handler; will retry") + default: + // Still syncing; check again after the interval. } - - time.Sleep(5 * time.Second) } - }(inactiveClient, ah) + }(retry.client, retry.handler, retry.wait) } return nil } -type activeHandler struct { - s *Service - log zerolog.Logger - address string - opts *api.EventsOpts -} - -func (h *activeHandler) attestationHandler(ctx context.Context, data *spec.VersionedAttestation) { - log := h.log.With().Str("address", h.address).Logger() - log.Trace().Msg("Attestation event received") - - // We only forward events from the currently active provider. If we did not do this then we could end up with - // inconsistent results, for example a client may receive a `head` event and a subsequent call to fetch the head - // block end up with an earlier block. - if h.s.Address() != h.address { - return - } - - log.Trace().Msg("Forwarding due to primary active address") - - h.opts.AttestationHandler(ctx, data) +// eventsRetry is a client whose subscription Events retries. +type eventsRetry struct { + client deferredEventsClient + // handler forwards the client's events while it is the active one. + handler *activeHandler + // wait is set if the client has just failed to subscribe, so is to wait an interval before + // it is first retried. + wait bool } -func (h *activeHandler) attesterSlashingHandler(ctx context.Context, data *electra.AttesterSlashing) { - log := h.log.With().Str("address", h.address).Logger() - log.Trace().Msg("Attester slashing event received") - - // We only forward events from the currently active provider. If we did not do this then we could end up with - // inconsistent results, for example a client may receive a `head` event and a subsequent call to fetch the head - // block end up with an earlier block. - if h.s.Address() != h.address { - return - } - - log.Trace().Msg("Forwarding due to primary active address") - - h.opts.AttesterSlashingHandler(ctx, data) +// retryableEventsClient reports whether the subscription of a client that is not subscribed can +// be retried, logging once for the client why it is being retried or dropped. subscribeErr is +// the error from an attempt to subscribe the client, or nil if none was made. +func retryableEventsClient(log zerolog.Logger, + client consensusclient.Service, + topics []string, + subscribeErr error, +) ( + deferredEventsClient, + bool, +) { + deferredClient, isDeferrable := client.(deferredEventsClient) + _, isProvider := client.(consensusclient.EventsProvider) + + level := zerolog.ErrorLevel + var msg string + switch { + case !isProvider: + msg = "Not an events provider" + case isDeferrable && subscribeErr == nil: + return deferredClient, true + case isDeferrable: + level, msg = zerolog.WarnLevel, "Failed to set up events handler; will retry" + case subscribeErr != nil: + msg = "Failed to set up events handler; not a node syncing provider, so will not retry" + default: + msg = "Not a node syncing provider; not subscribing to events" + } + log.WithLevel(level).Str("address", client.Address()).Strs("topics", topics).Err(subscribeErr).Msg(msg) + + return deferredClient, isDeferrable } -func (h *activeHandler) blobSidecarHandler(ctx context.Context, data *apiv1.BlobSidecarEvent) { - log := h.log.With().Str("address", h.address).Logger() - log.Trace().Msg("Blob sidecar event received") - - // We only forward events from the currently active provider. If we did not do this then we could end up with - // inconsistent results, for example a client may receive a `head` event and a subsequent call to fetch the head - // block end up with an earlier block. - if h.s.Address() != h.address { - return +// retryInterval returns how long Events waits between attempts to subscribe a deferred client. +// It is to be used in place of eventsRetryInterval, as a zero interval, as in a Service not built +// by New, would have the retry loop spin. +func (s *Service) retryInterval() time.Duration { + if s.eventsRetryInterval <= 0 { + return defaultEventsRetryInterval } - log.Trace().Msg("Forwarding due to primary active address") - - h.opts.BlobSidecarHandler(ctx, data) + return s.eventsRetryInterval } -func (h *activeHandler) blsToExecutionChangeHandler(ctx context.Context, data *capella.SignedBLSToExecutionChange) { - log := h.log.With().Str("address", h.address).Logger() - log.Trace().Msg("BLS to execution change event received") - - // We only forward events from the currently active provider. If we did not do this then we could end up with - // inconsistent results, for example a client may receive a `head` event and a subsequent call to fetch the head - // block end up with an earlier block. - if h.s.Address() != h.address { - return - } - - log.Trace().Msg("Forwarding due to primary active address") - - h.opts.BLSToExecutionChangeHandler(ctx, data) -} - -func (h *activeHandler) chainReorgHandler(ctx context.Context, data *apiv1.ChainReorgEvent) { - log := h.log.With().Str("address", h.address).Logger() - log.Trace().Msg("Chain reorg event received") - - // We only forward events from the currently active provider. If we did not do this then we could end up with - // inconsistent results, for example a client may receive a `head` event and a subsequent call to fetch the head - // block end up with an earlier block. - if h.s.Address() != h.address { - return - } - - log.Trace().Msg("Forwarding due to primary active address") - - h.opts.ChainReorgHandler(ctx, data) -} - -func (h *activeHandler) contributionAndProofHandler(ctx context.Context, data *altair.SignedContributionAndProof) { - log := h.log.With().Str("address", h.address).Logger() - log.Trace().Msg("Chain reorg event received") - - // We only forward events from the currently active provider. If we did not do this then we could end up with - // inconsistent results, for example a client may receive a `head` event and a subsequent call to fetch the head - // block end up with an earlier block. - if h.s.Address() != h.address { - return - } - - log.Trace().Msg("Forwarding due to primary active address") - - h.opts.ContributionAndProofHandler(ctx, data) -} - -func (h *activeHandler) finalizedCheckpointHandler(ctx context.Context, data *apiv1.FinalizedCheckpointEvent) { - log := h.log.With().Str("address", h.address).Logger() - log.Trace().Msg("Finalized checkpoint event received") - - // We only forward events from the currently active provider. If we did not do this then we could end up with - // inconsistent results, for example a client may receive a `head` event and a subsequent call to fetch the head - // block end up with an earlier block. - if h.s.Address() != h.address { - return - } - - log.Trace().Msg("Forwarding due to primary active address") - - h.opts.FinalizedCheckpointHandler(ctx, data) -} - -func (h *activeHandler) headHandler(ctx context.Context, data *apiv1.HeadEvent) { - log := h.log.With().Str("address", h.address).Logger() - log.Trace().Msg("Head event received") - - // We only forward events from the currently active provider. If we did not do this then we could end up with - // inconsistent results, for example a client may receive a `head` event and a subsequent call to fetch the head - // block end up with an earlier block. - if h.s.Address() != h.address { - return - } - - log.Trace().Msg("Forwarding due to primary active address") - - h.opts.HeadHandler(ctx, data) -} - -func (h *activeHandler) payloadAttributesHandler(ctx context.Context, data *apiv1.PayloadAttributesEvent) { - log := h.log.With().Str("address", h.address).Logger() - log.Trace().Msg("Payload attributes event received") - - // We only forward events from the currently active provider. If we did not do this then we could end up with - // inconsistent results, for example a client may receive a `head` event and a subsequent call to fetch the head - // block end up with an earlier block. - if h.s.Address() != h.address { - return - } - - log.Trace().Msg("Forwarding due to primary active address") - - h.opts.PayloadAttributesHandler(ctx, data) -} - -func (h *activeHandler) proposerSlashingHandler(ctx context.Context, data *phase0.ProposerSlashing) { - log := h.log.With().Str("address", h.address).Logger() - log.Trace().Msg("Proposer slashing event received") - - // We only forward events from the currently active provider. If we did not do this then we could end up with - // inconsistent results, for example a client may receive a `head` event and a subsequent call to fetch the head - // block end up with an earlier block. - if h.s.Address() != h.address { - return - } - - log.Trace().Msg("Forwarding due to primary active address") - - h.opts.ProposerSlashingHandler(ctx, data) -} - -func (h *activeHandler) singleAttestationHandler(ctx context.Context, data *electra.SingleAttestation) { - log := h.log.With().Str("address", h.address).Logger() - log.Trace().Msg("Single attestation event received") - - // We only forward events from the currently active provider. If we did not do this then we could end up with - // inconsistent results, for example a client may receive a `head` event and a subsequent call to fetch the head - // block end up with an earlier block. - if h.s.Address() != h.address { - return - } - - log.Trace().Msg("Forwarding due to primary active address") +type activeHandler struct { + s *Service + log zerolog.Logger + address string - h.opts.SingleAttestationHandler(ctx, data) + // clientOpts are the options handed to the underlying client: the caller's options filtered + // on the active address. One handler is built per client, each with options of its own. + clientOpts *api.EventsOpts } -func (h *activeHandler) voluntaryExitHandler(ctx context.Context, data *phase0.SignedVoluntaryExit) { - log := h.log.With().Str("address", h.address).Logger() - log.Trace().Msg("Voluntary exit event received") - - // We only forward events from the currently active provider. If we did not do this then we could end up with - // inconsistent results, for example a client may receive a `head` event and a subsequent call to fetch the head - // block end up with an earlier block. - if h.s.Address() != h.address { - return +// newActiveHandler creates a handler that filters the events of the client at the given address, +// forwarding those from the currently active client to the caller's handlers. +func newActiveHandler(s *Service, log zerolog.Logger, address string, opts *api.EventsOpts) *activeHandler { + ah := &activeHandler{ + s: s, + log: log, + address: address, } + ah.clientOpts = eventdispatch.ForwardingGuarded(opts, ah.forwards) - log.Trace().Msg("Forwarding due to primary active address") - - h.opts.VoluntaryExitHandler(ctx, data) + return ah } -func (h *activeHandler) genericHandler(event *apiv1.Event) { - log := h.log.With().Str("address", h.address).Str("topic", event.Topic).Logger() - log.Trace().Msg("Event received") +// forwards reports whether an event just received from this handler's client should be passed on +// to the caller. We only forward events from the currently active provider. If we did not do +// this then we could end up with inconsistent results, for example a client may receive a `head` +// event and a subsequent call to fetch the head block end up with an earlier block. +func (h *activeHandler) forwards(topic string) bool { + forwarding := h.s.Address() == h.address - // We only forward events from the currently active provider. If we did not do this then we could end up with - // inconsistent results, for example a client may receive a `head` event and a subsequent call to fetch the head - // block end up with an earlier block. - if h.s.Address() != h.address { - return - } + h.log.Trace(). + Str("address", h.address). + Str("topic", topic). + Bool("forwarding", forwarding). + Msg("Event received") - log.Trace().Msg("Forwarding due to primary active address") - - if h.opts.Handler != nil { - h.opts.Handler(event) - } + return forwarding } diff --git a/multi/events_internal_test.go b/multi/events_internal_test.go new file mode 100644 index 00000000..9d5ae596 --- /dev/null +++ b/multi/events_internal_test.go @@ -0,0 +1,318 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package multi + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + consensusclient "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + apiv1 "github.com/attestantio/go-eth2-client/api/v1" + "github.com/attestantio/go-eth2-client/mock" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" +) + +// TestEventsDoesNotWriteServiceClientLists confirms that an active client failing to subscribe +// is queued for retry on a list of Events' own. activateClient and deactivateClient leave the +// service's lists with spare capacity, so appending to a copy of the slice header would write +// into the service's backing array outside the lock, racing with those functions' own appends. +func TestEventsDoesNotWriteServiceClientLists(t *testing.T) { + // The failing client is retried until the context is done, so end it with the test. The + // mocks are given a context of their own, as ending theirs has them race on mock's logger. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + failing, err := mock.New(context.Background(), mock.WithName("failing")) + require.NoError(t, err) + failing.EventsFunc = func(context.Context, *api.EventsOpts) error { + return errors.New("failed to subscribe") + } + + inactive, err := mock.New(context.Background(), mock.WithName("inactive")) + require.NoError(t, err) + + inactiveClients := make([]consensusclient.Service, 1, 2) + inactiveClients[0] = inactive + + s := &Service{ + log: zerolog.Nop(), + activeClients: []consensusclient.Service{failing}, + inactiveClients: inactiveClients, + eventsRetryInterval: time.Millisecond, + } + + require.NoError(t, s.Events(ctx, &api.EventsOpts{ + Topics: []string{"head"}, + Handler: func(*apiv1.Event) {}, + })) + + s.clientsMu.RLock() + defer s.clientsMu.RUnlock() + require.Nil(t, s.inactiveClients[:cap(s.inactiveClients)][1], "Events wrote into the service's inactive client list") +} + +// TestEventsRetriesWithDefaultIntervalWhenUnset confirms that a Service whose retry interval is +// unset, as one not built by New, waits between attempts rather than retrying without pause. +func TestEventsRetriesWithDefaultIntervalWhenUnset(t *testing.T) { + // As in TestEventsDoesNotWriteServiceClientLists, the mock has a context of its own. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var attempts atomic.Int32 + failing, err := mock.New(context.Background(), mock.WithName("failing")) + require.NoError(t, err) + failing.EventsFunc = func(context.Context, *api.EventsOpts) error { + attempts.Add(1) + + return errors.New("failed to subscribe") + } + + s := &Service{ + log: zerolog.Nop(), + inactiveClients: []consensusclient.Service{failing}, + } + + require.NoError(t, s.Events(ctx, &api.EventsOpts{ + Topics: []string{"head"}, + Handler: func(*apiv1.Event) {}, + })) + + time.Sleep(50 * time.Millisecond) + require.LessOrEqual(t, attempts.Load(), int32(1), "retried without waiting for an interval") +} + +// syncingOnlyClient is a client that reports its sync state but does not provide events. +type syncingOnlyClient struct{} + +func (syncingOnlyClient) Name() string { return "syncing only" } +func (syncingOnlyClient) Address() string { return "syncing only" } +func (syncingOnlyClient) IsActive() bool { return true } +func (syncingOnlyClient) IsSynced() bool { return true } + +func (syncingOnlyClient) NodeSyncing(context.Context, *api.NodeSyncingOpts) (*api.Response[*apiv1.SyncState], error) { + return &api.Response[*apiv1.SyncState]{Data: &apiv1.SyncState{}}, nil +} + +// TestEventsSkipsClientsWithoutEvents confirms that a client that does not provide events, be it +// active or awaiting retry, is skipped rather than panicking Events, and that Events fails when +// that leaves no client to provide them. +func TestEventsSkipsClientsWithoutEvents(t *testing.T) { + provider, err := mock.New(context.Background(), mock.WithName("provider")) + require.NoError(t, err) + provider.EventsFunc = func(context.Context, *api.EventsOpts) error { return nil } + + tests := []struct { + name string + activeClients []consensusclient.Service + inactiveClients []consensusclient.Service + err string + }{ + { + name: "NoneProvide", + activeClients: []consensusclient.Service{syncingOnlyClient{}}, + inactiveClients: []consensusclient.Service{syncingOnlyClient{}}, + err: "no client can provide events", + }, + { + name: "OtherActiveProvides", + activeClients: []consensusclient.Service{syncingOnlyClient{}, provider}, + inactiveClients: []consensusclient.Service{syncingOnlyClient{}}, + }, + { + name: "OtherDeferredProvides", + activeClients: []consensusclient.Service{syncingOnlyClient{}}, + inactiveClients: []consensusclient.Service{syncingOnlyClient{}, provider}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + s := &Service{ + log: zerolog.Nop(), + activeClients: test.activeClients, + inactiveClients: test.inactiveClients, + eventsRetryInterval: time.Millisecond, + } + + var err error + require.NotPanics(t, func() { + err = s.Events(ctx, &api.EventsOpts{ + Topics: []string{"head"}, + Handler: func(*apiv1.Event) {}, + }) + }) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestRetryInterval(t *testing.T) { + tests := []struct { + name string + interval time.Duration + expected time.Duration + }{ + {name: "Set", interval: time.Millisecond, expected: time.Millisecond}, + {name: "Zero", interval: 0, expected: defaultEventsRetryInterval}, + {name: "Negative", interval: -time.Second, expected: defaultEventsRetryInterval}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.expected, (&Service{eventsRetryInterval: test.interval}).retryInterval()) + }) + } +} + +// eventsOnlyClient is a client that provides events, failing to subscribe, but does not report its +// sync state. +type eventsOnlyClient struct{} + +func (eventsOnlyClient) Name() string { return "events only" } +func (eventsOnlyClient) Address() string { return "events only" } +func (eventsOnlyClient) IsActive() bool { return true } +func (eventsOnlyClient) IsSynced() bool { return true } + +func (eventsOnlyClient) Events(context.Context, *api.EventsOpts) error { + return errors.New("subscription refused") +} + +// lockedBuffer is a buffer that can be written by one goroutine while read by another. +type lockedBuffer struct { + mu sync.Mutex + buffer bytes.Buffer +} + +func (b *lockedBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + + return b.buffer.Write(p) +} + +func (b *lockedBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + + return b.buffer.String() +} + +// TestEventsLogsWhyClientIsDropped confirms that each client Events does not subscribe is logged +// once, saying whether it will be retried and, if not, what it lacks. +func TestEventsLogsWhyClientIsDropped(t *testing.T) { + failing, err := mock.New(context.Background(), mock.WithName("failing")) + require.NoError(t, err) + failing.EventsFunc = func(context.Context, *api.EventsOpts) error { + return errors.New("subscription refused") + } + + provider, err := mock.New(context.Background(), mock.WithName("provider")) + require.NoError(t, err) + provider.EventsFunc = func(context.Context, *api.EventsOpts) error { return nil } + + tests := []struct { + name string + activeClients []consensusclient.Service + inactiveClients []consensusclient.Service + err string + expected string + }{ + { + name: "ActiveFailsDeferrable", + activeClients: []consensusclient.Service{failing}, + expected: `{"level":"warn","address":"failing","topics":["head"],"error":"subscription refused","message":"Failed to set up events handler; will retry"}`, + }, + { + name: "ActiveFailsNotDeferrable", + activeClients: []consensusclient.Service{provider, eventsOnlyClient{}}, + expected: `{"level":"error","address":"events only","topics":["head"],"error":"subscription refused","message":"Failed to set up events handler; not a node syncing provider, so will not retry"}`, + }, + { + name: "OnlyClientFailsNotDeferrable", + activeClients: []consensusclient.Service{eventsOnlyClient{}}, + err: "no client can provide events", + expected: `{"level":"error","address":"events only","topics":["head"],"error":"subscription refused","message":"Failed to set up events handler; not a node syncing provider, so will not retry"}`, + }, + { + name: "InactiveNotEventsProvider", + activeClients: []consensusclient.Service{provider}, + inactiveClients: []consensusclient.Service{syncingOnlyClient{}}, + expected: `{"level":"error","address":"syncing only","topics":["head"],"message":"Not an events provider"}`, + }, + { + name: "InactiveNotSyncingProvider", + activeClients: []consensusclient.Service{provider}, + inactiveClients: []consensusclient.Service{eventsOnlyClient{}}, + expected: `{"level":"error","address":"events only","topics":["head"],"message":"Not a node syncing provider; not subscribing to events"}`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // The deferred client is retried until the context is done, so end it with the test. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + output := &lockedBuffer{} + s := &Service{ + log: zerolog.New(output).Level(zerolog.WarnLevel), + activeClients: test.activeClients, + inactiveClients: test.inactiveClients, + eventsRetryInterval: time.Hour, + } + + err := s.Events(ctx, &api.EventsOpts{ + Topics: []string{"head"}, + Handler: func(*apiv1.Event) {}, + }) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + } + + // Events writes the line before it returns. A client that failed to subscribe waits + // an interval before its retry goroutine tries again, so no further line follows. + require.Never(t, func() bool { + return strings.Count(output.String(), "\n") > 1 + }, 50*time.Millisecond, 5*time.Millisecond, "expected exactly one log line") + lines := strings.Split(strings.TrimSpace(output.String()), "\n") + require.Len(t, lines, 1, "expected exactly one log line") + + // Each line carries a random call id, which is dropped before comparing. + var logged map[string]any + require.NoError(t, json.Unmarshal([]byte(lines[0]), &logged)) + delete(logged, "id") + actual, err := json.Marshal(logged) + require.NoError(t, err) + require.JSONEq(t, test.expected, string(actual)) + }) + } +} diff --git a/multi/events_test.go b/multi/events_test.go index ab3f03b6..be2e1cfc 100644 --- a/multi/events_test.go +++ b/multi/events_test.go @@ -1,4 +1,4 @@ -// Copyright © 2021 Attestant Limited. +// Copyright © 2021 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -15,10 +15,15 @@ package multi_test import ( "context" + "errors" + "reflect" + "sync/atomic" "testing" + "time" consensusclient "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/api" + apiv1 "github.com/attestantio/go-eth2-client/api/v1" "github.com/attestantio/go-eth2-client/mock" "github.com/attestantio/go-eth2-client/multi" "github.com/attestantio/go-eth2-client/testclients" @@ -26,6 +31,56 @@ import ( "github.com/stretchr/testify/require" ) +func TestEventsRejectsMissingHandler(t *testing.T) { + ctx := context.Background() + client, subscribed := mockCapturingEvents(ctx, t, "mock 1") + + multiClient, err := multi.New(ctx, + multi.WithLogLevel(zerolog.Disabled), + multi.WithClients([]consensusclient.Service{client}), + ) + require.NoError(t, err) + + err = multiClient.(consensusclient.EventsProvider).Events(ctx, &api.EventsOpts{Topics: []string{"head"}}) + require.ErrorIs(t, err, consensusclient.ErrInvalidOptions) + require.ErrorContains(t, err, "no handler for head event") + require.Nil(t, subscribed(), "invalid options were passed to a client") +} + +func TestEventsRejectsUnknownTopic(t *testing.T) { + ctx := context.Background() + client, subscribed := mockCapturingEvents(ctx, t, "mock 1") + + multiClient, err := multi.New(ctx, + multi.WithLogLevel(zerolog.Disabled), + multi.WithClients([]consensusclient.Service{client}), + ) + require.NoError(t, err) + + err = multiClient.(consensusclient.EventsProvider).Events(ctx, &api.EventsOpts{ + Topics: []string{"head", "typo"}, + Handler: func(*apiv1.Event) {}, + }) + require.ErrorIs(t, err, consensusclient.ErrInvalidOptions) + require.ErrorContains(t, err, "unsupported event topic typo") + require.Nil(t, subscribed(), "invalid options were passed to a client") +} + +func TestEventsRejectsEmptyTopics(t *testing.T) { + ctx := context.Background() + client, subscribed := mockCapturingEvents(ctx, t, "mock 1") + + multiClient, err := multi.New(ctx, + multi.WithLogLevel(zerolog.Disabled), + multi.WithClients([]consensusclient.Service{client}), + ) + require.NoError(t, err) + + err = multiClient.(consensusclient.EventsProvider).Events(ctx, &api.EventsOpts{}) + require.ErrorIs(t, err, consensusclient.ErrInvalidOptions) + require.Nil(t, subscribed(), "invalid options were passed to a client") +} + func TestEvents(t *testing.T) { ctx := context.Background() @@ -51,6 +106,523 @@ func TestEvents(t *testing.T) { require.NoError(t, err) require.NoError(t, multiClient.(consensusclient.EventsProvider).Events(ctx, &api.EventsOpts{ - Topics: []string{"block"}, + Topics: []string{"block"}, + BlockHandler: func(context.Context, *apiv1.BlockEvent) {}, + })) +} + +// TestEventsForwardsGenericHandler confirms that an event delivered by an active client +// reaches the caller's own generic handler, exactly once. +func TestEventsForwardsGenericHandler(t *testing.T) { + ctx := context.Background() + + client, clientOpts := mockCapturingEvents(ctx, t, "mock 1") + + multiClient, err := multi.New(ctx, + multi.WithLogLevel(zerolog.Disabled), + multi.WithClients([]consensusclient.Service{client}), + ) + require.NoError(t, err) + + received := 0 + require.NoError(t, multiClient.(consensusclient.EventsProvider).Events(ctx, &api.EventsOpts{ + Topics: []string{"head"}, + Handler: func(*apiv1.Event) { received++ }, + })) + + require.NotNil(t, clientOpts(), "underlying client was never subscribed") + require.NotNil(t, clientOpts().Handler, "no generic handler passed to the underlying client") + + // Deliver an event as the underlying client would. + clientOpts().Handler(&apiv1.Event{Topic: "head"}) + + require.Equal(t, 1, received) +} + +// TestEventsForwardsTopicHandler confirms that an event delivered by an active client reaches +// the caller's own topic-specific handler, exactly once. +func TestEventsForwardsTopicHandler(t *testing.T) { + ctx := context.Background() + + client, clientOpts := mockCapturingEvents(ctx, t, "mock 1") + + multiClient, err := multi.New(ctx, + multi.WithLogLevel(zerolog.Disabled), + multi.WithClients([]consensusclient.Service{client}), + ) + require.NoError(t, err) + + received := 0 + require.NoError(t, multiClient.(consensusclient.EventsProvider).Events(ctx, &api.EventsOpts{ + Topics: []string{"block"}, + BlockHandler: func(context.Context, *apiv1.BlockEvent) { received++ }, })) + + require.NotNil(t, clientOpts(), "underlying client was never subscribed") + require.NotNil(t, clientOpts().BlockHandler, "no block handler passed to the underlying client") + + // Deliver an event as the underlying client would. + clientOpts().BlockHandler(ctx, &apiv1.BlockEvent{}) + + require.Equal(t, 1, received) +} + +func TestEventsForwardsHeadV2Handler(t *testing.T) { + ctx := context.Background() + + client, clientOpts := mockCapturingEvents(ctx, t, "mock 1") + multiClient, err := multi.New(ctx, + multi.WithLogLevel(zerolog.Disabled), + multi.WithClients([]consensusclient.Service{client}), + ) + require.NoError(t, err) + + received := 0 + require.NoError(t, multiClient.(consensusclient.EventsProvider).Events(ctx, &api.EventsOpts{ + Topics: []string{"head_v2"}, + HeadV2Handler: func(context.Context, *apiv1.HeadEventV2) { received++ }, + })) + + require.NotNil(t, clientOpts()) + require.NotNil(t, clientOpts().HeadV2Handler) + clientOpts().HeadV2Handler(ctx, &apiv1.HeadEventV2{}) + require.Equal(t, 1, received) +} + +func TestEventsFiltersNonPrimaryHeadV2(t *testing.T) { + ctx := context.Background() + primary, primaryOpts := mockCapturingEvents(ctx, t, "mock 1") + secondary, secondaryOpts := mockCapturingEvents(ctx, t, "mock 2") + multiClient, err := multi.New(ctx, + multi.WithLogLevel(zerolog.Disabled), + multi.WithClients([]consensusclient.Service{primary, secondary}), + ) + require.NoError(t, err) + require.Equal(t, "mock 1", multiClient.Address()) + + received := 0 + require.NoError(t, multiClient.(consensusclient.EventsProvider).Events(ctx, &api.EventsOpts{ + Topics: []string{"head_v2"}, + HeadV2Handler: func(context.Context, *apiv1.HeadEventV2) { received++ }, + })) + require.NotNil(t, secondaryOpts()) + secondaryOpts().HeadV2Handler(ctx, &apiv1.HeadEventV2{}) + require.Zero(t, received) + require.NotNil(t, primaryOpts()) + primaryOpts().HeadV2Handler(ctx, &apiv1.HeadEventV2{}) + require.Equal(t, 1, received) +} + +// TestEventsFiltersNonPrimaryClient confirms that events are forwarded only from the client +// that is currently the primary active one. Were events from every active client forwarded we +// could end up with inconsistent results, a `head` event arriving from one client while a +// subsequent fetch is answered by another that is behind it. +func TestEventsFiltersNonPrimaryClient(t *testing.T) { + ctx := context.Background() + + primary, primaryOpts := mockCapturingEvents(ctx, t, "mock 1") + secondary, secondaryOpts := mockCapturingEvents(ctx, t, "mock 2") + + multiClient, err := multi.New(ctx, + multi.WithLogLevel(zerolog.Disabled), + multi.WithClients([]consensusclient.Service{primary, secondary}), + ) + require.NoError(t, err) + require.Equal(t, "mock 1", multiClient.Address(), "the first client must be the primary") + + received := 0 + require.NoError(t, multiClient.(consensusclient.EventsProvider).Events(ctx, &api.EventsOpts{ + Topics: []string{"head"}, + Handler: func(*apiv1.Event) { received++ }, + })) + + require.NotNil(t, secondaryOpts(), "the secondary client was never subscribed") + secondaryOpts().Handler(&apiv1.Event{Topic: "head"}) + require.Equal(t, 0, received, "an event from a non-primary client must not be forwarded") + + require.NotNil(t, primaryOpts(), "the primary client was never subscribed") + primaryOpts().Handler(&apiv1.Event{Topic: "head"}) + require.Equal(t, 1, received, "an event from the primary client must be forwarded") +} + +// TestEventsFiltersClientThatBecomesActive confirms that a client which was not synced when the +// subscription was made is subject to the same active-address filtering as one that was. Such a +// client is subscribed later, from a goroutine, once it reports itself synced; that subscription +// must not bypass the filter, or events from a client that is not the primary one are forwarded. +func TestEventsFiltersClientThatBecomesActive(t *testing.T) { + ctx := context.Background() + + primary, _ := mockCapturingEvents(ctx, t, "mock 1") + + late, lateOpts := mockCapturingEvents(ctx, t, "mock 2") + // Not synced, so it starts out inactive, but reports itself synced when next asked. + late.SyncDistance = 1 + late.NodeSyncingFunc = func(context.Context, *api.NodeSyncingOpts) (*api.Response[*apiv1.SyncState], error) { + return &api.Response[*apiv1.SyncState]{Data: &apiv1.SyncState{IsSyncing: false}}, nil + } + + multiClient, err := multi.New(ctx, + multi.WithLogLevel(zerolog.Disabled), + multi.WithClients([]consensusclient.Service{primary, late}), + ) + require.NoError(t, err) + require.Equal(t, "mock 1", multiClient.Address(), "the synced client must be the primary") + + received := 0 + require.NoError(t, multiClient.(consensusclient.EventsProvider).Events(ctx, &api.EventsOpts{ + Topics: []string{"head"}, + Handler: func(*apiv1.Event) { received++ }, + })) + + require.Eventually(t, func() bool { return lateOpts() != nil }, 5*time.Second, 10*time.Millisecond, + "the client that became active was never subscribed") + + lateOpts().Handler(&apiv1.Event{Topic: "head"}) + require.Equal(t, 0, received, "an event from a non-primary client must not be forwarded") +} + +// TestEventsPassesOptionsToClient confirms the caller's topics and common options reach the +// underlying client. A client rejects a subscription naming no topics, so an active client +// handed options stripped of them does not subscribe at all: it reports a failure, and is +// dropped from the active list. +func TestEventsPassesOptionsToClient(t *testing.T) { + ctx := context.Background() + + client, clientOpts := mockCapturingEvents(ctx, t, "mock 1") + + multiClient, err := multi.New(ctx, + multi.WithLogLevel(zerolog.Disabled), + multi.WithClients([]consensusclient.Service{client}), + ) + require.NoError(t, err) + + require.NoError(t, multiClient.(consensusclient.EventsProvider).Events(ctx, &api.EventsOpts{ + Common: api.CommonOpts{Timeout: 17 * time.Second}, + Topics: []string{"head", "block"}, + Handler: func(*apiv1.Event) {}, + })) + + require.NotNil(t, clientOpts(), "underlying client was never subscribed") + require.Equal(t, []string{"head", "block"}, clientOpts().Topics) + require.Equal(t, 17*time.Second, clientOpts().Common.Timeout, "common options did not reach the client") +} + +// TestEventsOwnsTopics confirms that a subscription retains the requested topics even if the +// caller later reuses its options slice. +func TestEventsOwnsTopics(t *testing.T) { + ctx := context.Background() + + client, clientOpts := mockCapturingEvents(ctx, t, "mock 1") + + multiClient, err := multi.New(ctx, + multi.WithLogLevel(zerolog.Disabled), + multi.WithClients([]consensusclient.Service{client}), + ) + require.NoError(t, err) + + topics := []string{"head", "block"} + require.NoError(t, multiClient.(consensusclient.EventsProvider).Events(ctx, &api.EventsOpts{ + Topics: topics, + Handler: func(*apiv1.Event) {}, + })) + + topics[0] = "attestation" + + require.NotNil(t, clientOpts(), "underlying client was never subscribed") + require.Equal(t, []string{"head", "block"}, clientOpts().Topics) +} + +// TestEventsOwnsTopicsForDeferredClients confirms that a client subscribed later, once it +// reports itself synced, retains the requested topics when the caller reuses the options slice +// after Events returns. +func TestEventsOwnsTopicsForDeferredClients(t *testing.T) { + ctx := context.Background() + + primary, _ := mockCapturingEvents(ctx, t, "mock 1") + deferred, deferredOpts := mockCapturingEvents(ctx, t, "mock 2") + deferred.SyncDistance = 1 + syncStarted := make(chan struct{}) + continueSync := make(chan struct{}) + deferred.NodeSyncingFunc = func(context.Context, *api.NodeSyncingOpts) (*api.Response[*apiv1.SyncState], error) { + close(syncStarted) + <-continueSync + + return &api.Response[*apiv1.SyncState]{Data: &apiv1.SyncState{IsSyncing: false}}, nil + } + + multiClient, err := multi.New(ctx, + multi.WithLogLevel(zerolog.Disabled), + multi.WithClients([]consensusclient.Service{primary, deferred}), + ) + require.NoError(t, err) + + topics := []string{"head", "block"} + require.NoError(t, multiClient.(consensusclient.EventsProvider).Events(ctx, &api.EventsOpts{ + Topics: topics, + Handler: func(*apiv1.Event) {}, + })) + + <-syncStarted + topics[0] = "attestation" + close(continueSync) + + require.Eventually(t, func() bool { return deferredOpts() != nil }, 5*time.Second, 10*time.Millisecond, + "the deferred client was never subscribed") + require.Equal(t, []string{"head", "block"}, deferredOpts().Topics) +} + +// TestEventsRetriesDeferredClient confirms that a client which was not synced when the +// subscription was made is retried after a failure to check its sync state or to subscribe, +// rather than abandoned. An abandoned client can still become the primary one, and as events +// are forwarded only from the primary the caller would then receive none at all. +func TestEventsRetriesDeferredClient(t *testing.T) { + tests := []struct { + name string + syncErrors int32 + eventErrors int32 + }{ + { + name: "SyncStateFailure", + syncErrors: 1, + }, + { + name: "SubscribeFailure", + eventErrors: 1, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx := context.Background() + + primary, _ := mockCapturingEvents(ctx, t, "mock 1") + deferred, deferredOpts := mockCapturingEvents(ctx, t, "mock 2") + deferred.SyncDistance = 1 + + var syncCalls atomic.Int32 + deferred.NodeSyncingFunc = func(context.Context, *api.NodeSyncingOpts) (*api.Response[*apiv1.SyncState], error) { + if syncCalls.Add(1) <= test.syncErrors { + return nil, errors.New("failed to obtain sync state") + } + + return &api.Response[*apiv1.SyncState]{Data: &apiv1.SyncState{IsSyncing: false}}, nil + } + + subscribe := deferred.EventsFunc + var eventCalls atomic.Int32 + deferred.EventsFunc = func(ctx context.Context, opts *api.EventsOpts) error { + if eventCalls.Add(1) <= test.eventErrors { + return errors.New("failed to subscribe") + } + + return subscribe(ctx, opts) + } + + multiClient, err := multi.New(ctx, + multi.WithLogLevel(zerolog.Disabled), + multi.WithClients([]consensusclient.Service{primary, deferred}), + multi.WithEventsRetryInterval(time.Millisecond), + ) + require.NoError(t, err) + + require.NoError(t, multiClient.(consensusclient.EventsProvider).Events(ctx, &api.EventsOpts{ + Topics: []string{"head"}, + Handler: func(*apiv1.Event) {}, + })) + + require.Eventually(t, func() bool { return deferredOpts() != nil }, 5*time.Second, time.Millisecond, + "the deferred client was abandoned after a failure") + }) + } +} + +// TestEventsRetriesFailedActiveClient confirms that an active client that fails to subscribe is +// retried after the configured interval, and subscribed, rather than abandoned. +func TestEventsRetriesFailedActiveClient(t *testing.T) { + ctx := context.Background() + + failing, failingOpts := mockCapturingEvents(ctx, t, "mock 1") + other, _ := mockCapturingEvents(ctx, t, "mock 2") + + subscribe := failing.EventsFunc + var eventCalls atomic.Int32 + failing.EventsFunc = func(ctx context.Context, opts *api.EventsOpts) error { + if eventCalls.Add(1) == 1 { + return errors.New("failed to subscribe") + } + + return subscribe(ctx, opts) + } + + multiClient, err := multi.New(ctx, + multi.WithLogLevel(zerolog.Disabled), + multi.WithClients([]consensusclient.Service{failing, other}), + multi.WithEventsRetryInterval(time.Millisecond), + ) + require.NoError(t, err) + + require.NoError(t, multiClient.(consensusclient.EventsProvider).Events(ctx, &api.EventsOpts{ + Topics: []string{"head"}, + Handler: func(*apiv1.Event) {}, + })) + + require.Eventually(t, func() bool { return failingOpts() != nil }, 5*time.Second, time.Millisecond, + "the client that failed to subscribe was never retried") + require.Equal(t, int32(2), eventCalls.Load()) +} + +// TestEventsStopsRetryingOnContextDone confirms that a deferred client is retried only for as +// long as the subscription's context lives, so that one which never syncs does not leave a +// goroutine polling it for the life of the process. +func TestEventsStopsRetryingOnContextDone(t *testing.T) { + primary, _ := mockCapturingEvents(context.Background(), t, "mock 1") + deferred, _ := mockCapturingEvents(context.Background(), t, "mock 2") + deferred.SyncDistance = 1 + + var syncCalls atomic.Int32 + deferred.NodeSyncingFunc = func(context.Context, *api.NodeSyncingOpts) (*api.Response[*apiv1.SyncState], error) { + syncCalls.Add(1) + + return nil, errors.New("failed to obtain sync state") + } + + multiClient, err := multi.New(context.Background(), + multi.WithLogLevel(zerolog.Disabled), + multi.WithClients([]consensusclient.Service{primary, deferred}), + multi.WithEventsRetryInterval(time.Millisecond), + ) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + require.NoError(t, multiClient.(consensusclient.EventsProvider).Events(ctx, &api.EventsOpts{ + Topics: []string{"head"}, + Handler: func(*apiv1.Event) {}, + })) + + require.Eventually(t, func() bool { return syncCalls.Load() > 1 }, 5*time.Second, time.Millisecond, + "the deferred client was not retried") + cancel() + + // Allow a retry already under way to finish, then confirm no further ones start. + time.Sleep(50 * time.Millisecond) + calls := syncCalls.Load() + time.Sleep(50 * time.Millisecond) + require.Equal(t, calls, syncCalls.Load(), "the deferred client was retried after its context was done") +} + +// TestEventsForwardsEveryHandler confirms that every handler in api.EventsOpts is substituted +// and forwarded back to the caller. The handler fields are walked reflectively rather than +// named, so that a handler added to api.EventsOpts in the future cannot be silently left +// unwired here: it joins this test automatically, and fails it until it is forwarded. +func TestEventsForwardsEveryHandler(t *testing.T) { + ctx := context.Background() + + handlers := handlerFields() + require.NotEmpty(t, handlers, "no handler fields found in api.EventsOpts") + + for _, handler := range handlers { + t.Run(handler.Name, func(t *testing.T) { + client, clientOpts := mockCapturingEvents(ctx, t, "mock 1") + + multiClient, err := multi.New(ctx, + multi.WithLogLevel(zerolog.Disabled), + multi.WithClients([]consensusclient.Service{client}), + ) + require.NoError(t, err) + + // Supply this specific handler and a generic handler for the head subscription. + received := 0 + opts := &api.EventsOpts{ + Topics: []string{"head"}, + Handler: func(*apiv1.Event) {}, + } + reflect.ValueOf(opts).Elem().FieldByName(handler.Name).Set( + reflect.MakeFunc(handler.Type, func([]reflect.Value) []reflect.Value { + received++ + + return nil + }), + ) + + require.NoError(t, multiClient.(consensusclient.EventsProvider).Events(ctx, opts)) + + require.NotNil(t, clientOpts(), "underlying client was never subscribed") + substituted := reflect.ValueOf(clientOpts()).Elem().FieldByName(handler.Name) + require.False(t, substituted.IsNil(), "handler not passed to the underlying client") + + // No other specific handler is substituted. The generic handler makes the head + // subscription valid even when testing another topic's handler. + for _, other := range handlers { + if other.Name == handler.Name || other.Name == "Handler" { + continue + } + + unsupplied := reflect.ValueOf(clientOpts()).Elem().FieldByName(other.Name) + require.True(t, unsupplied.IsNil(), "unsupplied handler %s was substituted", other.Name) + } + + // Deliver an event as the underlying client would. + substituted.Call(handlerArgs(ctx, handler.Type)) + + require.Equal(t, 1, received, "event did not reach the caller's handler exactly once") + }) + } +} + +// handlerFields returns the handler fields of api.EventsOpts, being those that are functions. +func handlerFields() []reflect.StructField { + optsType := reflect.TypeFor[api.EventsOpts]() + + fields := make([]reflect.StructField, 0, optsType.NumField()) + + for i := range optsType.NumField() { + if field := optsType.Field(i); field.Type.Kind() == reflect.Func { + fields = append(fields, field) + } + } + + return fields +} + +// handlerArgs builds an argument list with which a handler of the given type can be called: the +// context where one is wanted, and a pointer to a zero value elsewhere, given that handlers can +// be expected to dereference the event they are passed. +func handlerArgs(ctx context.Context, handler reflect.Type) []reflect.Value { + ctxType := reflect.TypeFor[context.Context]() + + args := make([]reflect.Value, 0, handler.NumIn()) + + for i := range handler.NumIn() { + switch arg := handler.In(i); { + case arg == ctxType: + args = append(args, reflect.ValueOf(ctx)) + case arg.Kind() == reflect.Pointer: + args = append(args, reflect.New(arg.Elem())) + default: + args = append(args, reflect.Zero(arg)) + } + } + + return args +} + +// mockCapturingEvents returns a synced mock client that captures the options it is handed by +// Events(), along with an accessor returning those options, or nil if it was never subscribed. +// The accessor is safe to call from another goroutine, as a client that starts out inactive is +// subscribed from one. +func mockCapturingEvents(ctx context.Context, t *testing.T, name string) (*mock.Service, func() *api.EventsOpts) { + t.Helper() + + client, err := mock.New(ctx, mock.WithName(name)) + require.NoError(t, err) + + var captured atomic.Pointer[api.EventsOpts] + + client.EventsFunc = func(_ context.Context, opts *api.EventsOpts) error { + captured.Store(opts) + + return nil + } + + return client, captured.Load } diff --git a/multi/executionpayloadenvelope.go b/multi/executionpayloadenvelope.go new file mode 100644 index 00000000..eb4b40e6 --- /dev/null +++ b/multi/executionpayloadenvelope.go @@ -0,0 +1,49 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package multi + +import ( + "context" + + consensusclient "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" +) + +// ExecutionPayloadEnvelope obtains the cached envelope for a slot and block root. +func (s *Service) ExecutionPayloadEnvelope(ctx context.Context, + opts *api.ExecutionPayloadEnvelopeOpts, +) ( + *api.Response[*spec.VersionedExecutionPayloadEnvelope], + error, +) { + res, err := s.doCall(ctx, func(ctx context.Context, client consensusclient.Service) (any, error) { + envelope, err := client.(consensusclient.ExecutionPayloadEnvelopeProvider).ExecutionPayloadEnvelope(ctx, opts) + if err != nil { + return nil, err + } + + return envelope, nil + }, nil) + if err != nil { + return nil, err + } + + response, isResponse := res.(*api.Response[*spec.VersionedExecutionPayloadEnvelope]) + if !isResponse { + return nil, ErrIncorrectType + } + + return response, nil +} diff --git a/multi/parameters.go b/multi/parameters.go index 86e30547..d2190fa1 100644 --- a/multi/parameters.go +++ b/multi/parameters.go @@ -32,6 +32,8 @@ type parameters struct { enforceJSON bool allowDelayedStart bool name string + + eventsRetryInterval time.Duration } // Parameter is the interface for service parameters. @@ -108,12 +110,22 @@ func WithName(name string) Parameter { }) } +// WithEventsRetryInterval sets how long Events waits between attempts to subscribe a client that +// is not subscribed. A client that fails to subscribe when Events is called waits this long +// before its first retry; one that was not synced then is tried at once. +func WithEventsRetryInterval(interval time.Duration) Parameter { + return parameterFunc(func(p *parameters) { + p.eventsRetryInterval = interval + }) +} + // parseAndCheckParameters parses and checks parameters to ensure that mandatory parameters are present and correct. func parseAndCheckParameters(params ...Parameter) (*parameters, error) { parameters := parameters{ - logLevel: zerolog.GlobalLevel(), - timeout: 2 * time.Second, - extraHeaders: make(map[string]string), + logLevel: zerolog.GlobalLevel(), + timeout: 2 * time.Second, + extraHeaders: make(map[string]string), + eventsRetryInterval: defaultEventsRetryInterval, } for _, p := range params { @@ -126,6 +138,10 @@ func parseAndCheckParameters(params ...Parameter) (*parameters, error) { return nil, errors.New("no timeout specified") } + if parameters.eventsRetryInterval <= 0 { + return nil, errors.New("events retry interval must be positive") + } + if len(parameters.clients)+len(parameters.addresses) == 0 { return nil, errors.New("no Ethereum 2 clients specified") } diff --git a/multi/payloadattestationdata.go b/multi/payloadattestationdata.go new file mode 100644 index 00000000..cc3c0800 --- /dev/null +++ b/multi/payloadattestationdata.go @@ -0,0 +1,49 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package multi + +import ( + "context" + + consensusclient "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" +) + +// PayloadAttestationData obtains payload attestation data for the given options. +func (s *Service) PayloadAttestationData(ctx context.Context, + opts *api.PayloadAttestationDataOpts, +) ( + *api.Response[*spec.VersionedPayloadAttestationData], + error, +) { + res, err := s.doCall(ctx, func(ctx context.Context, client consensusclient.Service) (any, error) { + data, err := client.(consensusclient.PayloadAttestationDataProvider).PayloadAttestationData(ctx, opts) + if err != nil { + return nil, err + } + + return data, nil + }, nil) + if err != nil { + return nil, err + } + + response, isResponse := res.(*api.Response[*spec.VersionedPayloadAttestationData]) + if !isResponse { + return nil, ErrIncorrectType + } + + return response, nil +} diff --git a/multi/payloadattestationpool.go b/multi/payloadattestationpool.go new file mode 100644 index 00000000..16e20364 --- /dev/null +++ b/multi/payloadattestationpool.go @@ -0,0 +1,49 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package multi + +import ( + "context" + + consensusclient "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" +) + +// PayloadAttestationPool obtains the payload attestation pool for the given options. +func (s *Service) PayloadAttestationPool(ctx context.Context, + opts *api.PayloadAttestationPoolOpts, +) ( + *api.Response[[]*spec.VersionedPayloadAttestation], + error, +) { + res, err := s.doCall(ctx, func(ctx context.Context, client consensusclient.Service) (any, error) { + pool, err := client.(consensusclient.PayloadAttestationPoolProvider).PayloadAttestationPool(ctx, opts) + if err != nil { + return nil, err + } + + return pool, nil + }, nil) + if err != nil { + return nil, err + } + + response, isResponse := res.(*api.Response[[]*spec.VersionedPayloadAttestation]) + if !isResponse { + return nil, ErrIncorrectType + } + + return response, nil +} diff --git a/multi/proposerduties.go b/multi/proposerduties.go index c5e592ca..7e126107 100644 --- a/multi/proposerduties.go +++ b/multi/proposerduties.go @@ -15,12 +15,46 @@ package multi import ( "context" + "fmt" consensusclient "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/api" apiv1 "github.com/attestantio/go-eth2-client/api/v1" ) +// ProposerDutiesV2 obtains proposer duties for the given epoch using the v2 API. +// If opts.Indices is empty all duties are returned, otherwise only matching duties are returned. +func (s *Service) ProposerDutiesV2(ctx context.Context, + opts *api.ProposerDutiesOpts, +) ( + *api.Response[[]*apiv1.ProposerDuty], + error, +) { + res, err := s.doCall(ctx, func(ctx context.Context, client consensusclient.Service) (any, error) { + provider, supported := client.(consensusclient.ProposerDutiesV2Provider) + if !supported { + return nil, fmt.Errorf("%s@%s does not support this call", client.Name(), client.Address()) + } + + duties, err := provider.ProposerDutiesV2(ctx, opts) + if err != nil { + return nil, err + } + + return duties, nil + }, nil) + if err != nil { + return nil, err + } + + response, isResponse := res.(*api.Response[[]*apiv1.ProposerDuty]) + if !isResponse { + return nil, ErrIncorrectType + } + + return response, nil +} + // ProposerDuties obtains proposer duties for the given epoch. // If validatorIndices is empty all duties are returned, otherwise only matching duties are returned. func (s *Service) ProposerDuties(ctx context.Context, diff --git a/multi/proposerduties_test.go b/multi/proposerduties_test.go index 42a9186b..3405a2ce 100644 --- a/multi/proposerduties_test.go +++ b/multi/proposerduties_test.go @@ -19,13 +19,75 @@ import ( consensusclient "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/api/v1" "github.com/attestantio/go-eth2-client/mock" "github.com/attestantio/go-eth2-client/multi" + "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/attestantio/go-eth2-client/testclients" "github.com/rs/zerolog" "github.com/stretchr/testify/require" ) +type proposerDutiesV1Only struct { + consensusclient.Service +} + +func TestProposerDutiesV2FallsBackFromUnsupportedClient(t *testing.T) { + ctx := context.Background() + + unsupportedClient, err := mock.New(ctx, mock.WithName("unsupported")) + require.NoError(t, err) + supportedClient, err := mock.New(ctx, mock.WithName("supported")) + require.NoError(t, err) + + multiClient, err := multi.New(ctx, + multi.WithLogLevel(zerolog.Disabled), + multi.WithClients([]consensusclient.Service{ + &proposerDutiesV1Only{Service: unsupportedClient}, + supportedClient, + }), + ) + require.NoError(t, err) + + response, err := multiClient.(consensusclient.ProposerDutiesV2Provider).ProposerDutiesV2(ctx, &api.ProposerDutiesOpts{}) + require.NoError(t, err) + require.NotNil(t, response) + require.Equal(t, "supported", multiClient.Address()) +} + +func TestProposerDutiesV2(t *testing.T) { + ctx := context.Background() + root := phase0.Root{0xaa} + duties := []*v1.ProposerDuty{{ValidatorIndex: 7, Slot: 65}} + + mockClient, err := mock.New(ctx) + require.NoError(t, err) + mockClient.ProposerDutiesV2Func = func(_ context.Context, _ *api.ProposerDutiesOpts) (*api.Response[[]*v1.ProposerDuty], error) { + return &api.Response[[]*v1.ProposerDuty]{ + Data: duties, + Metadata: map[string]any{ + "dependent_root": root, + "execution_optimistic": true, + }, + }, nil + } + + multiClient, err := multi.New(ctx, + multi.WithLogLevel(zerolog.Disabled), + multi.WithClients([]consensusclient.Service{mockClient}), + ) + require.NoError(t, err) + + provider, supported := multiClient.(consensusclient.ProposerDutiesV2Provider) + require.True(t, supported) + + response, err := provider.ProposerDutiesV2(ctx, &api.ProposerDutiesOpts{Epoch: 2}) + require.NoError(t, err) + require.Same(t, duties[0], response.Data[0]) + require.Equal(t, root, response.Metadata["dependent_root"]) + require.Equal(t, true, response.Metadata["execution_optimistic"]) +} + func TestProposerDuties(t *testing.T) { ctx := context.Background() diff --git a/multi/ptcduties.go b/multi/ptcduties.go new file mode 100644 index 00000000..58487539 --- /dev/null +++ b/multi/ptcduties.go @@ -0,0 +1,49 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package multi + +import ( + "context" + + consensusclient "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + apiv1 "github.com/attestantio/go-eth2-client/api/v1" +) + +// PTCDuties obtains payload timeliness committee duties. +func (s *Service) PTCDuties(ctx context.Context, + opts *api.PTCDutiesOpts, +) ( + *api.Response[[]*apiv1.PTCDuty], + error, +) { + res, err := s.doCall(ctx, func(ctx context.Context, client consensusclient.Service) (any, error) { + duties, err := client.(consensusclient.PTCDutiesProvider).PTCDuties(ctx, opts) + if err != nil { + return nil, err + } + + return duties, nil + }, nil) + if err != nil { + return nil, err + } + + response, isResponse := res.(*api.Response[[]*apiv1.PTCDuty]) + if !isResponse { + return nil, ErrIncorrectType + } + + return response, nil +} diff --git a/multi/service.go b/multi/service.go index 1450be80..04630c29 100644 --- a/multi/service.go +++ b/multi/service.go @@ -1,4 +1,4 @@ -// Copyright © 2021, 2024 Attestant Limited. +// Copyright © 2021 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -16,6 +16,7 @@ package multi import ( "context" "sync" + "time" consensusclient "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/http" @@ -33,8 +34,16 @@ type Service struct { clientsMu sync.RWMutex activeClients []consensusclient.Service inactiveClients []consensusclient.Service + + // eventsRetryInterval is how long Events waits between attempts to subscribe a client that + // was not synced, or failed to subscribe, when Events was called. Read it through + // retryInterval, which covers it being unset. + eventsRetryInterval time.Duration } +// defaultEventsRetryInterval is the default for Service.eventsRetryInterval. +const defaultEventsRetryInterval = 5 * time.Second + // New creates a new Ethereum 2 client with multiple endpoints. // The endpoints are periodically checked to see if they are active, // and requests will retry a different client if the currently active @@ -102,10 +111,11 @@ func New(ctx context.Context, params ...Parameter) (consensusclient.Service, err log.Trace().Int("active", len(activeClients)).Int("inactive", len(inactiveClients)).Msg("Initial providers") s := &Service{ - log: log, - name: parameters.name, - activeClients: activeClients, - inactiveClients: inactiveClients, + log: log, + name: parameters.name, + activeClients: activeClients, + inactiveClients: inactiveClients, + eventsRetryInterval: parameters.eventsRetryInterval, } // Set initial metrics. diff --git a/multi/service_test.go b/multi/service_test.go index 1963b6f8..eb959386 100644 --- a/multi/service_test.go +++ b/multi/service_test.go @@ -16,6 +16,7 @@ package multi_test import ( "context" "testing" + "time" client "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/mock" @@ -59,6 +60,24 @@ func TestService(t *testing.T) { }, err: "client is not active", }, + { + name: "EventsRetryIntervalZero", + params: []multi.Parameter{ + multi.WithLogLevel(zerolog.Disabled), + multi.WithClients([]client.Service{consensusclient1}), + multi.WithEventsRetryInterval(0), + }, + err: "problem with parameters: events retry interval must be positive", + }, + { + name: "EventsRetryIntervalNegative", + params: []multi.Parameter{ + multi.WithLogLevel(zerolog.Disabled), + multi.WithClients([]client.Service{consensusclient1}), + multi.WithEventsRetryInterval(-time.Second), + }, + err: "problem with parameters: events retry interval must be positive", + }, { name: "Good", params: []multi.Parameter{ diff --git a/multi/signedexecutionpayloadenvelope.go b/multi/signedexecutionpayloadenvelope.go new file mode 100644 index 00000000..fd4680c5 --- /dev/null +++ b/multi/signedexecutionpayloadenvelope.go @@ -0,0 +1,49 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package multi + +import ( + "context" + + consensusclient "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" +) + +// SignedExecutionPayloadEnvelope fetches a signed execution payload envelope given a block ID. +func (s *Service) SignedExecutionPayloadEnvelope(ctx context.Context, + opts *api.SignedExecutionPayloadEnvelopeOpts, +) ( + *api.Response[*spec.VersionedSignedExecutionPayloadEnvelope], + error, +) { + res, err := s.doCall(ctx, func(ctx context.Context, client consensusclient.Service) (any, error) { + envelope, err := client.(consensusclient.ExecutionPayloadProvider).SignedExecutionPayloadEnvelope(ctx, opts) + if err != nil { + return nil, err + } + + return envelope, nil + }, nil) + if err != nil { + return nil, err + } + + response, isResponse := res.(*api.Response[*spec.VersionedSignedExecutionPayloadEnvelope]) + if !isResponse { + return nil, ErrIncorrectType + } + + return response, nil +} diff --git a/multi/submitexecutionpayloadbid.go b/multi/submitexecutionpayloadbid.go new file mode 100644 index 00000000..651ec77e --- /dev/null +++ b/multi/submitexecutionpayloadbid.go @@ -0,0 +1,37 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package multi + +import ( + "context" + + consensusclient "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" +) + +// SubmitExecutionPayloadBid submits an execution payload bid. +func (s *Service) SubmitExecutionPayloadBid(ctx context.Context, + opts *api.SubmitExecutionPayloadBidOpts, +) error { + _, err := s.doCall(ctx, func(ctx context.Context, client consensusclient.Service) (any, error) { + err := client.(consensusclient.ExecutionPayloadBidSubmitter).SubmitExecutionPayloadBid(ctx, opts) + if err != nil { + return nil, err + } + + return true, nil + }, nil) + + return err +} diff --git a/multi/submitexecutionpayloadenvelope.go b/multi/submitexecutionpayloadenvelope.go new file mode 100644 index 00000000..f3f4f8df --- /dev/null +++ b/multi/submitexecutionpayloadenvelope.go @@ -0,0 +1,37 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package multi + +import ( + "context" + + consensusclient "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" +) + +// SubmitExecutionPayloadEnvelope submits a signed execution payload envelope. +func (s *Service) SubmitExecutionPayloadEnvelope(ctx context.Context, + opts *api.SubmitExecutionPayloadEnvelopeOpts, +) error { + _, err := s.doCall(ctx, func(ctx context.Context, client consensusclient.Service) (any, error) { + err := client.(consensusclient.ExecutionPayloadEnvelopeSubmitter).SubmitExecutionPayloadEnvelope(ctx, opts) + if err != nil { + return nil, err + } + + return true, nil + }, nil) + + return err +} diff --git a/multi/submitpayloadattestationmessages.go b/multi/submitpayloadattestationmessages.go new file mode 100644 index 00000000..46a268ce --- /dev/null +++ b/multi/submitpayloadattestationmessages.go @@ -0,0 +1,37 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package multi + +import ( + "context" + + consensusclient "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" +) + +// SubmitPayloadAttestationMessages submits payload attestation messages. +func (s *Service) SubmitPayloadAttestationMessages(ctx context.Context, + opts *api.SubmitPayloadAttestationMessagesOpts, +) error { + _, err := s.doCall(ctx, func(ctx context.Context, client consensusclient.Service) (any, error) { + err := client.(consensusclient.PayloadAttestationMessagesSubmitter).SubmitPayloadAttestationMessages(ctx, opts) + if err != nil { + return nil, err + } + + return true, nil + }, nil) + + return err +} diff --git a/multi/submitproposerpreferences.go b/multi/submitproposerpreferences.go new file mode 100644 index 00000000..e991167d --- /dev/null +++ b/multi/submitproposerpreferences.go @@ -0,0 +1,39 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package multi + +import ( + "context" + "errors" + + consensusclient "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" +) + +// SubmitProposerPreferences submits signed proposer preferences. +func (s *Service) SubmitProposerPreferences(ctx context.Context, opts *api.SubmitProposerPreferencesOpts) error { + _, err := s.doCall(ctx, func(ctx context.Context, client consensusclient.Service) (any, error) { + if err := client.(consensusclient.ProposerPreferencesSubmitter).SubmitProposerPreferences(ctx, opts); err != nil { + return nil, err + } + + return true, nil + }, func(_ context.Context, _ consensusclient.Service, err error) (bool, error) { + // Invalid input is invalid for every client, so neither fail over nor + // deactivate a healthy client because the caller supplied bad preferences. + return !errors.Is(err, consensusclient.ErrInvalidOptions), err + }) + + return err +} diff --git a/multi/submitproposerpreferences_test.go b/multi/submitproposerpreferences_test.go new file mode 100644 index 00000000..477fcd6a --- /dev/null +++ b/multi/submitproposerpreferences_test.go @@ -0,0 +1,95 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package multi_test + +import ( + "context" + "errors" + "testing" + + consensusclient "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/mock" + "github.com/attestantio/go-eth2-client/multi" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" +) + +// invalidOptionsPreferencesClient rejects every submission with ErrInvalidOptions, +// as the HTTP service does when the caller supplies too many or nil preferences. +type invalidOptionsPreferencesClient struct { + name string + calls int +} + +func (c *invalidOptionsPreferencesClient) Name() string { return c.name } +func (c *invalidOptionsPreferencesClient) Address() string { return c.name } +func (*invalidOptionsPreferencesClient) IsActive() bool { return true } +func (*invalidOptionsPreferencesClient) IsSynced() bool { return true } + +func (c *invalidOptionsPreferencesClient) SubmitProposerPreferences(_ context.Context, + _ *api.SubmitProposerPreferencesOpts, +) error { + c.calls++ + + return errors.Join(errors.New("too many proposer preferences"), consensusclient.ErrInvalidOptions) +} + +func TestSubmitProposerPreferences(t *testing.T) { + ctx := context.Background() + + client1, err := mock.New(ctx, mock.WithName("mock 1")) + require.NoError(t, err) + client2, err := mock.New(ctx, mock.WithName("mock 2")) + require.NoError(t, err) + + multiClient, err := multi.New(ctx, + multi.WithLogLevel(zerolog.Disabled), + multi.WithClients([]consensusclient.Service{client1, client2}), + ) + require.NoError(t, err) + + err = multiClient.(consensusclient.ProposerPreferencesSubmitter). + SubmitProposerPreferences(ctx, &api.SubmitProposerPreferencesOpts{ + Preferences: []*gloas.SignedProposerPreferences{{Message: &gloas.ProposerPreferences{}}}, + }) + require.NoError(t, err) +} + +// TestSubmitProposerPreferencesDoesNotFailOverOnInvalidOptions ensures that a +// caller-side error is not treated as a provider failure: invalid input is +// invalid everywhere, so neither client should be tried twice or deactivated. +func TestSubmitProposerPreferencesDoesNotFailOverOnInvalidOptions(t *testing.T) { + ctx := context.Background() + + client1 := &invalidOptionsPreferencesClient{name: "invalid 1"} + client2 := &invalidOptionsPreferencesClient{name: "invalid 2"} + + multiClient, err := multi.New(ctx, + multi.WithLogLevel(zerolog.Disabled), + multi.WithClients([]consensusclient.Service{client1, client2}), + ) + require.NoError(t, err) + + err = multiClient.(consensusclient.ProposerPreferencesSubmitter). + SubmitProposerPreferences(ctx, &api.SubmitProposerPreferencesOpts{ + Preferences: []*gloas.SignedProposerPreferences{{Message: &gloas.ProposerPreferences{}}}, + }) + require.ErrorIs(t, err, consensusclient.ErrInvalidOptions) + require.Equal(t, 1, client1.calls) + require.Equal(t, 0, client2.calls) + // The first client is still active, so it remains the multi client's address. + require.Equal(t, "invalid 1", multiClient.Address()) +} diff --git a/providerparity_test.go b/providerparity_test.go new file mode 100644 index 00000000..5797e32d --- /dev/null +++ b/providerparity_test.go @@ -0,0 +1,90 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package client_test + +import ( + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/http" + "github.com/attestantio/go-eth2-client/mock" + "github.com/attestantio/go-eth2-client/multi" + "github.com/attestantio/go-eth2-client/testclients" +) + +// Provider assertions keep implementations in parity. +var ( + _ client.ProposerDutiesV2Provider = (*http.Service)(nil) + _ client.ProposerDutiesV2Provider = (*multi.Service)(nil) + _ client.ProposerDutiesV2Provider = (*mock.Service)(nil) + _ client.ProposerDutiesV2Provider = (*testclients.Erroring)(nil) + _ client.ProposerDutiesV2Provider = (*testclients.Sleepy)(nil) + + _ client.PTCDutiesProvider = (*http.Service)(nil) + _ client.PTCDutiesProvider = (*multi.Service)(nil) + _ client.PTCDutiesProvider = (*mock.Service)(nil) + _ client.PTCDutiesProvider = (*testclients.Erroring)(nil) + _ client.PTCDutiesProvider = (*testclients.Sleepy)(nil) + + _ client.PayloadAttestationDataProvider = (*http.Service)(nil) + _ client.PayloadAttestationDataProvider = (*multi.Service)(nil) + _ client.PayloadAttestationDataProvider = (*mock.Service)(nil) + _ client.PayloadAttestationDataProvider = (*testclients.Erroring)(nil) + _ client.PayloadAttestationDataProvider = (*testclients.Sleepy)(nil) + + _ client.PayloadAttestationPoolProvider = (*http.Service)(nil) + _ client.PayloadAttestationPoolProvider = (*multi.Service)(nil) + _ client.PayloadAttestationPoolProvider = (*mock.Service)(nil) + _ client.PayloadAttestationPoolProvider = (*testclients.Erroring)(nil) + _ client.PayloadAttestationPoolProvider = (*testclients.Sleepy)(nil) + + _ client.PayloadAttestationMessagesSubmitter = (*http.Service)(nil) + _ client.PayloadAttestationMessagesSubmitter = (*multi.Service)(nil) + _ client.PayloadAttestationMessagesSubmitter = (*mock.Service)(nil) + _ client.PayloadAttestationMessagesSubmitter = (*testclients.Erroring)(nil) + _ client.PayloadAttestationMessagesSubmitter = (*testclients.Sleepy)(nil) + _ client.ProposerPreferencesSubmitter = (*http.Service)(nil) + _ client.ProposerPreferencesSubmitter = (*multi.Service)(nil) + _ client.ProposerPreferencesSubmitter = (*mock.Service)(nil) + _ client.ProposerPreferencesSubmitter = (*testclients.Erroring)(nil) + _ client.ProposerPreferencesSubmitter = (*testclients.Sleepy)(nil) + + _ client.EPBSProposalProvider = (*http.Service)(nil) + _ client.EPBSProposalProvider = (*multi.Service)(nil) + _ client.EPBSProposalProvider = (*mock.Service)(nil) + _ client.EPBSProposalProvider = (*testclients.Erroring)(nil) + _ client.EPBSProposalProvider = (*testclients.Sleepy)(nil) + + _ client.ExecutionPayloadEnvelopeProvider = (*http.Service)(nil) + _ client.ExecutionPayloadEnvelopeProvider = (*multi.Service)(nil) + _ client.ExecutionPayloadEnvelopeProvider = (*mock.Service)(nil) + _ client.ExecutionPayloadEnvelopeProvider = (*testclients.Erroring)(nil) + _ client.ExecutionPayloadEnvelopeProvider = (*testclients.Sleepy)(nil) + + _ client.ExecutionPayloadProvider = (*http.Service)(nil) + _ client.ExecutionPayloadProvider = (*multi.Service)(nil) + _ client.ExecutionPayloadProvider = (*mock.Service)(nil) + _ client.ExecutionPayloadProvider = (*testclients.Erroring)(nil) + _ client.ExecutionPayloadProvider = (*testclients.Sleepy)(nil) + + _ client.ExecutionPayloadEnvelopeSubmitter = (*http.Service)(nil) + _ client.ExecutionPayloadEnvelopeSubmitter = (*multi.Service)(nil) + _ client.ExecutionPayloadEnvelopeSubmitter = (*mock.Service)(nil) + _ client.ExecutionPayloadEnvelopeSubmitter = (*testclients.Erroring)(nil) + _ client.ExecutionPayloadEnvelopeSubmitter = (*testclients.Sleepy)(nil) + + _ client.ExecutionPayloadBidSubmitter = (*http.Service)(nil) + _ client.ExecutionPayloadBidSubmitter = (*multi.Service)(nil) + _ client.ExecutionPayloadBidSubmitter = (*mock.Service)(nil) + _ client.ExecutionPayloadBidSubmitter = (*testclients.Erroring)(nil) + _ client.ExecutionPayloadBidSubmitter = (*testclients.Sleepy)(nil) +) diff --git a/service.go b/service.go index 92bf8d33..1f93a231 100644 --- a/service.go +++ b/service.go @@ -1,4 +1,4 @@ -// Copyright © 2020 - 2023 Attestant Limited. +// Copyright © 2020 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -338,6 +338,57 @@ type ProposalProvider interface { ) } +// EPBSProposalProvider is the interface for providing ePBS proposals. +// +// It sits alongside ProposalProvider rather than replacing it: the endpoint +// behind it is gloas-onwards only, and the endpoint behind ProposalProvider is +// frozen at the last pre-gloas fork, so a client needs both to span the fork. +type EPBSProposalProvider interface { + // EPBSProposal fetches an ePBS proposal for signing. + EPBSProposal(ctx context.Context, + opts *api.EPBSProposalOpts, + ) ( + *api.Response[*api.VersionedEPBSProposal], + error, + ) +} + +// MultiForkProposalProvider is the interface for providing proposals on either +// side of the gloas fork. +// +// It composes the two proposal providers rather than adding an endpoint of its +// own. ProposalProvider is frozen at the last pre-gloas fork and +// EPBSProposalProvider is gloas-onwards only, so a caller that must produce +// blocks across the fork boundary needs both; this states that requirement as +// one type instead of leaving each caller to compose it. +// +// "MultiFork" is the number of forks spanned, not the number of nodes: it is +// unrelated to the multi package. +type MultiForkProposalProvider interface { + ProposalProvider + EPBSProposalProvider +} + +// ExecutionPayloadEnvelopeProvider is the interface for providing the execution +// payload envelope a node cached while producing a block. +// +// The name tracks the type returned, which is what distinguishes it from +// ExecutionPayloadProvider: that one reads a published, signed envelope back off +// the chain, this one collects an unsigned one from the node that built it so +// the caller can sign it. +type ExecutionPayloadEnvelopeProvider interface { + // ExecutionPayloadEnvelope obtains the cached execution payload envelope for + // the given slot and beacon block root. It returns + // ErrNoExecutionPayloadEnvelope if the node holds none, which is the normal + // answer for any slot other than the one it is proposing. + ExecutionPayloadEnvelope(ctx context.Context, + opts *api.ExecutionPayloadEnvelopeOpts, + ) ( + *api.Response[*spec.VersionedExecutionPayloadEnvelope], + error, + ) +} + // ProposalSlashingSubmitter is the interface for submitting proposal slashings. type ProposalSlashingSubmitter interface { SubmitProposalSlashing(ctx context.Context, slashing *phase0.ProposerSlashing) error @@ -565,6 +616,17 @@ type ProposerDutiesProvider interface { ) } +// ProposerDutiesV2Provider is the interface for providing proposer duties using the v2 API. +type ProposerDutiesV2Provider interface { + // ProposerDutiesV2 obtains proposer duties for the given options. + ProposerDutiesV2(ctx context.Context, + opts *api.ProposerDutiesOpts, + ) ( + *api.Response[[]*apiv1.ProposerDuty], + error, + ) +} + // SpecProvider is the interface for providing spec data. type SpecProvider interface { // Spec provides the spec information of the chain. @@ -685,3 +747,65 @@ type NodeClientProvider interface { // NodeClient provides the client for the node. NodeClient(ctx context.Context) (*api.Response[string], error) } + +// ExecutionPayloadProvider is the interface for providing execution payloads. +type ExecutionPayloadProvider interface { + // SignedExecutionPayloadEnvelope fetches a signed execution payload + // envelope given a block ID. Returns a versioned wrapper so callers can + // branch on Version regardless of which fork's envelope is populated. + SignedExecutionPayloadEnvelope(ctx context.Context, + opts *api.SignedExecutionPayloadEnvelopeOpts, + ) ( + *api.Response[*spec.VersionedSignedExecutionPayloadEnvelope], + error, + ) +} + +// ExecutionPayloadEnvelopeSubmitter is the interface for submitting execution +// payload envelopes. +type ExecutionPayloadEnvelopeSubmitter interface { + // SubmitExecutionPayloadEnvelope submits a signed execution payload + // envelope (with its blobs and KZG proofs) for broadcast. + SubmitExecutionPayloadEnvelope(ctx context.Context, + opts *api.SubmitExecutionPayloadEnvelopeOpts, + ) error +} + +// ExecutionPayloadBidSubmitter is the interface for submitting execution +// payload bids. +type ExecutionPayloadBidSubmitter interface { + // SubmitExecutionPayloadBid submits a signed execution payload bid for + // gossip broadcast. + SubmitExecutionPayloadBid(ctx context.Context, + opts *api.SubmitExecutionPayloadBidOpts, + ) error +} + +// PTCDutiesProvider is the interface for providing payload timeliness committee duties. +type PTCDutiesProvider interface { + PTCDuties(ctx context.Context, opts *api.PTCDutiesOpts) (*api.Response[[]*apiv1.PTCDuty], error) +} + +// PayloadAttestationDataProvider is the interface for providing payload attestation data. +type PayloadAttestationDataProvider interface { + PayloadAttestationData(ctx context.Context, + opts *api.PayloadAttestationDataOpts, + ) (*api.Response[*spec.VersionedPayloadAttestationData], error) +} + +// PayloadAttestationPoolProvider is the interface for providing payload attestation pools. +type PayloadAttestationPoolProvider interface { + PayloadAttestationPool(ctx context.Context, + opts *api.PayloadAttestationPoolOpts, + ) (*api.Response[[]*spec.VersionedPayloadAttestation], error) +} + +// PayloadAttestationMessagesSubmitter is the interface for submitting payload attestation messages. +type PayloadAttestationMessagesSubmitter interface { + SubmitPayloadAttestationMessages(ctx context.Context, opts *api.SubmitPayloadAttestationMessagesOpts) error +} + +// ProposerPreferencesSubmitter is the interface for submitting proposer preferences. +type ProposerPreferencesSubmitter interface { + SubmitProposerPreferences(ctx context.Context, opts *api.SubmitProposerPreferencesOpts) error +} diff --git a/spec/dataversion.go b/spec/dataversion.go index 608cd354..75040ef2 100644 --- a/spec/dataversion.go +++ b/spec/dataversion.go @@ -1,4 +1,4 @@ -// Copyright © 2021 - 2023 Attestant Limited. +// Copyright © 2021 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -38,6 +38,8 @@ const ( DataVersionElectra // DataVersionFulu is data applicable for the Fulu release of the beacon chain. DataVersionFulu + // DataVersionGloas is data applicable for the Gloas release of the beacon chain. + DataVersionGloas ) var dataVersionStrings = [...]string{ @@ -49,6 +51,7 @@ var dataVersionStrings = [...]string{ "deneb", "electra", "fulu", + "gloas", } var dataVersionMap = map[string]DataVersion{ @@ -59,11 +62,14 @@ var dataVersionMap = map[string]DataVersion{ `"deneb"`: DataVersionDeneb, `"electra"`: DataVersionElectra, `"fulu"`: DataVersionFulu, + `"gloas"`: DataVersionGloas, } // MarshalJSON implements json.Marshaler. +// The string comes from String() so the bounds check lives in one place; +// indexing dataVersionStrings here would panic on a version it has no entry for. func (d *DataVersion) MarshalJSON() ([]byte, error) { - return fmt.Appendf(nil, "%q", dataVersionStrings[*d]), nil + return fmt.Appendf(nil, "%q", d.String()), nil } // UnmarshalJSON implements json.Unmarshaler. @@ -82,7 +88,9 @@ func (d *DataVersion) UnmarshalJSON(input []byte) error { // String returns a string representation of the struct. func (d DataVersion) String() string { - if int(d) >= len(dataVersionStrings) { + // uint64, not int: int(d) wraps negative at 1<<63 and would slip past into + // an out-of-range index. + if uint64(d) >= uint64(len(dataVersionStrings)) { return "unknown" } diff --git a/spec/dataversion_test.go b/spec/dataversion_test.go new file mode 100644 index 00000000..a38b212c --- /dev/null +++ b/spec/dataversion_test.go @@ -0,0 +1,63 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spec_test + +import ( + "encoding/json" + "math" + "testing" + + "github.com/attestantio/go-eth2-client/spec" + "github.com/stretchr/testify/require" +) + +// TestDataVersionMarshalJSON pins the wire format of every version in the enum +// and requires an out-of-range version to marshal as "unknown" rather than +// panicking, since a panic here aborts the marshal of the whole enclosing +// response. +// +// The address is taken deliberately: MarshalJSON has a pointer receiver, so +// json.Marshal(test.version) would skip the marshaler and emit the number. +func TestDataVersionMarshalJSON(t *testing.T) { + tests := []struct { + name string + version spec.DataVersion + expected string + }{ + {name: "Unknown", version: spec.DataVersionUnknown, expected: `"unknown"`}, + {name: "Phase0", version: spec.DataVersionPhase0, expected: `"phase0"`}, + {name: "Altair", version: spec.DataVersionAltair, expected: `"altair"`}, + {name: "Bellatrix", version: spec.DataVersionBellatrix, expected: `"bellatrix"`}, + {name: "Capella", version: spec.DataVersionCapella, expected: `"capella"`}, + {name: "Deneb", version: spec.DataVersionDeneb, expected: `"deneb"`}, + {name: "Electra", version: spec.DataVersionElectra, expected: `"electra"`}, + {name: "Fulu", version: spec.DataVersionFulu, expected: `"fulu"`}, + {name: "Gloas", version: spec.DataVersionGloas, expected: `"gloas"`}, + // A fork added to the enum but not to the string table lands here. + {name: "PastLastVersion", version: spec.DataVersionGloas + 1, expected: `"unknown"`}, + // A bounds check written against int, not uint64, wraps negative here. + {name: "SignedRangeBoundary", version: 1 << 63, expected: `"unknown"`}, + {name: "MaxUint64", version: math.MaxUint64, expected: `"unknown"`}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.NotPanics(t, func() { + res, err := json.Marshal(&test.version) + require.NoError(t, err) + require.Equal(t, test.expected, string(res)) + }) + }) + } +} diff --git a/spec/gloas/aggregateandproof.go b/spec/gloas/aggregateandproof.go new file mode 100644 index 00000000..f2ea09e7 --- /dev/null +++ b/spec/gloas/aggregateandproof.go @@ -0,0 +1,137 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// AggregateAndProof is the Ethereum 2 attestation structure. +type AggregateAndProof struct { + AggregatorIndex phase0.ValidatorIndex + Aggregate *Attestation + SelectionProof phase0.BLSSignature `ssz-size:"96"` +} + +// aggregateAndProofJSON is the spec representation of the struct. +type aggregateAndProofJSON struct { + AggregatorIndex string `json:"aggregator_index"` + Aggregate *Attestation `json:"aggregate"` + SelectionProof string `json:"selection_proof"` +} + +// aggregateAndProofYAML is the spec representation of the struct. +type aggregateAndProofYAML struct { + AggregatorIndex uint64 `yaml:"aggregator_index"` + Aggregate *Attestation `yaml:"aggregate"` + SelectionProof string `yaml:"selection_proof"` +} + +// MarshalJSON implements json.Marshaler. +func (a *AggregateAndProof) MarshalJSON() ([]byte, error) { + return json.Marshal(&aggregateAndProofJSON{ + AggregatorIndex: fmt.Sprintf("%d", a.AggregatorIndex), + Aggregate: a.Aggregate, + SelectionProof: fmt.Sprintf("%#x", a.SelectionProof), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (a *AggregateAndProof) UnmarshalJSON(input []byte) error { + var aggregateAndProofJSON aggregateAndProofJSON + if err := json.Unmarshal(input, &aggregateAndProofJSON); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + return a.unpack(&aggregateAndProofJSON) +} + +// MarshalYAML implements yaml.Marshaler. +func (a *AggregateAndProof) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&aggregateAndProofYAML{ + AggregatorIndex: uint64(a.AggregatorIndex), + Aggregate: a.Aggregate, + SelectionProof: fmt.Sprintf("%#x", a.SelectionProof), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (a *AggregateAndProof) UnmarshalYAML(input []byte) error { + // We unmarshal to the JSON struct to save on duplicate code. + var aggregateAndProofJSON aggregateAndProofJSON + if err := yaml.Unmarshal(input, &aggregateAndProofJSON); err != nil { + return err + } + + return a.unpack(&aggregateAndProofJSON) +} + +// String returns a string version of the structure. +func (a *AggregateAndProof) String() string { + data, err := yaml.Marshal(a) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} + +func (a *AggregateAndProof) unpack(aggregateAndProofJSON *aggregateAndProofJSON) error { + if aggregateAndProofJSON.AggregatorIndex == "" { + return errors.New("aggregator index missing") + } + + aggregatorIndex, err := strconv.ParseUint(aggregateAndProofJSON.AggregatorIndex, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value for aggregator index") + } + + a.AggregatorIndex = phase0.ValidatorIndex(aggregatorIndex) + + if aggregateAndProofJSON.Aggregate == nil { + return errors.New("aggregate missing") + } + + a.Aggregate = aggregateAndProofJSON.Aggregate + if aggregateAndProofJSON.SelectionProof == "" { + return errors.New("selection proof missing") + } + + selectionProof, err := hex.DecodeString(strings.TrimPrefix(aggregateAndProofJSON.SelectionProof, "0x")) + if err != nil { + return errors.Wrap(err, "invalid value for selection proof") + } + + if len(selectionProof) != phase0.SignatureLength { + return errors.New("incorrect length for selection proof") + } + + copy(a.SelectionProof[:], selectionProof) + + return nil +} diff --git a/spec/gloas/aggregateandproof_ssz.go b/spec/gloas/aggregateandproof_ssz.go new file mode 100644 index 00000000..abe5e047 --- /dev/null +++ b/spec/gloas/aggregateandproof_ssz.go @@ -0,0 +1,133 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 5ab124fd14fc73ebf073ec3d655828afa31b5a378eeb87325eaa26a64b59a0f3 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[AggregateAndProof](`ssz-static:"false"`) + +// MarshalSSZ marshals the *AggregateAndProof to SSZ-encoded bytes. +func (t *AggregateAndProof) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *AggregateAndProof to SSZ-encoded bytes, appending to the provided buffer. +func (t *AggregateAndProof) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(AggregateAndProof) + } + dstlen := len(dst) + { // Static Field #0 'AggregatorIndex' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.AggregatorIndex)) + } + // Offset Field #1 'Aggregate' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #2 'SelectionProof' + dst = append(dst, t.SelectionProof[:96]...) + } + { // Dynamic Field #1 'Aggregate' + binary.LittleEndian.PutUint32(dst[dstlen+8:], uint32(len(dst)-dstlen)) + t := t.Aggregate + if t == nil { + t = new(Attestation) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Aggregate") + } + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *AggregateAndProof from SSZ-encoded bytes. +func (t *AggregateAndProof) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 108 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 108) + } + { // Field #0 'AggregatorIndex' (static) + buf := buf[0:8] + t.AggregatorIndex = phase0.ValidatorIndex(binary.LittleEndian.Uint64(buf)) + } + // Field #1 'Aggregate' (offset) + offset1 := int(binary.LittleEndian.Uint32(buf[8:12])) + if offset1 != 108 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset1, 108), "Aggregate:o") + } + { // Field #2 'SelectionProof' (static) + buf := buf[12:108] + copy(t.SelectionProof[:], buf) + } + { // Field #1 'Aggregate' (dynamic) + buf := buf[offset1:] + if t.Aggregate == nil { + t.Aggregate = new(Attestation) + } + if err = t.Aggregate.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Aggregate") + } + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *AggregateAndProof. +func (t *AggregateAndProof) SizeSSZ() (size int) { + if t == nil { + t = new(AggregateAndProof) + } + // Field #0 'AggregatorIndex' static (8 bytes) + // Field #1 'Aggregate' offset (4 bytes) + // Field #2 'SelectionProof' static (96 bytes) + size += 108 + { // Dynamic field #1 'Aggregate' + size += t.Aggregate.SizeSSZ() + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *AggregateAndProof. +func (t *AggregateAndProof) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *AggregateAndProof using the given hash walker. +func (t *AggregateAndProof) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(AggregateAndProof) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'AggregatorIndex' + hh.PutUint64(uint64(t.AggregatorIndex)) + } + { // Field #1 'Aggregate' + t := t.Aggregate + if t == nil { + t = new(Attestation) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Aggregate") + } + } + { // Field #2 'SelectionProof' + hh.PutBytes(t.SelectionProof[:96]) + } + hh.Merkleize(idx) + return nil +} diff --git a/spec/gloas/attestation.go b/spec/gloas/attestation.go new file mode 100644 index 00000000..0197f0fa --- /dev/null +++ b/spec/gloas/attestation.go @@ -0,0 +1,212 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + bitfield "github.com/OffchainLabs/go-bitfield" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// Attestation is the Ethereum 2 attestation structure. +type Attestation struct { + AggregationBits bitfield.Bitlist `ssz-index:"0" ssz-type:"progressive-bitlist"` + Data *phase0.AttestationData `ssz-index:"1"` + Signature phase0.BLSSignature `ssz-index:"2" ssz-size:"96"` + // CommitteeBits is Bitvector[MAX_COMMITTEES_PER_SLOT], so its width follows the + // preset: 8 bytes at mainnet, 1 on the minimal preset. As with + // PayloadAttestation.AggregationBits, its BitAt, SetBitAt and Len methods are + // hard-coded to the mainnet width and must not be used, and Shift panics on a + // value shorter than eight bytes; CommitteeIndex below stays correct only because + // it reads through the length-tolerant BitIndices. + // electra.Attestation.CommitteeBits carries the same tag and the same trap. + CommitteeBits bitfield.Bitvector64 `dynssz-size:"MAX_COMMITTEES_PER_SLOT/8" ssz-index:"3" ssz-size:"8"` +} + +// attestationJSON is a raw representation of the struct. +type attestationJSON struct { + AggregationBits string `json:"aggregation_bits"` + Data *phase0.AttestationData `json:"data"` + Signature string `json:"signature"` + CommitteeBits string `json:"committee_bits"` +} + +// attestationYAML is a raw representation of the struct. +type attestationYAML struct { + AggregationBits string `yaml:"aggregation_bits"` + Data *phase0.AttestationData `yaml:"data"` + Signature string `yaml:"signature"` + CommitteeBits string `yaml:"committee_bits"` +} + +// MarshalJSON implements json.Marshaler. +func (a *Attestation) MarshalJSON() ([]byte, error) { + return json.Marshal(&attestationJSON{ + AggregationBits: fmt.Sprintf("%#x", []byte(a.AggregationBits)), + Data: a.Data, + Signature: fmt.Sprintf("%#x", a.Signature), + CommitteeBits: fmt.Sprintf("%#x", a.CommitteeBits), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (a *Attestation) UnmarshalJSON(input []byte) error { + var attestationJSON attestationJSON + + err := json.Unmarshal(input, &attestationJSON) + if err != nil { + return errors.Wrap(err, "invalid JSON") + } + + return a.unpack(&attestationJSON) +} + +func (a *Attestation) unpack(attestationJSON *attestationJSON) error { + var err error + + if attestationJSON.AggregationBits == "" { + return errors.New("aggregation bits missing") + } + + if a.AggregationBits, err = hex.DecodeString(strings.TrimPrefix(attestationJSON.AggregationBits, "0x")); err != nil { + return errors.Wrap(err, "invalid value for aggregation bits") + } + + a.Data = attestationJSON.Data + if a.Data == nil { + return errors.New("data missing") + } + + if attestationJSON.Signature == "" { + return errors.New("signature missing") + } + + signature, err := hex.DecodeString(strings.TrimPrefix(attestationJSON.Signature, "0x")) + if err != nil { + return errors.Wrap(err, "invalid value for signature") + } + + if len(signature) != phase0.SignatureLength { + return errors.New("incorrect length for signature") + } + + copy(a.Signature[:], signature) + + if attestationJSON.CommitteeBits == "" { + return errors.New("committee bits missing") + } + + if a.CommitteeBits, err = hex.DecodeString(strings.TrimPrefix(attestationJSON.CommitteeBits, "0x")); err != nil { + return errors.Wrap(err, "invalid value for committee bits") + } + + return nil +} + +// MarshalYAML implements yaml.Marshaler. +func (a *Attestation) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&attestationYAML{ + AggregationBits: fmt.Sprintf("%#x", []byte(a.AggregationBits)), + Data: a.Data, + Signature: fmt.Sprintf("%#x", a.Signature), + CommitteeBits: fmt.Sprintf("%#x", []byte(a.CommitteeBits)), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (a *Attestation) UnmarshalYAML(input []byte) error { + // We unmarshal to the JSON struct to save on duplicate code. + var attestationJSON attestationJSON + if err := yaml.Unmarshal(input, &attestationJSON); err != nil { + return err + } + + return a.unpack(&attestationJSON) +} + +// String returns a string version of the structure. +func (a *Attestation) String() string { + data, err := yaml.Marshal(a) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} + +// CommitteeIndex returns the index if only one bit is set, otherwise error. +func (a *Attestation) CommitteeIndex() (phase0.CommitteeIndex, error) { + bits := a.CommitteeBits + if len(bits.BitIndices()) == 0 { + return 0, errors.New("no committee index found in committee bits") + } + + if len(bits.BitIndices()) > 1 { + return 0, errors.New("multiple committee indices found in committee bits") + } + + foundIndex := phase0.CommitteeIndex(bits.BitIndices()[0]) + + return foundIndex, nil +} + +// AggregateValidatorIndex returns the index if only one bit is set, otherwise error. +func (a *Attestation) AggregateValidatorIndex() (phase0.ValidatorIndex, error) { + bits := a.AggregationBits + if len(bits.BitIndices()) == 0 { + return 0, errors.New("no validator index found in aggregation bits") + } + + if len(bits.BitIndices()) > 1 { + return 0, errors.New("multiple validator indices found in aggregation bits") + } + + foundIndex := phase0.ValidatorIndex(bits.BitIndices()[0]) + + return foundIndex, nil +} + +// ToSingleAttestation returns a SingleAttestation representation of the Attestation. +func (a *Attestation) ToSingleAttestation(validatorIndex *phase0.ValidatorIndex) (*electra.SingleAttestation, error) { + if validatorIndex == nil { + return nil, errors.New("validator index is nil") + } + + committeeIndex, err := a.CommitteeIndex() + if err != nil { + return nil, err + } + + singleAttestation := electra.SingleAttestation{ + CommitteeIndex: committeeIndex, + AttesterIndex: *validatorIndex, + Data: a.Data, + Signature: a.Signature, + } + + return &singleAttestation, nil +} diff --git a/spec/gloas/attestation_ssz.go b/spec/gloas/attestation_ssz.go new file mode 100644 index 00000000..5833e1a5 --- /dev/null +++ b/spec/gloas/attestation_ssz.go @@ -0,0 +1,186 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: efa7422da7021e8ac7b4520b52e3e5bf651a342efab91cb7b683d5e6a7d2a7d8 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[Attestation](`ssz-static:"false"`) + +// MarshalSSZ marshals the *Attestation to SSZ-encoded bytes. +func (t *Attestation) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *Attestation to SSZ-encoded bytes, appending to the provided buffer. +func (t *Attestation) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(Attestation) + } + dstlen := len(dst) + // Offset Field #0 'AggregationBits' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #1 'Data' + t := t.Data + if t == nil { + t = new(phase0.AttestationData) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Data") + } + } + { // Static Field #2 'Signature' + dst = append(dst, t.Signature[:96]...) + } + { // Static Field #3 'CommitteeBits' + vlen := len(t.CommitteeBits) + if vlen > 8 { + return nil, sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 8), "CommitteeBits") + } + dst = append(dst, t.CommitteeBits[:vlen]...) + if vlen < 8 { + dst = sszutils.AppendZeroPadding(dst, (8-vlen)*1) + } + } + { // Dynamic Field #0 'AggregationBits' + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(len(dst)-dstlen)) + t := t.AggregationBits + vlen := len(t) + bval := []byte(t[:]) + if vlen == 0 { + bval = []byte{0x01} + } else if bval[vlen-1] == 0x00 { + return nil, sszutils.ErrorWithPath(sszutils.ErrBitlistNotTerminatedFn(), "AggregationBits") + } + dst = append(dst, bval...) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *Attestation from SSZ-encoded bytes. +func (t *Attestation) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 236 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 236) + } + // Field #0 'AggregationBits' (offset) + offset0 := int(binary.LittleEndian.Uint32(buf[0:4])) + if offset0 != 236 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 236), "AggregationBits:o") + } + { // Field #1 'Data' (static) + buf := buf[4:132] + if t.Data == nil { + t.Data = new(phase0.AttestationData) + } + if err = t.Data.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Data") + } + } + { // Field #2 'Signature' (static) + buf := buf[132:228] + copy(t.Signature[:], buf) + } + { // Field #3 'CommitteeBits' (static) + buf := buf[228:236] + t.CommitteeBits = sszutils.ExpandSlice(t.CommitteeBits, 8) + copy(t.CommitteeBits[:], buf) + } + { // Field #0 'AggregationBits' (dynamic) + buf := buf[offset0:] + val1 := t.AggregationBits + blen := len(buf) + if blen == 0 || buf[blen-1] == 0x00 { + return sszutils.ErrorWithPath(sszutils.ErrBitlistNotTerminatedFn(), "AggregationBits") + } + val1 = sszutils.ExpandSlice(val1, blen) + copy(val1[:], buf) + t.AggregationBits = val1 + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *Attestation. +func (t *Attestation) SizeSSZ() (size int) { + if t == nil { + t = new(Attestation) + } + // Field #0 'AggregationBits' offset (4 bytes) + // Field #1 'Data' static (128 bytes) + // Field #2 'Signature' static (96 bytes) + // Field #3 'CommitteeBits' static (8 bytes) + size += 236 + { // Dynamic field #0 'AggregationBits' + if len(t.AggregationBits) == 0 { + size += 1 + } else { + size += len(t.AggregationBits) + } + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *Attestation. +func (t *Attestation) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *Attestation using the given hash walker. +func (t *Attestation) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(Attestation) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'AggregationBits' + t := t.AggregationBits + if l := len(t); l > 0 && t[l-1] == 0x00 { + return sszutils.ErrorWithPath(sszutils.ErrBitlistNotTerminatedFn(), "AggregationBits") + } + idx := hh.StartTree(sszutils.TreeTypeNone) + bitlist, size := hasher.ParseProgressiveBitlistWithHasher(hh, t[:]) + hh.AppendBytes32(bitlist) + hh.MerkleizeProgressiveWithMixin(idx, size) + } + { // Field #1 'Data' + t := t.Data + if t == nil { + t = new(phase0.AttestationData) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Data") + } + } + { // Field #2 'Signature' + hh.PutBytes(t.Signature[:96]) + } + { // Field #3 'CommitteeBits' + vlen := len(t.CommitteeBits) + if vlen > 8 { + return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 8), "CommitteeBits") + } + val := t.CommitteeBits[:] + if vlen < 8 { + val = sszutils.AppendZeroPadding(val, (8-vlen)*1) + } + hh.PutBytes(val[:8]) + } + hh.MerkleizeProgressiveWithActiveFields(idx, []byte{0x0f}) + return nil +} diff --git a/spec/gloas/attestation_test.go b/spec/gloas/attestation_test.go new file mode 100644 index 00000000..043df877 --- /dev/null +++ b/spec/gloas/attestation_test.go @@ -0,0 +1,192 @@ +// Copyright © 2025 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas_test + +import ( + "testing" + + "github.com/OffchainLabs/go-bitfield" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAttestation_CommitteeIndex(t *testing.T) { + // Test cases + tests := []struct { + name string + expectedIndices []phase0.CommitteeIndex + errorMsg string + doNotSet bool + }{ + { + name: "Valid index 0", + expectedIndices: []phase0.CommitteeIndex{0}, + }, + { + name: "Valid index 4", + expectedIndices: []phase0.CommitteeIndex{4}, + }, + { + name: "Valid index 40", + expectedIndices: []phase0.CommitteeIndex{40}, + }, + { + name: "Invalid index 64", + expectedIndices: []phase0.CommitteeIndex{64}, + errorMsg: "no committee index found in committee bits", + }, + { + name: "Invalid no index set", + expectedIndices: []phase0.CommitteeIndex{4}, + errorMsg: "no committee index found in committee bits", + doNotSet: true, + }, + { + name: "Invalid multiple index set", + expectedIndices: []phase0.CommitteeIndex{4, 40}, + errorMsg: "multiple committee indices found in committee bits", + }, + } + // Run tests + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + committeeBits := bitfield.NewBitvector64() + if !tt.doNotSet { + for _, expectedIndex := range tt.expectedIndices { + committeeBits.SetBitAt(uint64(expectedIndex), true) + } + } + attestation := &gloas.Attestation{CommitteeBits: committeeBits} + + committeeIndex, err := attestation.CommitteeIndex() + if tt.errorMsg == "" { + require.NoError(t, err) + for _, expectedIndex := range tt.expectedIndices { + assert.Equal(t, expectedIndex, committeeIndex) + } + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + }) + } +} + +func TestAttestation_AggregateValidatorIndex(t *testing.T) { + // Test cases + tests := []struct { + name string + expectedIndices []phase0.ValidatorIndex + errorMsg string + doNotSet bool + }{ + { + name: "Valid index 0", + expectedIndices: []phase0.ValidatorIndex{0}, + }, + { + name: "Valid index 4", + expectedIndices: []phase0.ValidatorIndex{4}, + }, + { + name: "Valid index 140", + expectedIndices: []phase0.ValidatorIndex{140}, + }, + { + name: "Invalid index 160", + expectedIndices: []phase0.ValidatorIndex{160}, + errorMsg: "no validator index found in aggregation bits", + }, + { + name: "Invalid no index set", + expectedIndices: []phase0.ValidatorIndex{64}, + errorMsg: "no validator index found in aggregation bits", + doNotSet: true, + }, + { + name: "Invalid multiple index set", + expectedIndices: []phase0.ValidatorIndex{4, 40}, + errorMsg: "multiple validator indices found in aggregation bits", + }, + } + // Run tests + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + aggregateBits := bitfield.NewBitlist(160) + if !tt.doNotSet { + for _, expectedIndex := range tt.expectedIndices { + aggregateBits.SetBitAt(uint64(expectedIndex), true) + } + } + attestation := &gloas.Attestation{AggregationBits: aggregateBits} + + aggregateValidatorIndex, err := attestation.AggregateValidatorIndex() + if tt.errorMsg == "" { + require.NoError(t, err) + for _, expectedIndex := range tt.expectedIndices { + assert.Equal(t, expectedIndex, aggregateValidatorIndex) + } + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + }) + } +} + +func TestAttestation_SSZ(t *testing.T) { + aggregateSize := uint64(131072) + aggregateBits := bitfield.NewBitlist(aggregateSize) + committeeSize := uint64(64) + committeeBits := bitfield.NewBitvector64() + attestation := gloas.Attestation{ + AggregationBits: aggregateBits, + CommitteeBits: committeeBits, + Data: &phase0.AttestationData{ + Slot: 1, + Index: 1, + BeaconBlockRoot: phase0.Root{}, + Source: &phase0.Checkpoint{ + Epoch: 1, + Root: phase0.Root{}, + }, + Target: &phase0.Checkpoint{ + Epoch: 2, + Root: phase0.Root{}, + }, + }, + } + // Set a bit beyond the bit list. + aggregateBits.SetBitAt(aggregateSize, true) + // Set a bit in the last bit of the list. + aggregateBits.SetBitAt(aggregateSize-1, true) + + // Should only have the bit that was set on the last bit. + require.Equal(t, 1, len(aggregateBits.BitIndices())) + require.Equal(t, aggregateSize, aggregateBits.Len()) + + // Set a bit beyond the bit vector. + committeeBits.SetBitAt(committeeSize, true) + // Set a bit in the last bit of the vector. + committeeBits.SetBitAt(committeeSize-1, true) + + // Should only have the bit that was set on the last bit. + require.Equal(t, 1, len(committeeBits.BitIndices())) + + // Ensure we can actually serialise. + _, err := attestation.MarshalSSZ() + require.NoError(t, err) +} diff --git a/spec/gloas/attesterslashing.go b/spec/gloas/attesterslashing.go new file mode 100644 index 00000000..0d37cddf --- /dev/null +++ b/spec/gloas/attesterslashing.go @@ -0,0 +1,107 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// AttesterSlashing provides information about an attester slashing. +type AttesterSlashing struct { + Attestation1 *IndexedAttestation + Attestation2 *IndexedAttestation +} + +// attesterSlashingJSON is the spec representation of the struct. +type attesterSlashingJSON struct { + Attestation1 *IndexedAttestation `json:"attestation_1"` + Attestation2 *IndexedAttestation `json:"attestation_2"` +} + +// attesterSlashingYAML is the spec representation of the struct. +type attesterSlashingYAML struct { + Attestation1 *IndexedAttestation `yaml:"attestation_1"` + Attestation2 *IndexedAttestation `yaml:"attestation_2"` +} + +// MarshalJSON implements json.Marshaler. +func (a *AttesterSlashing) MarshalJSON() ([]byte, error) { + return json.Marshal(&attesterSlashingJSON{ + Attestation1: a.Attestation1, + Attestation2: a.Attestation2, + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (a *AttesterSlashing) UnmarshalJSON(input []byte) error { + var attesterSlashingJSON attesterSlashingJSON + if err := json.Unmarshal(input, &attesterSlashingJSON); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + return a.unpack(&attesterSlashingJSON) +} + +// MarshalYAML implements yaml.Marshaler. +func (a *AttesterSlashing) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&attesterSlashingYAML{ + Attestation1: a.Attestation1, + Attestation2: a.Attestation2, + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (a *AttesterSlashing) UnmarshalYAML(input []byte) error { + // We unmarshal to the JSON struct to save on duplicate code. + var attesterSlashingJSON attesterSlashingJSON + if err := yaml.Unmarshal(input, &attesterSlashingJSON); err != nil { + return err + } + + return a.unpack(&attesterSlashingJSON) +} + +func (a *AttesterSlashing) String() string { + data, err := yaml.Marshal(a) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} + +func (a *AttesterSlashing) unpack(attesterSlashingJSON *attesterSlashingJSON) error { + if attesterSlashingJSON.Attestation1 == nil { + return errors.New("attestation 1 missing") + } + + a.Attestation1 = attesterSlashingJSON.Attestation1 + if attesterSlashingJSON.Attestation2 == nil { + return errors.New("attestation 2 missing") + } + + a.Attestation2 = attesterSlashingJSON.Attestation2 + + return nil +} diff --git a/spec/gloas/attesterslashing_ssz.go b/spec/gloas/attesterslashing_ssz.go new file mode 100644 index 00000000..875f4209 --- /dev/null +++ b/spec/gloas/attesterslashing_ssz.go @@ -0,0 +1,148 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 834d4b32b565ed8ddf561b91ac0c479c93bd2aad55733cf5dd6787d254e95231 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[AttesterSlashing](`ssz-static:"false"`) + +// MarshalSSZ marshals the *AttesterSlashing to SSZ-encoded bytes. +func (t *AttesterSlashing) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *AttesterSlashing to SSZ-encoded bytes, appending to the provided buffer. +func (t *AttesterSlashing) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(AttesterSlashing) + } + dstlen := len(dst) + // Offset Field #0 'Attestation1' + // Offset Field #1 'Attestation2' + dst = append(dst, 0, 0, 0, 0, 0, 0, 0, 0) + { // Dynamic Field #0 'Attestation1' + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(len(dst)-dstlen)) + t := t.Attestation1 + if t == nil { + t = new(IndexedAttestation) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Attestation1") + } + } + { // Dynamic Field #1 'Attestation2' + binary.LittleEndian.PutUint32(dst[dstlen+4:], uint32(len(dst)-dstlen)) + t := t.Attestation2 + if t == nil { + t = new(IndexedAttestation) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Attestation2") + } + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *AttesterSlashing from SSZ-encoded bytes. +func (t *AttesterSlashing) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 8 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 8) + } + // Field #0 'Attestation1' (offset) + offset0 := int(binary.LittleEndian.Uint32(buf[0:4])) + if offset0 != 8 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 8), "Attestation1:o") + } + // Field #1 'Attestation2' (offset) + offset1 := int(binary.LittleEndian.Uint32(buf[4:8])) + if offset1 < offset0 || offset1 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset1, offset0, buflen), "Attestation2:o") + } + { // Field #0 'Attestation1' (dynamic) + buf := buf[offset0:offset1] + if t.Attestation1 == nil { + t.Attestation1 = new(IndexedAttestation) + } + if err = t.Attestation1.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Attestation1") + } + } + { // Field #1 'Attestation2' (dynamic) + buf := buf[offset1:] + if t.Attestation2 == nil { + t.Attestation2 = new(IndexedAttestation) + } + if err = t.Attestation2.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Attestation2") + } + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *AttesterSlashing. +func (t *AttesterSlashing) SizeSSZ() (size int) { + if t == nil { + t = new(AttesterSlashing) + } + // Field #0 'Attestation1' offset (4 bytes) + // Field #1 'Attestation2' offset (4 bytes) + size += 8 + { // Dynamic field #0 'Attestation1' + size += t.Attestation1.SizeSSZ() + } + { // Dynamic field #1 'Attestation2' + size += t.Attestation2.SizeSSZ() + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *AttesterSlashing. +func (t *AttesterSlashing) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *AttesterSlashing using the given hash walker. +func (t *AttesterSlashing) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(AttesterSlashing) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Attestation1' + t := t.Attestation1 + if t == nil { + t = new(IndexedAttestation) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Attestation1") + } + } + { // Field #1 'Attestation2' + t := t.Attestation2 + if t == nil { + t = new(IndexedAttestation) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Attestation2") + } + } + hh.Merkleize(idx) + return nil +} diff --git a/spec/gloas/beaconblock.go b/spec/gloas/beaconblock.go new file mode 100644 index 00000000..4e433066 --- /dev/null +++ b/spec/gloas/beaconblock.go @@ -0,0 +1,40 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// BeaconBlock represents a beacon block for EIP-7732. +type BeaconBlock struct { + Slot phase0.Slot + ProposerIndex phase0.ValidatorIndex + ParentRoot phase0.Root `ssz-size:"32"` + StateRoot phase0.Root `ssz-size:"32"` + Body *BeaconBlockBody +} + +// String returns a string version of the structure. +func (b *BeaconBlock) String() string { + data, err := yaml.Marshal(b) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/beaconblock_json.go b/spec/gloas/beaconblock_json.go new file mode 100644 index 00000000..958dcdef --- /dev/null +++ b/spec/gloas/beaconblock_json.go @@ -0,0 +1,69 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// beaconBlockJSON is the spec representation of the struct. +type beaconBlockJSON struct { + Slot string `json:"slot"` + ProposerIndex string `json:"proposer_index"` + ParentRoot phase0.Root `json:"parent_root"` + StateRoot phase0.Root `json:"state_root"` + Body *BeaconBlockBody `json:"body"` +} + +// MarshalJSON implements json.Marshaler. +func (b *BeaconBlock) MarshalJSON() ([]byte, error) { + return json.Marshal(&beaconBlockJSON{ + Slot: fmt.Sprintf("%d", b.Slot), + ProposerIndex: fmt.Sprintf("%d", b.ProposerIndex), + ParentRoot: b.ParentRoot, + StateRoot: b.StateRoot, + Body: b.Body, + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (b *BeaconBlock) UnmarshalJSON(input []byte) error { + var data beaconBlockJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + slot, err := strconv.ParseUint(data.Slot, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid slot") + } + b.Slot = phase0.Slot(slot) + + proposerIndex, err := strconv.ParseUint(data.ProposerIndex, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid proposer index") + } + b.ProposerIndex = phase0.ValidatorIndex(proposerIndex) + + b.ParentRoot = data.ParentRoot + b.StateRoot = data.StateRoot + b.Body = data.Body + + return nil +} diff --git a/spec/gloas/beaconblock_ssz.go b/spec/gloas/beaconblock_ssz.go new file mode 100644 index 00000000..eee024f2 --- /dev/null +++ b/spec/gloas/beaconblock_ssz.go @@ -0,0 +1,155 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 29be7500521c82f8e1a4b25df318e7b03bc07606db0ec1084404668206d8495f +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[BeaconBlock](`ssz-static:"false"`) + +// MarshalSSZ marshals the *BeaconBlock to SSZ-encoded bytes. +func (t *BeaconBlock) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *BeaconBlock to SSZ-encoded bytes, appending to the provided buffer. +func (t *BeaconBlock) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(BeaconBlock) + } + dstlen := len(dst) + { // Static Field #0 'Slot' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.Slot)) + } + { // Static Field #1 'ProposerIndex' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.ProposerIndex)) + } + { // Static Field #2 'ParentRoot' + dst = append(dst, t.ParentRoot[:32]...) + } + { // Static Field #3 'StateRoot' + dst = append(dst, t.StateRoot[:32]...) + } + // Offset Field #4 'Body' + dst = append(dst, 0, 0, 0, 0) + { // Dynamic Field #4 'Body' + binary.LittleEndian.PutUint32(dst[dstlen+80:], uint32(len(dst)-dstlen)) + t := t.Body + if t == nil { + t = new(BeaconBlockBody) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Body") + } + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *BeaconBlock from SSZ-encoded bytes. +func (t *BeaconBlock) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 84 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 84) + } + { // Field #0 'Slot' (static) + buf := buf[0:8] + t.Slot = phase0.Slot(binary.LittleEndian.Uint64(buf)) + } + { // Field #1 'ProposerIndex' (static) + buf := buf[8:16] + t.ProposerIndex = phase0.ValidatorIndex(binary.LittleEndian.Uint64(buf)) + } + { // Field #2 'ParentRoot' (static) + buf := buf[16:48] + copy(t.ParentRoot[:], buf) + } + { // Field #3 'StateRoot' (static) + buf := buf[48:80] + copy(t.StateRoot[:], buf) + } + // Field #4 'Body' (offset) + offset4 := int(binary.LittleEndian.Uint32(buf[80:84])) + if offset4 != 84 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset4, 84), "Body:o") + } + { // Field #4 'Body' (dynamic) + buf := buf[offset4:] + if t.Body == nil { + t.Body = new(BeaconBlockBody) + } + if err = t.Body.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Body") + } + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *BeaconBlock. +func (t *BeaconBlock) SizeSSZ() (size int) { + if t == nil { + t = new(BeaconBlock) + } + // Field #0 'Slot' static (8 bytes) + // Field #1 'ProposerIndex' static (8 bytes) + // Field #2 'ParentRoot' static (32 bytes) + // Field #3 'StateRoot' static (32 bytes) + // Field #4 'Body' offset (4 bytes) + size += 84 + { // Dynamic field #4 'Body' + size += t.Body.SizeSSZ() + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *BeaconBlock. +func (t *BeaconBlock) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *BeaconBlock using the given hash walker. +func (t *BeaconBlock) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(BeaconBlock) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Slot' + hh.PutUint64(uint64(t.Slot)) + } + { // Field #1 'ProposerIndex' + hh.PutUint64(uint64(t.ProposerIndex)) + } + { // Field #2 'ParentRoot' + hh.PutBytes(t.ParentRoot[:32]) + } + { // Field #3 'StateRoot' + hh.PutBytes(t.StateRoot[:32]) + } + { // Field #4 'Body' + t := t.Body + if t == nil { + t = new(BeaconBlockBody) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Body") + } + } + hh.Merkleize(idx) + return nil +} diff --git a/spec/gloas/beaconblock_yaml.go b/spec/gloas/beaconblock_yaml.go new file mode 100644 index 00000000..96e55692 --- /dev/null +++ b/spec/gloas/beaconblock_yaml.go @@ -0,0 +1,53 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (b *BeaconBlock) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&beaconBlockJSON{ + Slot: fmt.Sprintf("%d", b.Slot), + ProposerIndex: fmt.Sprintf("%d", b.ProposerIndex), + ParentRoot: b.ParentRoot, + StateRoot: b.StateRoot, + Body: b.Body, + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (b *BeaconBlock) UnmarshalYAML(input []byte) error { + var unmarshaled beaconBlockJSON + if err := yaml.Unmarshal(input, &unmarshaled); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(unmarshaled) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return b.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/beaconblockbody.go b/spec/gloas/beaconblockbody.go new file mode 100644 index 00000000..86e97647 --- /dev/null +++ b/spec/gloas/beaconblockbody.go @@ -0,0 +1,50 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// BeaconBlockBody represents the body of a beacon block for EIP-7732. +type BeaconBlockBody struct { + RANDAOReveal phase0.BLSSignature `ssz-index:"0" ssz-size:"96"` + ETH1Data *phase0.ETH1Data `ssz-index:"1"` + Graffiti [32]byte `ssz-index:"2" ssz-size:"32"` + ProposerSlashings []*phase0.ProposerSlashing `ssz-index:"3" ssz-type:"progressive-list"` + AttesterSlashings []*AttesterSlashing `ssz-index:"4" ssz-type:"progressive-list"` + Attestations []*Attestation `ssz-index:"5" ssz-type:"progressive-list"` + Deposits []*phase0.Deposit `ssz-index:"6" ssz-type:"progressive-list"` + VoluntaryExits []*phase0.SignedVoluntaryExit `ssz-index:"7" ssz-type:"progressive-list"` + SyncAggregate *altair.SyncAggregate `ssz-index:"8"` + BLSToExecutionChanges []*capella.SignedBLSToExecutionChange `ssz-index:"9" ssz-type:"progressive-list"` + SignedExecutionPayloadBid *SignedExecutionPayloadBid `ssz-index:"10"` + PayloadAttestations []*PayloadAttestation `ssz-index:"11" ssz-type:"progressive-list"` + ParentExecutionRequests *ExecutionRequests `ssz-index:"12"` +} + +// String returns a string version of the structure. +func (b *BeaconBlockBody) String() string { + data, err := yaml.Marshal(b) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/beaconblockbody_json.go b/spec/gloas/beaconblockbody_json.go new file mode 100644 index 00000000..be167e7d --- /dev/null +++ b/spec/gloas/beaconblockbody_json.go @@ -0,0 +1,115 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// beaconBlockBodyJSON is the spec representation of the struct. +type beaconBlockBodyJSON struct { + RANDAOReveal string `json:"randao_reveal"` + ETH1Data *phase0.ETH1Data `json:"eth1_data"` + Graffiti string `json:"graffiti"` + ProposerSlashings []*phase0.ProposerSlashing `json:"proposer_slashings"` + AttesterSlashings []*AttesterSlashing `json:"attester_slashings"` + Attestations []*Attestation `json:"attestations"` + Deposits []*phase0.Deposit `json:"deposits"` + VoluntaryExits []*phase0.SignedVoluntaryExit `json:"voluntary_exits"` + SyncAggregate *altair.SyncAggregate `json:"sync_aggregate"` + BLSToExecutionChanges []*capella.SignedBLSToExecutionChange `json:"bls_to_execution_changes"` + SignedExecutionPayloadBid *SignedExecutionPayloadBid `json:"signed_execution_payload_bid"` + PayloadAttestations []*PayloadAttestation `json:"payload_attestations"` + ParentExecutionRequests *ExecutionRequests `json:"parent_execution_requests"` +} + +// MarshalJSON implements json.Marshaler. +func (b *BeaconBlockBody) MarshalJSON() ([]byte, error) { + return json.Marshal(&beaconBlockBodyJSON{ + RANDAOReveal: fmt.Sprintf("%#x", b.RANDAOReveal), + ETH1Data: b.ETH1Data, + Graffiti: fmt.Sprintf("%#x", b.Graffiti), + ProposerSlashings: b.ProposerSlashings, + AttesterSlashings: b.AttesterSlashings, + Attestations: b.Attestations, + Deposits: b.Deposits, + VoluntaryExits: b.VoluntaryExits, + SyncAggregate: b.SyncAggregate, + BLSToExecutionChanges: b.BLSToExecutionChanges, + SignedExecutionPayloadBid: b.SignedExecutionPayloadBid, + PayloadAttestations: b.PayloadAttestations, + ParentExecutionRequests: b.ParentExecutionRequests, + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (b *BeaconBlockBody) UnmarshalJSON(input []byte) error { + var data beaconBlockBodyJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + // RANDAO reveal + if data.RANDAOReveal == "" { + return errors.New("randao reveal missing") + } + randaoReveal, err := hex.DecodeString(strings.TrimPrefix(data.RANDAOReveal, "0x")) + if err != nil { + return errors.Wrap(err, "invalid randao reveal") + } + if len(randaoReveal) != phase0.SignatureLength { + return errors.New("incorrect length for randao reveal") + } + copy(b.RANDAOReveal[:], randaoReveal) + + b.ETH1Data = data.ETH1Data + + // Graffiti + if data.Graffiti == "" { + return errors.New("graffiti missing") + } + graffiti, err := hex.DecodeString(strings.TrimPrefix(data.Graffiti, "0x")) + if err != nil { + return errors.Wrap(err, "invalid graffiti") + } + if len(graffiti) != phase0.GraffitiLength { + return errors.New("incorrect length for graffiti") + } + copy(b.Graffiti[:], graffiti) + + b.ProposerSlashings = data.ProposerSlashings + b.AttesterSlashings = data.AttesterSlashings + b.Attestations = data.Attestations + b.Deposits = data.Deposits + b.VoluntaryExits = data.VoluntaryExits + b.SyncAggregate = data.SyncAggregate + b.BLSToExecutionChanges = data.BLSToExecutionChanges + b.SignedExecutionPayloadBid = data.SignedExecutionPayloadBid + b.PayloadAttestations = data.PayloadAttestations + + if data.ParentExecutionRequests == nil { + return errors.New("parent execution requests missing") + } + b.ParentExecutionRequests = data.ParentExecutionRequests + + return nil +} diff --git a/spec/gloas/beaconblockbody_ssz.go b/spec/gloas/beaconblockbody_ssz.go new file mode 100644 index 00000000..fc50d85c --- /dev/null +++ b/spec/gloas/beaconblockbody_ssz.go @@ -0,0 +1,712 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: ff294cfb40110eeb1710be67170dfa5bc91aa532d7cc6ea215b5545f7e14fcac +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[BeaconBlockBody](`ssz-static:"false"`) + +// MarshalSSZ marshals the *BeaconBlockBody to SSZ-encoded bytes. +func (t *BeaconBlockBody) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *BeaconBlockBody to SSZ-encoded bytes, appending to the provided buffer. +func (t *BeaconBlockBody) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + zeroBytes := sszutils.ZeroBytes() + if t == nil { + t = new(BeaconBlockBody) + } + dstlen := len(dst) + { // Static Field #0 'RANDAOReveal' + dst = append(dst, t.RANDAOReveal[:96]...) + } + { // Static Field #1 'ETH1Data' + t := t.ETH1Data + if t == nil { + t = new(phase0.ETH1Data) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "ETH1Data") + } + } + { // Static Field #2 'Graffiti' + dst = append(dst, t.Graffiti[:32]...) + } + // Offset Field #3 'ProposerSlashings' + // Offset Field #4 'AttesterSlashings' + // Offset Field #5 'Attestations' + // Offset Field #6 'Deposits' + // Offset Field #7 'VoluntaryExits' + dst = append(dst, zeroBytes[:20]...) + { // Static Field #8 'SyncAggregate' + t := t.SyncAggregate + if t == nil { + t = new(altair.SyncAggregate) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "SyncAggregate") + } + } + // Offset Field #9 'BLSToExecutionChanges' + // Offset Field #10 'SignedExecutionPayloadBid' + // Offset Field #11 'PayloadAttestations' + // Offset Field #12 'ParentExecutionRequests' + dst = append(dst, zeroBytes[:16]...) + { // Dynamic Field #3 'ProposerSlashings' + binary.LittleEndian.PutUint32(dst[dstlen+200:], uint32(len(dst)-dstlen)) + t := t.ProposerSlashings + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(phase0.ProposerSlashing) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "ProposerSlashings[%d]", idx1) + } + } + } + { // Dynamic Field #4 'AttesterSlashings' + binary.LittleEndian.PutUint32(dst[dstlen+204:], uint32(len(dst)-dstlen)) + t := t.AttesterSlashings + dstlen := len(dst) + vlen := len(t) + dst = sszutils.AppendZeroPadding(dst, vlen*4) + for idx1 := range vlen { + binary.LittleEndian.PutUint32(dst[dstlen+(idx1*4):], uint32(len(dst)-dstlen)) + t := t[idx1] + if t == nil { + t = new(AttesterSlashing) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "AttesterSlashings[%d]", idx1) + } + } + } + { // Dynamic Field #5 'Attestations' + binary.LittleEndian.PutUint32(dst[dstlen+208:], uint32(len(dst)-dstlen)) + t := t.Attestations + dstlen := len(dst) + vlen := len(t) + dst = sszutils.AppendZeroPadding(dst, vlen*4) + for idx1 := range vlen { + binary.LittleEndian.PutUint32(dst[dstlen+(idx1*4):], uint32(len(dst)-dstlen)) + t := t[idx1] + if t == nil { + t = new(Attestation) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "Attestations[%d]", idx1) + } + } + } + { // Dynamic Field #6 'Deposits' + binary.LittleEndian.PutUint32(dst[dstlen+212:], uint32(len(dst)-dstlen)) + t := t.Deposits + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(phase0.Deposit) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "Deposits[%d]", idx1) + } + } + } + { // Dynamic Field #7 'VoluntaryExits' + binary.LittleEndian.PutUint32(dst[dstlen+216:], uint32(len(dst)-dstlen)) + t := t.VoluntaryExits + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(phase0.SignedVoluntaryExit) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "VoluntaryExits[%d]", idx1) + } + } + } + { // Dynamic Field #9 'BLSToExecutionChanges' + binary.LittleEndian.PutUint32(dst[dstlen+380:], uint32(len(dst)-dstlen)) + t := t.BLSToExecutionChanges + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(capella.SignedBLSToExecutionChange) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "BLSToExecutionChanges[%d]", idx1) + } + } + } + { // Dynamic Field #10 'SignedExecutionPayloadBid' + binary.LittleEndian.PutUint32(dst[dstlen+384:], uint32(len(dst)-dstlen)) + t := t.SignedExecutionPayloadBid + if t == nil { + t = new(SignedExecutionPayloadBid) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "SignedExecutionPayloadBid") + } + } + { // Dynamic Field #11 'PayloadAttestations' + binary.LittleEndian.PutUint32(dst[dstlen+388:], uint32(len(dst)-dstlen)) + t := t.PayloadAttestations + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(PayloadAttestation) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "PayloadAttestations[%d]", idx1) + } + } + } + { // Dynamic Field #12 'ParentExecutionRequests' + binary.LittleEndian.PutUint32(dst[dstlen+392:], uint32(len(dst)-dstlen)) + t := t.ParentExecutionRequests + if t == nil { + t = new(ExecutionRequests) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "ParentExecutionRequests") + } + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *BeaconBlockBody from SSZ-encoded bytes. +func (t *BeaconBlockBody) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 396 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 396) + } + { // Field #0 'RANDAOReveal' (static) + buf := buf[0:96] + copy(t.RANDAOReveal[:], buf) + } + { // Field #1 'ETH1Data' (static) + buf := buf[96:168] + if t.ETH1Data == nil { + t.ETH1Data = new(phase0.ETH1Data) + } + if err = t.ETH1Data.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "ETH1Data") + } + } + { // Field #2 'Graffiti' (static) + buf := buf[168:200] + copy(t.Graffiti[:], buf) + } + // Field #3 'ProposerSlashings' (offset) + offset3 := int(binary.LittleEndian.Uint32(buf[200:204])) + if offset3 != 396 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset3, 396), "ProposerSlashings:o") + } + // Field #4 'AttesterSlashings' (offset) + offset4 := int(binary.LittleEndian.Uint32(buf[204:208])) + if offset4 < offset3 || offset4 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset4, offset3, buflen), "AttesterSlashings:o") + } + // Field #5 'Attestations' (offset) + offset5 := int(binary.LittleEndian.Uint32(buf[208:212])) + if offset5 < offset4 || offset5 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset5, offset4, buflen), "Attestations:o") + } + // Field #6 'Deposits' (offset) + offset6 := int(binary.LittleEndian.Uint32(buf[212:216])) + if offset6 < offset5 || offset6 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset6, offset5, buflen), "Deposits:o") + } + // Field #7 'VoluntaryExits' (offset) + offset7 := int(binary.LittleEndian.Uint32(buf[216:220])) + if offset7 < offset6 || offset7 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset7, offset6, buflen), "VoluntaryExits:o") + } + { // Field #8 'SyncAggregate' (static) + buf := buf[220:380] + if t.SyncAggregate == nil { + t.SyncAggregate = new(altair.SyncAggregate) + } + if err = t.SyncAggregate.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "SyncAggregate") + } + } + // Field #9 'BLSToExecutionChanges' (offset) + offset9 := int(binary.LittleEndian.Uint32(buf[380:384])) + if offset9 < offset7 || offset9 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset9, offset7, buflen), "BLSToExecutionChanges:o") + } + // Field #10 'SignedExecutionPayloadBid' (offset) + offset10 := int(binary.LittleEndian.Uint32(buf[384:388])) + if offset10 < offset9 || offset10 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset10, offset9, buflen), "SignedExecutionPayloadBid:o") + } + // Field #11 'PayloadAttestations' (offset) + offset11 := int(binary.LittleEndian.Uint32(buf[388:392])) + if offset11 < offset10 || offset11 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset11, offset10, buflen), "PayloadAttestations:o") + } + // Field #12 'ParentExecutionRequests' (offset) + offset12 := int(binary.LittleEndian.Uint32(buf[392:396])) + if offset12 < offset11 || offset12 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset12, offset11, buflen), "ParentExecutionRequests:o") + } + { // Field #3 'ProposerSlashings' (dynamic) + buf := buf[offset3:offset4] + val1 := t.ProposerSlashings + itemCount := len(buf) / 416 + if len(buf)%416 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 416), "ProposerSlashings") + } + val1 = sszutils.ExpandSlice(val1, itemCount) + for idx1 := range itemCount { + if val1[idx1] == nil { + val1[idx1] = new(phase0.ProposerSlashing) + } + buf := buf[416*idx1 : 416*(idx1+1)] + if err = val1[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "ProposerSlashings[%d]", idx1) + } + } + t.ProposerSlashings = val1 + } + { // Field #4 'AttesterSlashings' (dynamic) + buf := buf[offset4:offset5] + val2 := t.AttesterSlashings + startOffset := int(0) + if len(buf) != 0 { + if len(buf) < 4 { + return sszutils.ErrorWithPath(sszutils.ErrListOffsetsEOFFn(len(buf), 4), "AttesterSlashings") + } + startOffset = int(binary.LittleEndian.Uint32(buf[0:4])) + } + itemCount := startOffset / 4 + if startOffset%4 != 0 || len(buf) < startOffset || (len(buf) != 0 && startOffset == 0) { + return sszutils.ErrorWithPath(sszutils.ErrInvalidListStartOffsetFn(startOffset, len(buf)), "AttesterSlashings") + } + val2 = sszutils.ExpandSlice(val2, itemCount) + for idx1 := range itemCount { + var endOffset int + if idx1 < itemCount-1 { + endOffset = int(binary.LittleEndian.Uint32(buf[(idx1+1)*4 : (idx1+2)*4])) + } else { + endOffset = len(buf) + } + if endOffset < startOffset || endOffset > len(buf) { + return sszutils.ErrorWithPathf(sszutils.ErrElementOffsetOutOfRangeFn(endOffset, startOffset, len(buf)), "AttesterSlashings[%d]", idx1) + } + buf := buf[startOffset:endOffset] + startOffset = endOffset + val3 := val2[idx1] + if val3 == nil { + val3 = new(AttesterSlashing) + } + if err = val3.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "AttesterSlashings[%d]", idx1) + } + val2[idx1] = val3 + } + t.AttesterSlashings = val2 + } + { // Field #5 'Attestations' (dynamic) + buf := buf[offset5:offset6] + val4 := t.Attestations + startOffset := int(0) + if len(buf) != 0 { + if len(buf) < 4 { + return sszutils.ErrorWithPath(sszutils.ErrListOffsetsEOFFn(len(buf), 4), "Attestations") + } + startOffset = int(binary.LittleEndian.Uint32(buf[0:4])) + } + itemCount := startOffset / 4 + if startOffset%4 != 0 || len(buf) < startOffset || (len(buf) != 0 && startOffset == 0) { + return sszutils.ErrorWithPath(sszutils.ErrInvalidListStartOffsetFn(startOffset, len(buf)), "Attestations") + } + val4 = sszutils.ExpandSlice(val4, itemCount) + for idx1 := range itemCount { + var endOffset int + if idx1 < itemCount-1 { + endOffset = int(binary.LittleEndian.Uint32(buf[(idx1+1)*4 : (idx1+2)*4])) + } else { + endOffset = len(buf) + } + if endOffset < startOffset || endOffset > len(buf) { + return sszutils.ErrorWithPathf(sszutils.ErrElementOffsetOutOfRangeFn(endOffset, startOffset, len(buf)), "Attestations[%d]", idx1) + } + buf := buf[startOffset:endOffset] + startOffset = endOffset + val5 := val4[idx1] + if val5 == nil { + val5 = new(Attestation) + } + if err = val5.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "Attestations[%d]", idx1) + } + val4[idx1] = val5 + } + t.Attestations = val4 + } + { // Field #6 'Deposits' (dynamic) + buf := buf[offset6:offset7] + val6 := t.Deposits + itemCount := len(buf) / 1240 + if len(buf)%1240 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 1240), "Deposits") + } + val6 = sszutils.ExpandSlice(val6, itemCount) + for idx1 := range itemCount { + if val6[idx1] == nil { + val6[idx1] = new(phase0.Deposit) + } + buf := buf[1240*idx1 : 1240*(idx1+1)] + if err = val6[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "Deposits[%d]", idx1) + } + } + t.Deposits = val6 + } + { // Field #7 'VoluntaryExits' (dynamic) + buf := buf[offset7:offset9] + val7 := t.VoluntaryExits + itemCount := len(buf) / 112 + if len(buf)%112 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 112), "VoluntaryExits") + } + val7 = sszutils.ExpandSlice(val7, itemCount) + for idx1 := range itemCount { + if val7[idx1] == nil { + val7[idx1] = new(phase0.SignedVoluntaryExit) + } + buf := buf[112*idx1 : 112*(idx1+1)] + if err = val7[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "VoluntaryExits[%d]", idx1) + } + } + t.VoluntaryExits = val7 + } + { // Field #9 'BLSToExecutionChanges' (dynamic) + buf := buf[offset9:offset10] + val8 := t.BLSToExecutionChanges + itemCount := len(buf) / 172 + if len(buf)%172 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 172), "BLSToExecutionChanges") + } + val8 = sszutils.ExpandSlice(val8, itemCount) + for idx1 := range itemCount { + if val8[idx1] == nil { + val8[idx1] = new(capella.SignedBLSToExecutionChange) + } + buf := buf[172*idx1 : 172*(idx1+1)] + if err = val8[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "BLSToExecutionChanges[%d]", idx1) + } + } + t.BLSToExecutionChanges = val8 + } + { // Field #10 'SignedExecutionPayloadBid' (dynamic) + buf := buf[offset10:offset11] + if t.SignedExecutionPayloadBid == nil { + t.SignedExecutionPayloadBid = new(SignedExecutionPayloadBid) + } + if err = t.SignedExecutionPayloadBid.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "SignedExecutionPayloadBid") + } + } + { // Field #11 'PayloadAttestations' (dynamic) + buf := buf[offset11:offset12] + val9 := t.PayloadAttestations + itemCount := len(buf) / 202 + if len(buf)%202 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 202), "PayloadAttestations") + } + val9 = sszutils.ExpandSlice(val9, itemCount) + for idx1 := range itemCount { + if val9[idx1] == nil { + val9[idx1] = new(PayloadAttestation) + } + buf := buf[202*idx1 : 202*(idx1+1)] + if err = val9[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "PayloadAttestations[%d]", idx1) + } + } + t.PayloadAttestations = val9 + } + { // Field #12 'ParentExecutionRequests' (dynamic) + buf := buf[offset12:] + if t.ParentExecutionRequests == nil { + t.ParentExecutionRequests = new(ExecutionRequests) + } + if err = t.ParentExecutionRequests.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "ParentExecutionRequests") + } + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *BeaconBlockBody. +func (t *BeaconBlockBody) SizeSSZ() (size int) { + if t == nil { + t = new(BeaconBlockBody) + } + // Field #0 'RANDAOReveal' static (96 bytes) + // Field #1 'ETH1Data' static (72 bytes) + // Field #2 'Graffiti' static (32 bytes) + // Field #3 'ProposerSlashings' offset (4 bytes) + // Field #4 'AttesterSlashings' offset (4 bytes) + // Field #5 'Attestations' offset (4 bytes) + // Field #6 'Deposits' offset (4 bytes) + // Field #7 'VoluntaryExits' offset (4 bytes) + // Field #8 'SyncAggregate' static (160 bytes) + // Field #9 'BLSToExecutionChanges' offset (4 bytes) + // Field #10 'SignedExecutionPayloadBid' offset (4 bytes) + // Field #11 'PayloadAttestations' offset (4 bytes) + // Field #12 'ParentExecutionRequests' offset (4 bytes) + size += 396 + { // Dynamic field #3 'ProposerSlashings' + size += len(t.ProposerSlashings) * 416 + } + { // Dynamic field #4 'AttesterSlashings' + t := t.AttesterSlashings + vlen := len(t) + size += vlen * 4 // Offsets + for i1 := range vlen { + size += t[i1].SizeSSZ() + } + } + { // Dynamic field #5 'Attestations' + t := t.Attestations + vlen := len(t) + size += vlen * 4 // Offsets + for i2 := range vlen { + size += t[i2].SizeSSZ() + } + } + { // Dynamic field #6 'Deposits' + size += len(t.Deposits) * 1240 + } + { // Dynamic field #7 'VoluntaryExits' + size += len(t.VoluntaryExits) * 112 + } + { // Dynamic field #9 'BLSToExecutionChanges' + size += len(t.BLSToExecutionChanges) * 172 + } + { // Dynamic field #10 'SignedExecutionPayloadBid' + size += t.SignedExecutionPayloadBid.SizeSSZ() + } + { // Dynamic field #11 'PayloadAttestations' + size += len(t.PayloadAttestations) * 202 + } + { // Dynamic field #12 'ParentExecutionRequests' + size += t.ParentExecutionRequests.SizeSSZ() + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *BeaconBlockBody. +func (t *BeaconBlockBody) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *BeaconBlockBody using the given hash walker. +func (t *BeaconBlockBody) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(BeaconBlockBody) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'RANDAOReveal' + hh.PutBytes(t.RANDAOReveal[:96]) + } + { // Field #1 'ETH1Data' + t := t.ETH1Data + if t == nil { + t = new(phase0.ETH1Data) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "ETH1Data") + } + } + { // Field #2 'Graffiti' + hh.PutBytes(t.Graffiti[:32]) + } + { // Field #3 'ProposerSlashings' + t := t.ProposerSlashings + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(phase0.ProposerSlashing) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "ProposerSlashings[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #4 'AttesterSlashings' + t := t.AttesterSlashings + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(AttesterSlashing) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "AttesterSlashings[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #5 'Attestations' + t := t.Attestations + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(Attestation) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "Attestations[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #6 'Deposits' + t := t.Deposits + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(phase0.Deposit) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "Deposits[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #7 'VoluntaryExits' + t := t.VoluntaryExits + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(phase0.SignedVoluntaryExit) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "VoluntaryExits[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #8 'SyncAggregate' + t := t.SyncAggregate + if t == nil { + t = new(altair.SyncAggregate) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "SyncAggregate") + } + } + { // Field #9 'BLSToExecutionChanges' + t := t.BLSToExecutionChanges + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(capella.SignedBLSToExecutionChange) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "BLSToExecutionChanges[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #10 'SignedExecutionPayloadBid' + t := t.SignedExecutionPayloadBid + if t == nil { + t = new(SignedExecutionPayloadBid) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "SignedExecutionPayloadBid") + } + } + { // Field #11 'PayloadAttestations' + t := t.PayloadAttestations + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(PayloadAttestation) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "PayloadAttestations[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #12 'ParentExecutionRequests' + t := t.ParentExecutionRequests + if t == nil { + t = new(ExecutionRequests) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "ParentExecutionRequests") + } + } + hh.MerkleizeProgressiveWithActiveFields(idx, []byte{0xff, 0x1f}) + return nil +} diff --git a/spec/gloas/beaconblockbody_yaml.go b/spec/gloas/beaconblockbody_yaml.go new file mode 100644 index 00000000..3fc8c73a --- /dev/null +++ b/spec/gloas/beaconblockbody_yaml.go @@ -0,0 +1,61 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (b *BeaconBlockBody) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&beaconBlockBodyJSON{ + RANDAOReveal: fmt.Sprintf("%#x", b.RANDAOReveal), + ETH1Data: b.ETH1Data, + Graffiti: fmt.Sprintf("%#x", b.Graffiti), + ProposerSlashings: b.ProposerSlashings, + AttesterSlashings: b.AttesterSlashings, + Attestations: b.Attestations, + Deposits: b.Deposits, + VoluntaryExits: b.VoluntaryExits, + SyncAggregate: b.SyncAggregate, + BLSToExecutionChanges: b.BLSToExecutionChanges, + SignedExecutionPayloadBid: b.SignedExecutionPayloadBid, + PayloadAttestations: b.PayloadAttestations, + ParentExecutionRequests: b.ParentExecutionRequests, + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (b *BeaconBlockBody) UnmarshalYAML(input []byte) error { + var data beaconBlockBodyJSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return b.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/beaconstate.go b/spec/gloas/beaconstate.go new file mode 100644 index 00000000..b22c17f0 --- /dev/null +++ b/spec/gloas/beaconstate.go @@ -0,0 +1,85 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + bitfield "github.com/OffchainLabs/go-bitfield" + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// BeaconState represents a beacon state for EIP-7732. +type BeaconState struct { + GenesisTime uint64 `ssz-index:"0"` + GenesisValidatorsRoot phase0.Root `ssz-index:"1" ssz-size:"32"` + Slot phase0.Slot `ssz-index:"2"` + Fork *phase0.Fork `ssz-index:"3"` + LatestBlockHeader *phase0.BeaconBlockHeader `ssz-index:"4"` + BlockRoots []phase0.Root `dynssz-size:"SLOTS_PER_HISTORICAL_ROOT,32" ssz-index:"5" ssz-size:"8192,32"` + StateRoots []phase0.Root `dynssz-size:"SLOTS_PER_HISTORICAL_ROOT,32" ssz-index:"6" ssz-size:"8192,32"` + HistoricalRoots []phase0.Root `dynssz-max:"HISTORICAL_ROOTS_LIMIT" ssz-index:"7" ssz-max:"16777216" ssz-size:"?,32"` + ETH1Data *phase0.ETH1Data `ssz-index:"8"` + ETH1DataVotes []*phase0.ETH1Data `dynssz-max:"EPOCHS_PER_ETH1_VOTING_PERIOD*SLOTS_PER_EPOCH" ssz-index:"9" ssz-max:"2048"` + ETH1DepositIndex uint64 `ssz-index:"10"` + Validators []*phase0.Validator `ssz-index:"11" ssz-type:"progressive-list"` + Balances []phase0.Gwei `ssz-index:"12" ssz-type:"progressive-list"` + RANDAOMixes []phase0.Root `dynssz-size:"EPOCHS_PER_HISTORICAL_VECTOR,32" ssz-index:"13" ssz-size:"65536,32"` + Slashings []phase0.Gwei `dynssz-size:"EPOCHS_PER_SLASHINGS_VECTOR" ssz-index:"14" ssz-size:"8192"` + PreviousEpochParticipation []altair.ParticipationFlags `ssz-index:"15" ssz-type:"progressive-list"` + CurrentEpochParticipation []altair.ParticipationFlags `ssz-index:"16" ssz-type:"progressive-list"` + JustificationBits bitfield.Bitvector4 `ssz-index:"17" ssz-size:"1"` + PreviousJustifiedCheckpoint *phase0.Checkpoint `ssz-index:"18"` + CurrentJustifiedCheckpoint *phase0.Checkpoint `ssz-index:"19"` + FinalizedCheckpoint *phase0.Checkpoint `ssz-index:"20"` + InactivityScores []uint64 `ssz-index:"21" ssz-type:"progressive-list"` + CurrentSyncCommittee *altair.SyncCommittee `ssz-index:"22"` + NextSyncCommittee *altair.SyncCommittee `ssz-index:"23"` + LatestBlockHash phase0.Hash32 `ssz-index:"24" ssz-size:"32"` + NextWithdrawalIndex capella.WithdrawalIndex `ssz-index:"25"` + NextWithdrawalValidatorIndex phase0.ValidatorIndex `ssz-index:"26"` + HistoricalSummaries []*capella.HistoricalSummary `dynssz-max:"HISTORICAL_ROOTS_LIMIT" ssz-index:"27" ssz-max:"16777216"` + DepositRequestsStartIndex uint64 `ssz-index:"28"` + DepositBalanceToConsume phase0.Gwei `ssz-index:"29"` + ExitBalanceToConsume phase0.Gwei `ssz-index:"30"` + EarliestExitEpoch phase0.Epoch `ssz-index:"31"` + ConsolidationBalanceToConsume phase0.Gwei `ssz-index:"32"` + EarliestConsolidationEpoch phase0.Epoch `ssz-index:"33"` + PendingDeposits []*electra.PendingDeposit `ssz-index:"34" ssz-type:"progressive-list"` + PendingPartialWithdrawals []*electra.PendingPartialWithdrawal `ssz-index:"35" ssz-type:"progressive-list"` + PendingConsolidations []*electra.PendingConsolidation `ssz-index:"36" ssz-type:"progressive-list"` + ProposerLookahead []phase0.ValidatorIndex `dynssz-size:"(MIN_SEED_LOOKAHEAD+1)*SLOTS_PER_EPOCH" ssz-index:"37" ssz-size:"64"` + Builders []*Builder `ssz-index:"38" ssz-type:"progressive-list"` + NextWithdrawalBuilderIndex BuilderIndex `ssz-index:"39"` + ExecutionPayloadAvailability []uint8 `dynssz-size:"SLOTS_PER_HISTORICAL_ROOT/8" ssz-index:"40" ssz-size:"1024"` + BuilderPendingPayments []*BuilderPendingPayment `dynssz-size:"SLOTS_PER_EPOCH*2" ssz-index:"41" ssz-size:"64"` + BuilderPendingWithdrawals []*BuilderPendingWithdrawal `ssz-index:"42" ssz-type:"progressive-list"` + LatestExecutionPayloadBid *ExecutionPayloadBid `ssz-index:"43"` + PayloadExpectedWithdrawals []*capella.Withdrawal `ssz-index:"44" ssz-type:"progressive-list"` + PTCWindow [][]phase0.ValidatorIndex `dynssz-size:"(2+MIN_SEED_LOOKAHEAD)*SLOTS_PER_EPOCH,PTC_SIZE" ssz-index:"45" ssz-size:"96,512"` +} + +// String returns a string version of the structure. +func (b *BeaconState) String() string { + data, err := yaml.Marshal(b) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/beaconstate_json.go b/spec/gloas/beaconstate_json.go new file mode 100644 index 00000000..5892c7f2 --- /dev/null +++ b/spec/gloas/beaconstate_json.go @@ -0,0 +1,478 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/codecs" + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// beaconStateJSON is the spec representation of the struct. +type beaconStateJSON struct { + GenesisTime string `json:"genesis_time"` + GenesisValidatorsRoot phase0.Root `json:"genesis_validators_root"` + Slot phase0.Slot `json:"slot"` + Fork *phase0.Fork `json:"fork"` + LatestBlockHeader *phase0.BeaconBlockHeader `json:"latest_block_header"` + BlockRoots []phase0.Root `json:"block_roots"` + StateRoots []phase0.Root `json:"state_roots"` + HistoricalRoots []phase0.Root `json:"historical_roots"` + ETH1Data *phase0.ETH1Data `json:"eth1_data"` + //nolint:staticcheck + ETH1DataVotes []*phase0.ETH1Data `json:"eth1_data_votes,allowempty"` + ETH1DepositIndex string `json:"eth1_deposit_index"` + Validators []*phase0.Validator `json:"validators"` + Balances []string `json:"balances"` + RANDAOMixes []string `json:"randao_mixes"` + Slashings []string `json:"slashings"` + PreviousEpochParticipation []string `json:"previous_epoch_participation"` + CurrentEpochParticipation []string `json:"current_epoch_participation"` + JustificationBits string `json:"justification_bits"` + PreviousJustifiedCheckpoint *phase0.Checkpoint `json:"previous_justified_checkpoint"` + CurrentJustifiedCheckpoint *phase0.Checkpoint `json:"current_justified_checkpoint"` + FinalizedCheckpoint *phase0.Checkpoint `json:"finalized_checkpoint"` + InactivityScores []string `json:"inactivity_scores"` + CurrentSyncCommittee *altair.SyncCommittee `json:"current_sync_committee"` + NextSyncCommittee *altair.SyncCommittee `json:"next_sync_committee"` + LatestBlockHash string `json:"latest_block_hash"` + NextWithdrawalIndex string `json:"next_withdrawal_index"` + NextWithdrawalValidatorIndex string `json:"next_withdrawal_validator_index"` + HistoricalSummaries []*capella.HistoricalSummary `json:"historical_summaries"` + DepositRequestsStartIndex string `json:"deposit_requests_start_index"` + DepositBalanceToConsume phase0.Gwei `json:"deposit_balance_to_consume"` + ExitBalanceToConsume phase0.Gwei `json:"exit_balance_to_consume"` + EarliestExitEpoch phase0.Epoch `json:"earliest_exit_epoch"` + ConsolidationBalanceToConsume phase0.Gwei `json:"consolidation_balance_to_consume"` + EarliestConsolidationEpoch phase0.Epoch `json:"earliest_consolidation_epoch"` + PendingDeposits []*electra.PendingDeposit `json:"pending_deposits"` + PendingPartialWithdrawals []*electra.PendingPartialWithdrawal `json:"pending_partial_withdrawals"` + PendingConsolidations []*electra.PendingConsolidation `json:"pending_consolidations"` + ProposerLookahead []string `json:"proposer_lookahead"` + Builders []*Builder `json:"builders"` + NextWithdrawalBuilderIndex string `json:"next_withdrawal_builder_index"` + ExecutionPayloadAvailability string `json:"execution_payload_availability"` + BuilderPendingPayments []*BuilderPendingPayment `json:"builder_pending_payments"` + BuilderPendingWithdrawals []*BuilderPendingWithdrawal `json:"builder_pending_withdrawals"` + LatestExecutionPayloadBid *ExecutionPayloadBid `json:"latest_execution_payload_bid"` + PayloadExpectedWithdrawals []*capella.Withdrawal `json:"payload_expected_withdrawals"` + PTCWindow [][]string `json:"ptc_window"` +} + +// MarshalJSON implements json.Marshaler. +func (b *BeaconState) MarshalJSON() ([]byte, error) { + balances := make([]string, len(b.Balances)) + for i := range b.Balances { + balances[i] = fmt.Sprintf("%d", b.Balances[i]) + } + randaoMixes := make([]string, len(b.RANDAOMixes)) + for i := range b.RANDAOMixes { + randaoMixes[i] = fmt.Sprintf("%#x", b.RANDAOMixes[i]) + } + slashings := make([]string, len(b.Slashings)) + for i := range b.Slashings { + slashings[i] = fmt.Sprintf("%d", b.Slashings[i]) + } + previousEpochParticipation := make([]string, len(b.PreviousEpochParticipation)) + for i := range b.PreviousEpochParticipation { + previousEpochParticipation[i] = fmt.Sprintf("%d", b.PreviousEpochParticipation[i]) + } + currentEpochParticipation := make([]string, len(b.CurrentEpochParticipation)) + for i := range b.CurrentEpochParticipation { + currentEpochParticipation[i] = fmt.Sprintf("%d", b.CurrentEpochParticipation[i]) + } + inactivityScores := make([]string, len(b.InactivityScores)) + for i := range b.InactivityScores { + inactivityScores[i] = strconv.FormatUint(b.InactivityScores[i], 10) + } + proposerLookahead := make([]string, len(b.ProposerLookahead)) + for i := range b.ProposerLookahead { + proposerLookahead[i] = fmt.Sprintf("%d", b.ProposerLookahead[i]) + } + ptcWindow := make([][]string, len(b.PTCWindow)) + for i := range b.PTCWindow { + ptcWindow[i] = make([]string, len(b.PTCWindow[i])) + for j := range b.PTCWindow[i] { + ptcWindow[i][j] = fmt.Sprintf("%d", b.PTCWindow[i][j]) + } + } + + return json.Marshal(&beaconStateJSON{ + GenesisTime: strconv.FormatUint(b.GenesisTime, 10), + GenesisValidatorsRoot: b.GenesisValidatorsRoot, + Slot: b.Slot, + Fork: b.Fork, + LatestBlockHeader: b.LatestBlockHeader, + BlockRoots: b.BlockRoots, + StateRoots: b.StateRoots, + HistoricalRoots: b.HistoricalRoots, + ETH1Data: b.ETH1Data, + ETH1DataVotes: b.ETH1DataVotes, + ETH1DepositIndex: strconv.FormatUint(b.ETH1DepositIndex, 10), + Validators: b.Validators, + Balances: balances, + RANDAOMixes: randaoMixes, + Slashings: slashings, + PreviousEpochParticipation: previousEpochParticipation, + CurrentEpochParticipation: currentEpochParticipation, + JustificationBits: fmt.Sprintf("%#x", b.JustificationBits.Bytes()), + PreviousJustifiedCheckpoint: b.PreviousJustifiedCheckpoint, + CurrentJustifiedCheckpoint: b.CurrentJustifiedCheckpoint, + FinalizedCheckpoint: b.FinalizedCheckpoint, + InactivityScores: inactivityScores, + CurrentSyncCommittee: b.CurrentSyncCommittee, + NextSyncCommittee: b.NextSyncCommittee, + LatestBlockHash: fmt.Sprintf("%#x", b.LatestBlockHash), + NextWithdrawalIndex: fmt.Sprintf("%d", b.NextWithdrawalIndex), + NextWithdrawalValidatorIndex: fmt.Sprintf("%d", b.NextWithdrawalValidatorIndex), + HistoricalSummaries: b.HistoricalSummaries, + DepositRequestsStartIndex: fmt.Sprintf("%d", b.DepositRequestsStartIndex), + DepositBalanceToConsume: b.DepositBalanceToConsume, + ExitBalanceToConsume: b.ExitBalanceToConsume, + EarliestExitEpoch: b.EarliestExitEpoch, + ConsolidationBalanceToConsume: b.ConsolidationBalanceToConsume, + EarliestConsolidationEpoch: b.EarliestConsolidationEpoch, + PendingDeposits: b.PendingDeposits, + PendingPartialWithdrawals: b.PendingPartialWithdrawals, + PendingConsolidations: b.PendingConsolidations, + ProposerLookahead: proposerLookahead, + Builders: b.Builders, + NextWithdrawalBuilderIndex: fmt.Sprintf("%d", b.NextWithdrawalBuilderIndex), + ExecutionPayloadAvailability: fmt.Sprintf("%#x", b.ExecutionPayloadAvailability), + BuilderPendingPayments: b.BuilderPendingPayments, + BuilderPendingWithdrawals: b.BuilderPendingWithdrawals, + LatestExecutionPayloadBid: b.LatestExecutionPayloadBid, + PayloadExpectedWithdrawals: b.PayloadExpectedWithdrawals, + PTCWindow: ptcWindow, + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +// +//nolint:gocyclo +func (b *BeaconState) UnmarshalJSON(input []byte) error { + raw, err := codecs.RawJSON(&beaconStateJSON{}, input) + if err != nil { + return err + } + + genesisTime := string(bytes.Trim(raw["genesis_time"], `"`)) + if b.GenesisTime, err = strconv.ParseUint(genesisTime, 10, 64); err != nil { + return errors.Wrap(err, "genesis_time") + } + + if err := b.GenesisValidatorsRoot.UnmarshalJSON(raw["genesis_validators_root"]); err != nil { + return errors.Wrap(err, "genesis_validators_root") + } + + if err := b.Slot.UnmarshalJSON(raw["slot"]); err != nil { + return errors.Wrap(err, "slot") + } + + b.Fork = &phase0.Fork{} + if err := b.Fork.UnmarshalJSON(raw["fork"]); err != nil { + return errors.Wrap(err, "fork") + } + + b.LatestBlockHeader = &phase0.BeaconBlockHeader{} + if err := b.LatestBlockHeader.UnmarshalJSON(raw["latest_block_header"]); err != nil { + return errors.Wrap(err, "latest_block_header") + } + + if err := json.Unmarshal(raw["block_roots"], &b.BlockRoots); err != nil { + return errors.Wrap(err, "block_roots") + } + + if err := json.Unmarshal(raw["state_roots"], &b.StateRoots); err != nil { + return errors.Wrap(err, "state_roots") + } + + if err := json.Unmarshal(raw["historical_roots"], &b.HistoricalRoots); err != nil { + return errors.Wrap(err, "historical_roots") + } + + b.ETH1Data = &phase0.ETH1Data{} + if err := b.ETH1Data.UnmarshalJSON(raw["eth1_data"]); err != nil { + return errors.Wrap(err, "eth1_data") + } + + if err := json.Unmarshal(raw["eth1_data_votes"], &b.ETH1DataVotes); err != nil { + return errors.Wrap(err, "eth1_data_votes") + } + for i := range b.ETH1DataVotes { + if b.ETH1DataVotes[i] == nil { + return fmt.Errorf("eth1 data votes entry %d missing", i) + } + } + + eth1DepositIndex := string(bytes.Trim(raw["eth1_deposit_index"], `"`)) + if b.ETH1DepositIndex, err = strconv.ParseUint(eth1DepositIndex, 10, 64); err != nil { + return errors.Wrap(err, "eth1_deposit_index") + } + + if err := json.Unmarshal(raw["validators"], &b.Validators); err != nil { + return errors.Wrap(err, "validators") + } + for i := range b.Validators { + if b.Validators[i] == nil { + return fmt.Errorf("validators entry %d missing", i) + } + } + + if err := json.Unmarshal(raw["balances"], &b.Balances); err != nil { + return errors.Wrap(err, "balances") + } + + if err := json.Unmarshal(raw["randao_mixes"], &b.RANDAOMixes); err != nil { + return errors.Wrap(err, "randao_mixes") + } + + if err := json.Unmarshal(raw["slashings"], &b.Slashings); err != nil { + return errors.Wrap(err, "slashings") + } + + if err := json.Unmarshal(raw["previous_epoch_participation"], &b.PreviousEpochParticipation); err != nil { + return errors.Wrap(err, "previous_epoch_participation") + } + + if err := json.Unmarshal(raw["current_epoch_participation"], &b.CurrentEpochParticipation); err != nil { + return errors.Wrap(err, "current_epoch_participation") + } + + justificationBits := string(bytes.TrimPrefix(bytes.Trim(raw["justification_bits"], `"`), []byte{'0', 'x'})) + if b.JustificationBits, err = hex.DecodeString(justificationBits); err != nil { + return errors.Wrap(err, "justification_bits") + } + + b.PreviousJustifiedCheckpoint = &phase0.Checkpoint{} + if err := b.PreviousJustifiedCheckpoint.UnmarshalJSON(raw["previous_justified_checkpoint"]); err != nil { + return errors.Wrap(err, "previous_justified_checkpoint") + } + + b.CurrentJustifiedCheckpoint = &phase0.Checkpoint{} + if err := b.CurrentJustifiedCheckpoint.UnmarshalJSON(raw["current_justified_checkpoint"]); err != nil { + return errors.Wrap(err, "current_justified_checkpoint") + } + + b.FinalizedCheckpoint = &phase0.Checkpoint{} + if err := b.FinalizedCheckpoint.UnmarshalJSON(raw["finalized_checkpoint"]); err != nil { + return errors.Wrap(err, "finalized_checkpoint") + } + + inactivityScores := make([]string, 0) + if err := json.Unmarshal(raw["inactivity_scores"], &inactivityScores); err != nil { + return errors.Wrap(err, "inactivity_scores") + } + b.InactivityScores = make([]uint64, len(inactivityScores)) + for i := range inactivityScores { + if inactivityScores[i] == "" { + return fmt.Errorf("inactivity score %d missing", i) + } + if b.InactivityScores[i], err = strconv.ParseUint(inactivityScores[i], 10, 64); err != nil { + return errors.Wrap(err, fmt.Sprintf("invalid value for inactivity score %d", i)) + } + } + + b.CurrentSyncCommittee = &altair.SyncCommittee{} + if err := b.CurrentSyncCommittee.UnmarshalJSON(raw["current_sync_committee"]); err != nil { + return errors.Wrap(err, "current_sync_committee") + } + + b.NextSyncCommittee = &altair.SyncCommittee{} + if err := b.NextSyncCommittee.UnmarshalJSON(raw["next_sync_committee"]); err != nil { + return errors.Wrap(err, "next_sync_committee") + } + + if raw["latest_block_hash"] == nil { + return errors.New("latest block hash missing") + } + latestBlockHash, err := hex.DecodeString( + strings.TrimPrefix(string(bytes.Trim(raw["latest_block_hash"], `"`)), "0x"), + ) + if err != nil { + return errors.Wrap(err, "invalid latest block hash") + } + if len(latestBlockHash) != phase0.Hash32Length { + return errors.New("incorrect length for latest block hash") + } + copy(b.LatestBlockHash[:], latestBlockHash) + + if err := b.NextWithdrawalIndex.UnmarshalJSON(raw["next_withdrawal_index"]); err != nil { + return errors.Wrap(err, "next_withdrawal_index") + } + + if err := b.NextWithdrawalValidatorIndex.UnmarshalJSON(raw["next_withdrawal_validator_index"]); err != nil { + return errors.Wrap(err, "next_withdrawal_validator_index") + } + + if err := json.Unmarshal(raw["historical_summaries"], &b.HistoricalSummaries); err != nil { + return errors.Wrap(err, "historical_summaries") + } + for i := range b.HistoricalSummaries { + if b.HistoricalSummaries[i] == nil { + return fmt.Errorf("historical summaries entry %d missing", i) + } + } + + depositRequestsStartIndex := string(bytes.Trim(raw["deposit_requests_start_index"], `"`)) + if b.DepositRequestsStartIndex, err = strconv.ParseUint(depositRequestsStartIndex, 10, 64); err != nil { + return errors.Wrap(err, "deposit_requests_start_index") + } + + if err := b.DepositBalanceToConsume.UnmarshalJSON(raw["deposit_balance_to_consume"]); err != nil { + return errors.Wrap(err, "deposit_balance_to_consume") + } + + if err := b.ExitBalanceToConsume.UnmarshalJSON(raw["exit_balance_to_consume"]); err != nil { + return errors.Wrap(err, "exit_balance_to_consume") + } + + if err := b.EarliestExitEpoch.UnmarshalJSON(raw["earliest_exit_epoch"]); err != nil { + return errors.Wrap(err, "earliest_exit_epoch") + } + + if err := b.ConsolidationBalanceToConsume.UnmarshalJSON(raw["consolidation_balance_to_consume"]); err != nil { + return errors.Wrap(err, "consolidation_balance_to_consume") + } + + if err := b.EarliestConsolidationEpoch.UnmarshalJSON(raw["earliest_consolidation_epoch"]); err != nil { + return errors.Wrap(err, "earliest_consolidation_epoch") + } + + if err := json.Unmarshal(raw["pending_deposits"], &b.PendingDeposits); err != nil { + return errors.Wrap(err, "pending_deposits") + } + for i := range b.PendingDeposits { + if b.PendingDeposits[i] == nil { + return fmt.Errorf("pending deposits entry %d missing", i) + } + } + + if err := json.Unmarshal(raw["pending_partial_withdrawals"], &b.PendingPartialWithdrawals); err != nil { + return errors.Wrap(err, "pending_partial_withdrawals") + } + for i := range b.PendingPartialWithdrawals { + if b.PendingPartialWithdrawals[i] == nil { + return fmt.Errorf("pending partial withdrawals entry %d missing", i) + } + } + + if err := json.Unmarshal(raw["pending_consolidations"], &b.PendingConsolidations); err != nil { + return errors.Wrap(err, "pending_consolidations") + } + for i := range b.PendingConsolidations { + if b.PendingConsolidations[i] == nil { + return fmt.Errorf("pending consolidations entry %d missing", i) + } + } + + if err := json.Unmarshal(raw["proposer_lookahead"], &b.ProposerLookahead); err != nil { + return errors.Wrap(err, "proposer_lookahead") + } + + if err := json.Unmarshal(raw["builders"], &b.Builders); err != nil { + return errors.Wrap(err, "builders") + } + for i := range b.Builders { + if b.Builders[i] == nil { + return fmt.Errorf("builders entry %d missing", i) + } + } + + if err := b.NextWithdrawalBuilderIndex.UnmarshalJSON(raw["next_withdrawal_builder_index"]); err != nil { + return errors.Wrap(err, "next_withdrawal_builder_index") + } + + // A Bitvector arrives as a single hex string, exactly like justification_bits + // above. Decoding it straight into the []uint8 field would instead get + // encoding/json's []byte special case, which reads base64. + executionPayloadAvailability := string(bytes.TrimPrefix(bytes.Trim(raw["execution_payload_availability"], `"`), []byte{'0', 'x'})) + if b.ExecutionPayloadAvailability, err = hex.DecodeString(executionPayloadAvailability); err != nil { + return errors.Wrap(err, "execution_payload_availability") + } + + if err := json.Unmarshal(raw["builder_pending_payments"], &b.BuilderPendingPayments); err != nil { + return errors.Wrap(err, "builder_pending_payments") + } + for i := range b.BuilderPendingPayments { + if b.BuilderPendingPayments[i] == nil { + return fmt.Errorf("builder pending payments entry %d missing", i) + } + } + + if err := json.Unmarshal(raw["builder_pending_withdrawals"], &b.BuilderPendingWithdrawals); err != nil { + return errors.Wrap(err, "builder_pending_withdrawals") + } + for i := range b.BuilderPendingWithdrawals { + if b.BuilderPendingWithdrawals[i] == nil { + return fmt.Errorf("builder pending withdrawals entry %d missing", i) + } + } + + b.LatestExecutionPayloadBid = &ExecutionPayloadBid{} + if err := b.LatestExecutionPayloadBid.UnmarshalJSON(raw["latest_execution_payload_bid"]); err != nil { + return errors.Wrap(err, "latest_execution_payload_bid") + } + + if err := json.Unmarshal(raw["payload_expected_withdrawals"], &b.PayloadExpectedWithdrawals); err != nil { + return errors.Wrap(err, "payload_expected_withdrawals") + } + for i := range b.PayloadExpectedWithdrawals { + if b.PayloadExpectedWithdrawals[i] == nil { + return fmt.Errorf("payload expected withdrawals entry %d missing", i) + } + } + + ptcWindowStr := make([][]string, 0) + if err := json.Unmarshal(raw["ptc_window"], &ptcWindowStr); err != nil { + // Prysm wraps each inner vector in an object carrying validator_indices, + // because proto3 cannot express a nested repeated field without an + // intermediate message. The spec shape is tried first and is the only one + // marshalled, so the deviation is absorbed where it arrives rather than + // propagated; on failure of both the spec shape's error is the one reported. + wrapped := make([]struct { + ValidatorIndices []string `json:"validator_indices"` + }, 0) + if wrappedErr := json.Unmarshal(raw["ptc_window"], &wrapped); wrappedErr != nil { + return errors.Wrap(err, "ptc_window") + } + ptcWindowStr = make([][]string, len(wrapped)) + for i := range wrapped { + ptcWindowStr[i] = wrapped[i].ValidatorIndices + } + } + b.PTCWindow = make([][]phase0.ValidatorIndex, len(ptcWindowStr)) + for i := range ptcWindowStr { + b.PTCWindow[i] = make([]phase0.ValidatorIndex, len(ptcWindowStr[i])) + for j := range ptcWindowStr[i] { + idx, parseErr := strconv.ParseUint(ptcWindowStr[i][j], 10, 64) + if parseErr != nil { + return errors.Wrap(parseErr, fmt.Sprintf("ptc_window[%d][%d]", i, j)) + } + b.PTCWindow[i][j] = phase0.ValidatorIndex(idx) + } + } + + return nil +} diff --git a/spec/gloas/beaconstate_ssz.go b/spec/gloas/beaconstate_ssz.go new file mode 100644 index 00000000..505e6e3e --- /dev/null +++ b/spec/gloas/beaconstate_ssz.go @@ -0,0 +1,1661 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: eb045f3698d24889c0a1276edf3fbc694d5d2177444ef5eb480cdf93a8136c5a +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[BeaconState](`ssz-static:"false"`) + +// MarshalSSZ marshals the *BeaconState to SSZ-encoded bytes. +func (t *BeaconState) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *BeaconState to SSZ-encoded bytes, appending to the provided buffer. +func (t *BeaconState) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + zeroBytes := sszutils.ZeroBytes() + if t == nil { + t = new(BeaconState) + } + dstlen := len(dst) + { // Static Field #0 'GenesisTime' + dst = binary.LittleEndian.AppendUint64(dst, t.GenesisTime) + } + { // Static Field #1 'GenesisValidatorsRoot' + dst = append(dst, t.GenesisValidatorsRoot[:32]...) + } + { // Static Field #2 'Slot' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.Slot)) + } + { // Static Field #3 'Fork' + t := t.Fork + if t == nil { + t = new(phase0.Fork) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Fork") + } + } + { // Static Field #4 'LatestBlockHeader' + t := t.LatestBlockHeader + if t == nil { + t = new(phase0.BeaconBlockHeader) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "LatestBlockHeader") + } + } + { // Static Field #5 'BlockRoots' + t := t.BlockRoots + vlen := len(t) + if vlen > 8192 { + return nil, sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 8192), "BlockRoots") + } + dst = sszutils.MarshalFixedBytesSlice(dst, t[:vlen]) + if vlen < 8192 { + dst = sszutils.AppendZeroPadding(dst, (8192-vlen)*32) + } + } + { // Static Field #6 'StateRoots' + t := t.StateRoots + vlen := len(t) + if vlen > 8192 { + return nil, sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 8192), "StateRoots") + } + dst = sszutils.MarshalFixedBytesSlice(dst, t[:vlen]) + if vlen < 8192 { + dst = sszutils.AppendZeroPadding(dst, (8192-vlen)*32) + } + } + // Offset Field #7 'HistoricalRoots' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #8 'ETH1Data' + t := t.ETH1Data + if t == nil { + t = new(phase0.ETH1Data) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "ETH1Data") + } + } + // Offset Field #9 'ETH1DataVotes' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #10 'ETH1DepositIndex' + dst = binary.LittleEndian.AppendUint64(dst, t.ETH1DepositIndex) + } + // Offset Field #11 'Validators' + // Offset Field #12 'Balances' + dst = append(dst, 0, 0, 0, 0, 0, 0, 0, 0) + { // Static Field #13 'RANDAOMixes' + t := t.RANDAOMixes + vlen := len(t) + if vlen > 65536 { + return nil, sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 65536), "RANDAOMixes") + } + dst = sszutils.MarshalFixedBytesSlice(dst, t[:vlen]) + if vlen < 65536 { + dst = sszutils.AppendZeroPadding(dst, (65536-vlen)*32) + } + } + { // Static Field #14 'Slashings' + t := t.Slashings + vlen := len(t) + if vlen > 8192 { + return nil, sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 8192), "Slashings") + } + dst = sszutils.MarshalUint64Slice(dst, t[:vlen]) + if vlen < 8192 { + dst = sszutils.AppendZeroPadding(dst, (8192-vlen)*8) + } + } + // Offset Field #15 'PreviousEpochParticipation' + // Offset Field #16 'CurrentEpochParticipation' + dst = append(dst, 0, 0, 0, 0, 0, 0, 0, 0) + { // Static Field #17 'JustificationBits' + vlen := len(t.JustificationBits) + if vlen > 1 { + return nil, sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 1), "JustificationBits") + } + dst = append(dst, t.JustificationBits[:vlen]...) + if vlen < 1 { + dst = sszutils.AppendZeroPadding(dst, (1-vlen)*1) + } + } + { // Static Field #18 'PreviousJustifiedCheckpoint' + t := t.PreviousJustifiedCheckpoint + if t == nil { + t = new(phase0.Checkpoint) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "PreviousJustifiedCheckpoint") + } + } + { // Static Field #19 'CurrentJustifiedCheckpoint' + t := t.CurrentJustifiedCheckpoint + if t == nil { + t = new(phase0.Checkpoint) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "CurrentJustifiedCheckpoint") + } + } + { // Static Field #20 'FinalizedCheckpoint' + t := t.FinalizedCheckpoint + if t == nil { + t = new(phase0.Checkpoint) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "FinalizedCheckpoint") + } + } + // Offset Field #21 'InactivityScores' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #22 'CurrentSyncCommittee' + t := t.CurrentSyncCommittee + if t == nil { + t = new(altair.SyncCommittee) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "CurrentSyncCommittee") + } + } + { // Static Field #23 'NextSyncCommittee' + t := t.NextSyncCommittee + if t == nil { + t = new(altair.SyncCommittee) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "NextSyncCommittee") + } + } + { // Static Field #24 'LatestBlockHash' + dst = append(dst, t.LatestBlockHash[:32]...) + } + { // Static Field #25 'NextWithdrawalIndex' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.NextWithdrawalIndex)) + } + { // Static Field #26 'NextWithdrawalValidatorIndex' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.NextWithdrawalValidatorIndex)) + } + // Offset Field #27 'HistoricalSummaries' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #28 'DepositRequestsStartIndex' + dst = binary.LittleEndian.AppendUint64(dst, t.DepositRequestsStartIndex) + } + { // Static Field #29 'DepositBalanceToConsume' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.DepositBalanceToConsume)) + } + { // Static Field #30 'ExitBalanceToConsume' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.ExitBalanceToConsume)) + } + { // Static Field #31 'EarliestExitEpoch' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.EarliestExitEpoch)) + } + { // Static Field #32 'ConsolidationBalanceToConsume' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.ConsolidationBalanceToConsume)) + } + { // Static Field #33 'EarliestConsolidationEpoch' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.EarliestConsolidationEpoch)) + } + // Offset Field #34 'PendingDeposits' + // Offset Field #35 'PendingPartialWithdrawals' + // Offset Field #36 'PendingConsolidations' + dst = append(dst, zeroBytes[:12]...) + { // Static Field #37 'ProposerLookahead' + t := t.ProposerLookahead + vlen := len(t) + if vlen > 64 { + return nil, sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 64), "ProposerLookahead") + } + dst = sszutils.MarshalUint64Slice(dst, t[:vlen]) + if vlen < 64 { + dst = sszutils.AppendZeroPadding(dst, (64-vlen)*8) + } + } + // Offset Field #38 'Builders' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #39 'NextWithdrawalBuilderIndex' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.NextWithdrawalBuilderIndex)) + } + { // Static Field #40 'ExecutionPayloadAvailability' + vlen := len(t.ExecutionPayloadAvailability) + if vlen > 1024 { + return nil, sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 1024), "ExecutionPayloadAvailability") + } + dst = append(dst, t.ExecutionPayloadAvailability[:vlen]...) + if vlen < 1024 { + dst = sszutils.AppendZeroPadding(dst, (1024-vlen)*1) + } + } + { // Static Field #41 'BuilderPendingPayments' + t := t.BuilderPendingPayments + vlen := len(t) + if vlen > 64 { + return nil, sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 64), "BuilderPendingPayments") + } + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(BuilderPendingPayment) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "BuilderPendingPayments[%d]", idx1) + } + } + if vlen < 64 { + dst = sszutils.AppendZeroPadding(dst, (64-vlen)*52) + } + } + // Offset Field #42 'BuilderPendingWithdrawals' + // Offset Field #43 'LatestExecutionPayloadBid' + // Offset Field #44 'PayloadExpectedWithdrawals' + dst = append(dst, zeroBytes[:12]...) + { // Static Field #45 'PTCWindow' + t := t.PTCWindow + vlen := len(t) + if vlen > 96 { + return nil, sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 96), "PTCWindow") + } + for idx1 := range vlen { + t := t[idx1] + vlen := len(t) + if vlen > 512 { + return nil, sszutils.ErrorWithPathf(sszutils.ErrVectorLengthFn(vlen, 512), "PTCWindow[%d]", idx1) + } + dst = sszutils.MarshalUint64Slice(dst, t[:vlen]) + if vlen < 512 { + dst = sszutils.AppendZeroPadding(dst, (512-vlen)*8) + } + } + if vlen < 96 { + dst = sszutils.AppendZeroPadding(dst, (96-vlen)*4096) + } + } + { // Dynamic Field #7 'HistoricalRoots' + binary.LittleEndian.PutUint32(dst[dstlen+524464:], uint32(len(dst)-dstlen)) + t := t.HistoricalRoots + vlen := len(t) + if vlen > 16777216 { + return nil, sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 16777216), "HistoricalRoots") + } + dst = sszutils.MarshalFixedBytesSlice(dst, t[:vlen]) + } + { // Dynamic Field #9 'ETH1DataVotes' + binary.LittleEndian.PutUint32(dst[dstlen+524540:], uint32(len(dst)-dstlen)) + t := t.ETH1DataVotes + vlen := len(t) + if vlen > 2048 { + return nil, sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 2048), "ETH1DataVotes") + } + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(phase0.ETH1Data) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "ETH1DataVotes[%d]", idx1) + } + } + } + { // Dynamic Field #11 'Validators' + binary.LittleEndian.PutUint32(dst[dstlen+524552:], uint32(len(dst)-dstlen)) + t := t.Validators + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(phase0.Validator) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "Validators[%d]", idx1) + } + } + } + { // Dynamic Field #12 'Balances' + binary.LittleEndian.PutUint32(dst[dstlen+524556:], uint32(len(dst)-dstlen)) + t := t.Balances + vlen := len(t) + dst = sszutils.MarshalUint64Slice(dst, t[:vlen]) + } + { // Dynamic Field #15 'PreviousEpochParticipation' + binary.LittleEndian.PutUint32(dst[dstlen+2687248:], uint32(len(dst)-dstlen)) + t := t.PreviousEpochParticipation + vlen := len(t) + for idx1 := range vlen { + dst = append(dst, byte(t[idx1])) + } + } + { // Dynamic Field #16 'CurrentEpochParticipation' + binary.LittleEndian.PutUint32(dst[dstlen+2687252:], uint32(len(dst)-dstlen)) + t := t.CurrentEpochParticipation + vlen := len(t) + for idx1 := range vlen { + dst = append(dst, byte(t[idx1])) + } + } + { // Dynamic Field #21 'InactivityScores' + binary.LittleEndian.PutUint32(dst[dstlen+2687377:], uint32(len(dst)-dstlen)) + t := t.InactivityScores + vlen := len(t) + dst = sszutils.MarshalUint64Slice(dst, t[:vlen]) + } + { // Dynamic Field #27 'HistoricalSummaries' + binary.LittleEndian.PutUint32(dst[dstlen+2736677:], uint32(len(dst)-dstlen)) + t := t.HistoricalSummaries + vlen := len(t) + if vlen > 16777216 { + return nil, sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 16777216), "HistoricalSummaries") + } + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(capella.HistoricalSummary) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "HistoricalSummaries[%d]", idx1) + } + } + } + { // Dynamic Field #34 'PendingDeposits' + binary.LittleEndian.PutUint32(dst[dstlen+2736729:], uint32(len(dst)-dstlen)) + t := t.PendingDeposits + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(electra.PendingDeposit) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "PendingDeposits[%d]", idx1) + } + } + } + { // Dynamic Field #35 'PendingPartialWithdrawals' + binary.LittleEndian.PutUint32(dst[dstlen+2736733:], uint32(len(dst)-dstlen)) + t := t.PendingPartialWithdrawals + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(electra.PendingPartialWithdrawal) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "PendingPartialWithdrawals[%d]", idx1) + } + } + } + { // Dynamic Field #36 'PendingConsolidations' + binary.LittleEndian.PutUint32(dst[dstlen+2736737:], uint32(len(dst)-dstlen)) + t := t.PendingConsolidations + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(electra.PendingConsolidation) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "PendingConsolidations[%d]", idx1) + } + } + } + { // Dynamic Field #38 'Builders' + binary.LittleEndian.PutUint32(dst[dstlen+2737253:], uint32(len(dst)-dstlen)) + t := t.Builders + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(Builder) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "Builders[%d]", idx1) + } + } + } + { // Dynamic Field #42 'BuilderPendingWithdrawals' + binary.LittleEndian.PutUint32(dst[dstlen+2741617:], uint32(len(dst)-dstlen)) + t := t.BuilderPendingWithdrawals + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(BuilderPendingWithdrawal) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "BuilderPendingWithdrawals[%d]", idx1) + } + } + } + { // Dynamic Field #43 'LatestExecutionPayloadBid' + binary.LittleEndian.PutUint32(dst[dstlen+2741621:], uint32(len(dst)-dstlen)) + t := t.LatestExecutionPayloadBid + if t == nil { + t = new(ExecutionPayloadBid) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "LatestExecutionPayloadBid") + } + } + { // Dynamic Field #44 'PayloadExpectedWithdrawals' + binary.LittleEndian.PutUint32(dst[dstlen+2741625:], uint32(len(dst)-dstlen)) + t := t.PayloadExpectedWithdrawals + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(capella.Withdrawal) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "PayloadExpectedWithdrawals[%d]", idx1) + } + } + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *BeaconState from SSZ-encoded bytes. +func (t *BeaconState) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 3134845 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 3134845) + } + { // Field #0 'GenesisTime' (static) + buf := buf[0:8] + t.GenesisTime = binary.LittleEndian.Uint64(buf) + } + { // Field #1 'GenesisValidatorsRoot' (static) + buf := buf[8:40] + copy(t.GenesisValidatorsRoot[:], buf) + } + { // Field #2 'Slot' (static) + buf := buf[40:48] + t.Slot = phase0.Slot(binary.LittleEndian.Uint64(buf)) + } + { // Field #3 'Fork' (static) + buf := buf[48:64] + if t.Fork == nil { + t.Fork = new(phase0.Fork) + } + if err = t.Fork.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Fork") + } + } + { // Field #4 'LatestBlockHeader' (static) + buf := buf[64:176] + if t.LatestBlockHeader == nil { + t.LatestBlockHeader = new(phase0.BeaconBlockHeader) + } + if err = t.LatestBlockHeader.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "LatestBlockHeader") + } + } + { // Field #5 'BlockRoots' (static) + buf := buf[176:262320] + val1 := t.BlockRoots + val1 = sszutils.ExpandSlice(val1, 8192) + sszutils.UnmarshalFixedBytesSlice(val1[:8192], buf) + t.BlockRoots = val1 + } + { // Field #6 'StateRoots' (static) + buf := buf[262320:524464] + val2 := t.StateRoots + val2 = sszutils.ExpandSlice(val2, 8192) + sszutils.UnmarshalFixedBytesSlice(val2[:8192], buf) + t.StateRoots = val2 + } + // Field #7 'HistoricalRoots' (offset) + offset7 := int(binary.LittleEndian.Uint32(buf[524464:524468])) + if offset7 != 3134845 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset7, 3134845), "HistoricalRoots:o") + } + { // Field #8 'ETH1Data' (static) + buf := buf[524468:524540] + if t.ETH1Data == nil { + t.ETH1Data = new(phase0.ETH1Data) + } + if err = t.ETH1Data.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "ETH1Data") + } + } + // Field #9 'ETH1DataVotes' (offset) + offset9 := int(binary.LittleEndian.Uint32(buf[524540:524544])) + if offset9 < offset7 || offset9 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset9, offset7, buflen), "ETH1DataVotes:o") + } + { // Field #10 'ETH1DepositIndex' (static) + buf := buf[524544:524552] + t.ETH1DepositIndex = binary.LittleEndian.Uint64(buf) + } + // Field #11 'Validators' (offset) + offset11 := int(binary.LittleEndian.Uint32(buf[524552:524556])) + if offset11 < offset9 || offset11 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset11, offset9, buflen), "Validators:o") + } + // Field #12 'Balances' (offset) + offset12 := int(binary.LittleEndian.Uint32(buf[524556:524560])) + if offset12 < offset11 || offset12 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset12, offset11, buflen), "Balances:o") + } + { // Field #13 'RANDAOMixes' (static) + buf := buf[524560:2621712] + val3 := t.RANDAOMixes + val3 = sszutils.ExpandSlice(val3, 65536) + sszutils.UnmarshalFixedBytesSlice(val3[:65536], buf) + t.RANDAOMixes = val3 + } + { // Field #14 'Slashings' (static) + buf := buf[2621712:2687248] + val4 := t.Slashings + val4 = sszutils.ExpandSlice(val4, 8192) + sszutils.UnmarshalUint64Slice(val4[:8192], buf) + t.Slashings = val4 + } + // Field #15 'PreviousEpochParticipation' (offset) + offset15 := int(binary.LittleEndian.Uint32(buf[2687248:2687252])) + if offset15 < offset12 || offset15 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset15, offset12, buflen), "PreviousEpochParticipation:o") + } + // Field #16 'CurrentEpochParticipation' (offset) + offset16 := int(binary.LittleEndian.Uint32(buf[2687252:2687256])) + if offset16 < offset15 || offset16 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset16, offset15, buflen), "CurrentEpochParticipation:o") + } + { // Field #17 'JustificationBits' (static) + buf := buf[2687256:2687257] + t.JustificationBits = sszutils.ExpandSlice(t.JustificationBits, 1) + copy(t.JustificationBits[:], buf) + } + { // Field #18 'PreviousJustifiedCheckpoint' (static) + buf := buf[2687257:2687297] + if t.PreviousJustifiedCheckpoint == nil { + t.PreviousJustifiedCheckpoint = new(phase0.Checkpoint) + } + if err = t.PreviousJustifiedCheckpoint.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "PreviousJustifiedCheckpoint") + } + } + { // Field #19 'CurrentJustifiedCheckpoint' (static) + buf := buf[2687297:2687337] + if t.CurrentJustifiedCheckpoint == nil { + t.CurrentJustifiedCheckpoint = new(phase0.Checkpoint) + } + if err = t.CurrentJustifiedCheckpoint.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "CurrentJustifiedCheckpoint") + } + } + { // Field #20 'FinalizedCheckpoint' (static) + buf := buf[2687337:2687377] + if t.FinalizedCheckpoint == nil { + t.FinalizedCheckpoint = new(phase0.Checkpoint) + } + if err = t.FinalizedCheckpoint.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "FinalizedCheckpoint") + } + } + // Field #21 'InactivityScores' (offset) + offset21 := int(binary.LittleEndian.Uint32(buf[2687377:2687381])) + if offset21 < offset16 || offset21 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset21, offset16, buflen), "InactivityScores:o") + } + { // Field #22 'CurrentSyncCommittee' (static) + buf := buf[2687381:2712005] + if t.CurrentSyncCommittee == nil { + t.CurrentSyncCommittee = new(altair.SyncCommittee) + } + if err = t.CurrentSyncCommittee.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "CurrentSyncCommittee") + } + } + { // Field #23 'NextSyncCommittee' (static) + buf := buf[2712005:2736629] + if t.NextSyncCommittee == nil { + t.NextSyncCommittee = new(altair.SyncCommittee) + } + if err = t.NextSyncCommittee.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "NextSyncCommittee") + } + } + { // Field #24 'LatestBlockHash' (static) + buf := buf[2736629:2736661] + copy(t.LatestBlockHash[:], buf) + } + { // Field #25 'NextWithdrawalIndex' (static) + buf := buf[2736661:2736669] + t.NextWithdrawalIndex = capella.WithdrawalIndex(binary.LittleEndian.Uint64(buf)) + } + { // Field #26 'NextWithdrawalValidatorIndex' (static) + buf := buf[2736669:2736677] + t.NextWithdrawalValidatorIndex = phase0.ValidatorIndex(binary.LittleEndian.Uint64(buf)) + } + // Field #27 'HistoricalSummaries' (offset) + offset27 := int(binary.LittleEndian.Uint32(buf[2736677:2736681])) + if offset27 < offset21 || offset27 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset27, offset21, buflen), "HistoricalSummaries:o") + } + { // Field #28 'DepositRequestsStartIndex' (static) + buf := buf[2736681:2736689] + t.DepositRequestsStartIndex = binary.LittleEndian.Uint64(buf) + } + { // Field #29 'DepositBalanceToConsume' (static) + buf := buf[2736689:2736697] + t.DepositBalanceToConsume = phase0.Gwei(binary.LittleEndian.Uint64(buf)) + } + { // Field #30 'ExitBalanceToConsume' (static) + buf := buf[2736697:2736705] + t.ExitBalanceToConsume = phase0.Gwei(binary.LittleEndian.Uint64(buf)) + } + { // Field #31 'EarliestExitEpoch' (static) + buf := buf[2736705:2736713] + t.EarliestExitEpoch = phase0.Epoch(binary.LittleEndian.Uint64(buf)) + } + { // Field #32 'ConsolidationBalanceToConsume' (static) + buf := buf[2736713:2736721] + t.ConsolidationBalanceToConsume = phase0.Gwei(binary.LittleEndian.Uint64(buf)) + } + { // Field #33 'EarliestConsolidationEpoch' (static) + buf := buf[2736721:2736729] + t.EarliestConsolidationEpoch = phase0.Epoch(binary.LittleEndian.Uint64(buf)) + } + // Field #34 'PendingDeposits' (offset) + offset34 := int(binary.LittleEndian.Uint32(buf[2736729:2736733])) + if offset34 < offset27 || offset34 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset34, offset27, buflen), "PendingDeposits:o") + } + // Field #35 'PendingPartialWithdrawals' (offset) + offset35 := int(binary.LittleEndian.Uint32(buf[2736733:2736737])) + if offset35 < offset34 || offset35 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset35, offset34, buflen), "PendingPartialWithdrawals:o") + } + // Field #36 'PendingConsolidations' (offset) + offset36 := int(binary.LittleEndian.Uint32(buf[2736737:2736741])) + if offset36 < offset35 || offset36 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset36, offset35, buflen), "PendingConsolidations:o") + } + { // Field #37 'ProposerLookahead' (static) + buf := buf[2736741:2737253] + val5 := t.ProposerLookahead + val5 = sszutils.ExpandSlice(val5, 64) + sszutils.UnmarshalUint64Slice(val5[:64], buf) + t.ProposerLookahead = val5 + } + // Field #38 'Builders' (offset) + offset38 := int(binary.LittleEndian.Uint32(buf[2737253:2737257])) + if offset38 < offset36 || offset38 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset38, offset36, buflen), "Builders:o") + } + { // Field #39 'NextWithdrawalBuilderIndex' (static) + buf := buf[2737257:2737265] + t.NextWithdrawalBuilderIndex = BuilderIndex(binary.LittleEndian.Uint64(buf)) + } + { // Field #40 'ExecutionPayloadAvailability' (static) + buf := buf[2737265:2738289] + t.ExecutionPayloadAvailability = sszutils.ExpandSlice(t.ExecutionPayloadAvailability, 1024) + copy(t.ExecutionPayloadAvailability[:], buf) + } + { // Field #41 'BuilderPendingPayments' (static) + buf := buf[2738289:2741617] + val6 := t.BuilderPendingPayments + val6 = sszutils.ExpandSlice(val6, 64) + for idx1 := range 64 { + if val6[idx1] == nil { + val6[idx1] = new(BuilderPendingPayment) + } + buf := buf[52*idx1 : 52*(idx1+1)] + if err = val6[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "BuilderPendingPayments[%d]", idx1) + } + } + t.BuilderPendingPayments = val6 + } + // Field #42 'BuilderPendingWithdrawals' (offset) + offset42 := int(binary.LittleEndian.Uint32(buf[2741617:2741621])) + if offset42 < offset38 || offset42 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset42, offset38, buflen), "BuilderPendingWithdrawals:o") + } + // Field #43 'LatestExecutionPayloadBid' (offset) + offset43 := int(binary.LittleEndian.Uint32(buf[2741621:2741625])) + if offset43 < offset42 || offset43 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset43, offset42, buflen), "LatestExecutionPayloadBid:o") + } + // Field #44 'PayloadExpectedWithdrawals' (offset) + offset44 := int(binary.LittleEndian.Uint32(buf[2741625:2741629])) + if offset44 < offset43 || offset44 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset44, offset43, buflen), "PayloadExpectedWithdrawals:o") + } + { // Field #45 'PTCWindow' (static) + buf := buf[2741629:3134845] + val7 := t.PTCWindow + val7 = sszutils.ExpandSlice(val7, 96) + for idx1 := range 96 { + val8 := val7[idx1] + buf := buf[4096*idx1 : 4096*(idx1+1)] + val8 = sszutils.ExpandSlice(val8, 512) + sszutils.UnmarshalUint64Slice(val8[:512], buf) + val7[idx1] = val8 + } + t.PTCWindow = val7 + } + { // Field #7 'HistoricalRoots' (dynamic) + buf := buf[offset7:offset9] + val9 := t.HistoricalRoots + itemCount := len(buf) / 32 + if len(buf)%32 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 32), "HistoricalRoots") + } + if itemCount > 16777216 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(itemCount, 16777216), "HistoricalRoots") + } + val9 = sszutils.ExpandSlice(val9, itemCount) + sszutils.UnmarshalFixedBytesSlice(val9[:itemCount], buf) + t.HistoricalRoots = val9 + } + { // Field #9 'ETH1DataVotes' (dynamic) + buf := buf[offset9:offset11] + val10 := t.ETH1DataVotes + itemCount := len(buf) / 72 + if len(buf)%72 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 72), "ETH1DataVotes") + } + if itemCount > 2048 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(itemCount, 2048), "ETH1DataVotes") + } + val10 = sszutils.ExpandSlice(val10, itemCount) + for idx1 := range itemCount { + if val10[idx1] == nil { + val10[idx1] = new(phase0.ETH1Data) + } + buf := buf[72*idx1 : 72*(idx1+1)] + if err = val10[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "ETH1DataVotes[%d]", idx1) + } + } + t.ETH1DataVotes = val10 + } + { // Field #11 'Validators' (dynamic) + buf := buf[offset11:offset12] + val11 := t.Validators + itemCount := len(buf) / 121 + if len(buf)%121 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 121), "Validators") + } + val11 = sszutils.ExpandSlice(val11, itemCount) + for idx1 := range itemCount { + if val11[idx1] == nil { + val11[idx1] = new(phase0.Validator) + } + buf := buf[121*idx1 : 121*(idx1+1)] + if err = val11[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "Validators[%d]", idx1) + } + } + t.Validators = val11 + } + { // Field #12 'Balances' (dynamic) + buf := buf[offset12:offset15] + val12 := t.Balances + itemCount := len(buf) / 8 + if len(buf)%8 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 8), "Balances") + } + val12 = sszutils.ExpandSlice(val12, itemCount) + sszutils.UnmarshalUint64Slice(val12, buf) + t.Balances = val12 + } + { // Field #15 'PreviousEpochParticipation' (dynamic) + buf := buf[offset15:offset16] + val13 := t.PreviousEpochParticipation + itemCount := len(buf) + val13 = sszutils.ExpandSlice(val13, itemCount) + for idx1 := range itemCount { + buf := buf[1*idx1 : 1*(idx1+1)] + val13[idx1] = altair.ParticipationFlags(buf[0]) + } + t.PreviousEpochParticipation = val13 + } + { // Field #16 'CurrentEpochParticipation' (dynamic) + buf := buf[offset16:offset21] + val14 := t.CurrentEpochParticipation + itemCount := len(buf) + val14 = sszutils.ExpandSlice(val14, itemCount) + for idx1 := range itemCount { + buf := buf[1*idx1 : 1*(idx1+1)] + val14[idx1] = altair.ParticipationFlags(buf[0]) + } + t.CurrentEpochParticipation = val14 + } + { // Field #21 'InactivityScores' (dynamic) + buf := buf[offset21:offset27] + val15 := t.InactivityScores + itemCount := len(buf) / 8 + if len(buf)%8 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 8), "InactivityScores") + } + val15 = sszutils.ExpandSlice(val15, itemCount) + sszutils.UnmarshalUint64Slice(val15, buf) + t.InactivityScores = val15 + } + { // Field #27 'HistoricalSummaries' (dynamic) + buf := buf[offset27:offset34] + val16 := t.HistoricalSummaries + itemCount := len(buf) / 64 + if len(buf)%64 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 64), "HistoricalSummaries") + } + if itemCount > 16777216 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(itemCount, 16777216), "HistoricalSummaries") + } + val16 = sszutils.ExpandSlice(val16, itemCount) + for idx1 := range itemCount { + if val16[idx1] == nil { + val16[idx1] = new(capella.HistoricalSummary) + } + buf := buf[64*idx1 : 64*(idx1+1)] + if err = val16[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "HistoricalSummaries[%d]", idx1) + } + } + t.HistoricalSummaries = val16 + } + { // Field #34 'PendingDeposits' (dynamic) + buf := buf[offset34:offset35] + val17 := t.PendingDeposits + itemCount := len(buf) / 192 + if len(buf)%192 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 192), "PendingDeposits") + } + val17 = sszutils.ExpandSlice(val17, itemCount) + for idx1 := range itemCount { + if val17[idx1] == nil { + val17[idx1] = new(electra.PendingDeposit) + } + buf := buf[192*idx1 : 192*(idx1+1)] + if err = val17[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "PendingDeposits[%d]", idx1) + } + } + t.PendingDeposits = val17 + } + { // Field #35 'PendingPartialWithdrawals' (dynamic) + buf := buf[offset35:offset36] + val18 := t.PendingPartialWithdrawals + itemCount := len(buf) / 24 + if len(buf)%24 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 24), "PendingPartialWithdrawals") + } + val18 = sszutils.ExpandSlice(val18, itemCount) + for idx1 := range itemCount { + if val18[idx1] == nil { + val18[idx1] = new(electra.PendingPartialWithdrawal) + } + buf := buf[24*idx1 : 24*(idx1+1)] + if err = val18[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "PendingPartialWithdrawals[%d]", idx1) + } + } + t.PendingPartialWithdrawals = val18 + } + { // Field #36 'PendingConsolidations' (dynamic) + buf := buf[offset36:offset38] + val19 := t.PendingConsolidations + itemCount := len(buf) / 16 + if len(buf)%16 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 16), "PendingConsolidations") + } + val19 = sszutils.ExpandSlice(val19, itemCount) + for idx1 := range itemCount { + if val19[idx1] == nil { + val19[idx1] = new(electra.PendingConsolidation) + } + buf := buf[16*idx1 : 16*(idx1+1)] + if err = val19[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "PendingConsolidations[%d]", idx1) + } + } + t.PendingConsolidations = val19 + } + { // Field #38 'Builders' (dynamic) + buf := buf[offset38:offset42] + val20 := t.Builders + itemCount := len(buf) / 93 + if len(buf)%93 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 93), "Builders") + } + val20 = sszutils.ExpandSlice(val20, itemCount) + for idx1 := range itemCount { + if val20[idx1] == nil { + val20[idx1] = new(Builder) + } + buf := buf[93*idx1 : 93*(idx1+1)] + if err = val20[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "Builders[%d]", idx1) + } + } + t.Builders = val20 + } + { // Field #42 'BuilderPendingWithdrawals' (dynamic) + buf := buf[offset42:offset43] + val21 := t.BuilderPendingWithdrawals + itemCount := len(buf) / 36 + if len(buf)%36 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 36), "BuilderPendingWithdrawals") + } + val21 = sszutils.ExpandSlice(val21, itemCount) + for idx1 := range itemCount { + if val21[idx1] == nil { + val21[idx1] = new(BuilderPendingWithdrawal) + } + buf := buf[36*idx1 : 36*(idx1+1)] + if err = val21[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "BuilderPendingWithdrawals[%d]", idx1) + } + } + t.BuilderPendingWithdrawals = val21 + } + { // Field #43 'LatestExecutionPayloadBid' (dynamic) + buf := buf[offset43:offset44] + if t.LatestExecutionPayloadBid == nil { + t.LatestExecutionPayloadBid = new(ExecutionPayloadBid) + } + if err = t.LatestExecutionPayloadBid.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "LatestExecutionPayloadBid") + } + } + { // Field #44 'PayloadExpectedWithdrawals' (dynamic) + buf := buf[offset44:] + val22 := t.PayloadExpectedWithdrawals + itemCount := len(buf) / 44 + if len(buf)%44 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 44), "PayloadExpectedWithdrawals") + } + val22 = sszutils.ExpandSlice(val22, itemCount) + for idx1 := range itemCount { + if val22[idx1] == nil { + val22[idx1] = new(capella.Withdrawal) + } + buf := buf[44*idx1 : 44*(idx1+1)] + if err = val22[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "PayloadExpectedWithdrawals[%d]", idx1) + } + } + t.PayloadExpectedWithdrawals = val22 + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *BeaconState. +func (t *BeaconState) SizeSSZ() (size int) { + if t == nil { + t = new(BeaconState) + } + // Field #0 'GenesisTime' static (8 bytes) + // Field #1 'GenesisValidatorsRoot' static (32 bytes) + // Field #2 'Slot' static (8 bytes) + // Field #3 'Fork' static (16 bytes) + // Field #4 'LatestBlockHeader' static (112 bytes) + // Field #5 'BlockRoots' static (262144 bytes) + // Field #6 'StateRoots' static (262144 bytes) + // Field #7 'HistoricalRoots' offset (4 bytes) + // Field #8 'ETH1Data' static (72 bytes) + // Field #9 'ETH1DataVotes' offset (4 bytes) + // Field #10 'ETH1DepositIndex' static (8 bytes) + // Field #11 'Validators' offset (4 bytes) + // Field #12 'Balances' offset (4 bytes) + // Field #13 'RANDAOMixes' static (2097152 bytes) + // Field #14 'Slashings' static (65536 bytes) + // Field #15 'PreviousEpochParticipation' offset (4 bytes) + // Field #16 'CurrentEpochParticipation' offset (4 bytes) + // Field #17 'JustificationBits' static (1 bytes) + // Field #18 'PreviousJustifiedCheckpoint' static (40 bytes) + // Field #19 'CurrentJustifiedCheckpoint' static (40 bytes) + // Field #20 'FinalizedCheckpoint' static (40 bytes) + // Field #21 'InactivityScores' offset (4 bytes) + // Field #22 'CurrentSyncCommittee' static (24624 bytes) + // Field #23 'NextSyncCommittee' static (24624 bytes) + // Field #24 'LatestBlockHash' static (32 bytes) + // Field #25 'NextWithdrawalIndex' static (8 bytes) + // Field #26 'NextWithdrawalValidatorIndex' static (8 bytes) + // Field #27 'HistoricalSummaries' offset (4 bytes) + // Field #28 'DepositRequestsStartIndex' static (8 bytes) + // Field #29 'DepositBalanceToConsume' static (8 bytes) + // Field #30 'ExitBalanceToConsume' static (8 bytes) + // Field #31 'EarliestExitEpoch' static (8 bytes) + // Field #32 'ConsolidationBalanceToConsume' static (8 bytes) + // Field #33 'EarliestConsolidationEpoch' static (8 bytes) + // Field #34 'PendingDeposits' offset (4 bytes) + // Field #35 'PendingPartialWithdrawals' offset (4 bytes) + // Field #36 'PendingConsolidations' offset (4 bytes) + // Field #37 'ProposerLookahead' static (512 bytes) + // Field #38 'Builders' offset (4 bytes) + // Field #39 'NextWithdrawalBuilderIndex' static (8 bytes) + // Field #40 'ExecutionPayloadAvailability' static (1024 bytes) + // Field #41 'BuilderPendingPayments' static (3328 bytes) + // Field #42 'BuilderPendingWithdrawals' offset (4 bytes) + // Field #43 'LatestExecutionPayloadBid' offset (4 bytes) + // Field #44 'PayloadExpectedWithdrawals' offset (4 bytes) + // Field #45 'PTCWindow' static (393216 bytes) + size += 3134845 + { // Dynamic field #7 'HistoricalRoots' + size += len(t.HistoricalRoots) * 32 + } + { // Dynamic field #9 'ETH1DataVotes' + size += len(t.ETH1DataVotes) * 72 + } + { // Dynamic field #11 'Validators' + size += len(t.Validators) * 121 + } + { // Dynamic field #12 'Balances' + size += len(t.Balances) * 8 + } + { // Dynamic field #15 'PreviousEpochParticipation' + size += len(t.PreviousEpochParticipation) + } + { // Dynamic field #16 'CurrentEpochParticipation' + size += len(t.CurrentEpochParticipation) + } + { // Dynamic field #21 'InactivityScores' + size += len(t.InactivityScores) * 8 + } + { // Dynamic field #27 'HistoricalSummaries' + size += len(t.HistoricalSummaries) * 64 + } + { // Dynamic field #34 'PendingDeposits' + size += len(t.PendingDeposits) * 192 + } + { // Dynamic field #35 'PendingPartialWithdrawals' + size += len(t.PendingPartialWithdrawals) * 24 + } + { // Dynamic field #36 'PendingConsolidations' + size += len(t.PendingConsolidations) * 16 + } + { // Dynamic field #38 'Builders' + size += len(t.Builders) * 93 + } + { // Dynamic field #42 'BuilderPendingWithdrawals' + size += len(t.BuilderPendingWithdrawals) * 36 + } + { // Dynamic field #43 'LatestExecutionPayloadBid' + size += t.LatestExecutionPayloadBid.SizeSSZ() + } + { // Dynamic field #44 'PayloadExpectedWithdrawals' + size += len(t.PayloadExpectedWithdrawals) * 44 + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *BeaconState. +func (t *BeaconState) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *BeaconState using the given hash walker. +func (t *BeaconState) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(BeaconState) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'GenesisTime' + hh.PutUint64(t.GenesisTime) + } + { // Field #1 'GenesisValidatorsRoot' + hh.PutBytes(t.GenesisValidatorsRoot[:32]) + } + { // Field #2 'Slot' + hh.PutUint64(uint64(t.Slot)) + } + { // Field #3 'Fork' + t := t.Fork + if t == nil { + t = new(phase0.Fork) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Fork") + } + } + { // Field #4 'LatestBlockHeader' + t := t.LatestBlockHeader + if t == nil { + t = new(phase0.BeaconBlockHeader) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "LatestBlockHeader") + } + } + { // Field #5 'BlockRoots' + t := t.BlockRoots + vlen := len(t) + if vlen > 8192 { + return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 8192), "BlockRoots") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + var val1 *phase0.Root + for idx1 := range 8192 { + if idx1 < vlen { + val1 = &t[idx1] + } else if idx1 == vlen { + val1 = new(phase0.Root) + } + hh.PutBytes(val1[:32]) + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.Merkleize(idx) + } + { // Field #6 'StateRoots' + t := t.StateRoots + vlen := len(t) + if vlen > 8192 { + return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 8192), "StateRoots") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + var val2 *phase0.Root + for idx1 := range 8192 { + if idx1 < vlen { + val2 = &t[idx1] + } else if idx1 == vlen { + val2 = new(phase0.Root) + } + hh.PutBytes(val2[:32]) + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.Merkleize(idx) + } + { // Field #7 'HistoricalRoots' + t := t.HistoricalRoots + vlen := uint64(len(t)) + if vlen > 16777216 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 16777216), "HistoricalRoots") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + for idx1 := range int(vlen) { + hh.PutBytes(t[idx1][:32]) + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(16777216, vlen, 32)) + } + { // Field #8 'ETH1Data' + t := t.ETH1Data + if t == nil { + t = new(phase0.ETH1Data) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "ETH1Data") + } + } + { // Field #9 'ETH1DataVotes' + t := t.ETH1DataVotes + vlen := uint64(len(t)) + if vlen > 2048 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 2048), "ETH1DataVotes") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(phase0.ETH1Data) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "ETH1DataVotes[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(2048, vlen, 32)) + } + { // Field #10 'ETH1DepositIndex' + hh.PutUint64(t.ETH1DepositIndex) + } + { // Field #11 'Validators' + t := t.Validators + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(phase0.Validator) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "Validators[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #12 'Balances' + t := t.Balances + idx := hh.StartTree(sszutils.TreeTypeProgressive) + sszutils.HashUint64Slice(hh, t) + hh.FillUpTo32() + vlen := uint64(len(t)) + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #13 'RANDAOMixes' + t := t.RANDAOMixes + vlen := len(t) + if vlen > 65536 { + return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 65536), "RANDAOMixes") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + var val3 *phase0.Root + for idx1 := range 65536 { + if idx1 < vlen { + val3 = &t[idx1] + } else if idx1 == vlen { + val3 = new(phase0.Root) + } + hh.PutBytes(val3[:32]) + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.Merkleize(idx) + } + { // Field #14 'Slashings' + t := t.Slashings + vlen := len(t) + if vlen > 8192 { + return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 8192), "Slashings") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + var val4, val4Empty phase0.Gwei + for idx1 := range 8192 { + if idx1 < vlen { + val4 = t[idx1] + } else if idx1 == vlen { + val4 = val4Empty + } + hh.AppendUint64(uint64(val4)) + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.FillUpTo32() + hh.Merkleize(idx) + } + { // Field #15 'PreviousEpochParticipation' + t := t.PreviousEpochParticipation + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + hh.AppendUint8(uint8(t[idx1])) + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.FillUpTo32() + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #16 'CurrentEpochParticipation' + t := t.CurrentEpochParticipation + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + hh.AppendUint8(uint8(t[idx1])) + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.FillUpTo32() + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #17 'JustificationBits' + vlen := len(t.JustificationBits) + if vlen > 1 { + return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 1), "JustificationBits") + } + val := t.JustificationBits[:] + if vlen < 1 { + val = sszutils.AppendZeroPadding(val, (1-vlen)*1) + } + hh.PutBytes(val[:1]) + } + { // Field #18 'PreviousJustifiedCheckpoint' + t := t.PreviousJustifiedCheckpoint + if t == nil { + t = new(phase0.Checkpoint) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "PreviousJustifiedCheckpoint") + } + } + { // Field #19 'CurrentJustifiedCheckpoint' + t := t.CurrentJustifiedCheckpoint + if t == nil { + t = new(phase0.Checkpoint) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "CurrentJustifiedCheckpoint") + } + } + { // Field #20 'FinalizedCheckpoint' + t := t.FinalizedCheckpoint + if t == nil { + t = new(phase0.Checkpoint) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "FinalizedCheckpoint") + } + } + { // Field #21 'InactivityScores' + t := t.InactivityScores + idx := hh.StartTree(sszutils.TreeTypeProgressive) + sszutils.HashUint64Slice(hh, t) + hh.FillUpTo32() + vlen := uint64(len(t)) + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #22 'CurrentSyncCommittee' + t := t.CurrentSyncCommittee + if t == nil { + t = new(altair.SyncCommittee) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "CurrentSyncCommittee") + } + } + { // Field #23 'NextSyncCommittee' + t := t.NextSyncCommittee + if t == nil { + t = new(altair.SyncCommittee) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "NextSyncCommittee") + } + } + { // Field #24 'LatestBlockHash' + hh.PutBytes(t.LatestBlockHash[:32]) + } + { // Field #25 'NextWithdrawalIndex' + hh.PutUint64(uint64(t.NextWithdrawalIndex)) + } + { // Field #26 'NextWithdrawalValidatorIndex' + hh.PutUint64(uint64(t.NextWithdrawalValidatorIndex)) + } + { // Field #27 'HistoricalSummaries' + t := t.HistoricalSummaries + vlen := uint64(len(t)) + if vlen > 16777216 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 16777216), "HistoricalSummaries") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(capella.HistoricalSummary) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "HistoricalSummaries[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(16777216, vlen, 32)) + } + { // Field #28 'DepositRequestsStartIndex' + hh.PutUint64(t.DepositRequestsStartIndex) + } + { // Field #29 'DepositBalanceToConsume' + hh.PutUint64(uint64(t.DepositBalanceToConsume)) + } + { // Field #30 'ExitBalanceToConsume' + hh.PutUint64(uint64(t.ExitBalanceToConsume)) + } + { // Field #31 'EarliestExitEpoch' + hh.PutUint64(uint64(t.EarliestExitEpoch)) + } + { // Field #32 'ConsolidationBalanceToConsume' + hh.PutUint64(uint64(t.ConsolidationBalanceToConsume)) + } + { // Field #33 'EarliestConsolidationEpoch' + hh.PutUint64(uint64(t.EarliestConsolidationEpoch)) + } + { // Field #34 'PendingDeposits' + t := t.PendingDeposits + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(electra.PendingDeposit) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "PendingDeposits[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #35 'PendingPartialWithdrawals' + t := t.PendingPartialWithdrawals + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(electra.PendingPartialWithdrawal) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "PendingPartialWithdrawals[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #36 'PendingConsolidations' + t := t.PendingConsolidations + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(electra.PendingConsolidation) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "PendingConsolidations[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #37 'ProposerLookahead' + t := t.ProposerLookahead + vlen := len(t) + if vlen > 64 { + return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 64), "ProposerLookahead") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + var val5, val5Empty phase0.ValidatorIndex + for idx1 := range 64 { + if idx1 < vlen { + val5 = t[idx1] + } else if idx1 == vlen { + val5 = val5Empty + } + hh.AppendUint64(uint64(val5)) + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.FillUpTo32() + hh.Merkleize(idx) + } + { // Field #38 'Builders' + t := t.Builders + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(Builder) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "Builders[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #39 'NextWithdrawalBuilderIndex' + hh.PutUint64(uint64(t.NextWithdrawalBuilderIndex)) + } + { // Field #40 'ExecutionPayloadAvailability' + vlen := len(t.ExecutionPayloadAvailability) + if vlen > 1024 { + return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 1024), "ExecutionPayloadAvailability") + } + val := t.ExecutionPayloadAvailability[:] + if vlen < 1024 { + val = sszutils.AppendZeroPadding(val, (1024-vlen)*1) + } + hh.PutBytes(val[:1024]) + } + { // Field #41 'BuilderPendingPayments' + t := t.BuilderPendingPayments + vlen := len(t) + if vlen > 64 { + return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 64), "BuilderPendingPayments") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + var val6 *BuilderPendingPayment + for idx1 := range 64 { + if idx1 < vlen { + val6 = t[idx1] + } else if idx1 == vlen { + val6 = new(BuilderPendingPayment) + } + if val6 == nil { + val6 = new(BuilderPendingPayment) + } + if err := val6.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "BuilderPendingPayments[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.Merkleize(idx) + } + { // Field #42 'BuilderPendingWithdrawals' + t := t.BuilderPendingWithdrawals + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(BuilderPendingWithdrawal) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "BuilderPendingWithdrawals[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #43 'LatestExecutionPayloadBid' + t := t.LatestExecutionPayloadBid + if t == nil { + t = new(ExecutionPayloadBid) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "LatestExecutionPayloadBid") + } + } + { // Field #44 'PayloadExpectedWithdrawals' + t := t.PayloadExpectedWithdrawals + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(capella.Withdrawal) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "PayloadExpectedWithdrawals[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #45 'PTCWindow' + t := t.PTCWindow + vlen := len(t) + if vlen > 96 { + return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 96), "PTCWindow") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + var val7, val7Empty []phase0.ValidatorIndex + for idx1 := range 96 { + if idx1 < vlen { + val7 = t[idx1] + } else if idx1 == vlen { + val7 = val7Empty + } + vlen := len(val7) + if vlen > 512 { + return sszutils.ErrorWithPathf(sszutils.ErrVectorLengthFn(vlen, 512), "PTCWindow[%d]", idx1) + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + var val8, val8Empty phase0.ValidatorIndex + for idx2 := range 512 { + if idx2 < vlen { + val8 = val7[idx2] + } else if idx2 == vlen { + val8 = val8Empty + } + hh.AppendUint64(uint64(val8)) + if (idx2+1)%256 == 0 { + hh.Collapse() + } + } + hh.FillUpTo32() + hh.Merkleize(idx) + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.Merkleize(idx) + } + hh.MerkleizeProgressiveWithActiveFields(idx, []byte{0xff, 0xff, 0xff, 0xff, 0xff, 0x3f}) + return nil +} diff --git a/spec/gloas/beaconstate_test.go b/spec/gloas/beaconstate_test.go new file mode 100644 index 00000000..526a615c --- /dev/null +++ b/spec/gloas/beaconstate_test.go @@ -0,0 +1,331 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas_test + +import ( + "encoding/json" + "regexp" + "testing" + + bitfield "github.com/OffchainLabs/go-bitfield" + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + require "github.com/stretchr/testify/require" +) + +// populatedBeaconState returns a state with every one of BeaconState's 46 fields +// carrying a distinctive non-zero value, which is what makes a round-trip an audit +// rather than a formality: a field left at its zero value hides a codec that +// mishandles it, since an absent or empty value round-trips clean either way. +// +// Vectors carry two entries rather than their spec length. Nothing in the JSON +// codec checks a vector's width, so a spec-sized 8192 block_roots would inflate +// the fixture without testing anything more. +func populatedBeaconState() *gloas.BeaconState { + state := &gloas.BeaconState{} + + state.GenesisTime = 1 + state.GenesisValidatorsRoot = phase0.Root{0x01} + state.Slot = 2 + state.Fork = &phase0.Fork{ + PreviousVersion: phase0.Version{0x02}, + CurrentVersion: phase0.Version{0x03}, + Epoch: 3, + } + state.LatestBlockHeader = &phase0.BeaconBlockHeader{ + Slot: 4, + ProposerIndex: 5, + ParentRoot: phase0.Root{0x04}, + StateRoot: phase0.Root{0x05}, + BodyRoot: phase0.Root{0x06}, + } + state.BlockRoots = []phase0.Root{{0x07}, {0x08}} + state.StateRoots = []phase0.Root{{0x09}, {0x0a}} + state.HistoricalRoots = []phase0.Root{{0x0b}, {0x0c}} + state.ETH1Data = &phase0.ETH1Data{ + DepositRoot: phase0.Root{0x0d}, + DepositCount: 6, + BlockHash: make([]byte, phase0.Hash32Length), + } + state.ETH1DataVotes = []*phase0.ETH1Data{{ + DepositRoot: phase0.Root{0x0e}, + DepositCount: 7, + BlockHash: make([]byte, phase0.Hash32Length), + }} + state.ETH1DepositIndex = 8 + state.Validators = []*phase0.Validator{{ + PublicKey: phase0.BLSPubKey{0x0f}, + WithdrawalCredentials: make([]byte, 32), + EffectiveBalance: 9, + ActivationEligibilityEpoch: 10, + ActivationEpoch: 11, + ExitEpoch: 12, + WithdrawableEpoch: 13, + }} + state.Balances = []phase0.Gwei{14, 15} + state.RANDAOMixes = []phase0.Root{{0x10}, {0x11}} + state.Slashings = []phase0.Gwei{16, 17} + state.PreviousEpochParticipation = []altair.ParticipationFlags{1, 2} + state.CurrentEpochParticipation = []altair.ParticipationFlags{3, 4} + state.JustificationBits = bitfield.Bitvector4{0x0b} + state.PreviousJustifiedCheckpoint = &phase0.Checkpoint{Epoch: 18, Root: phase0.Root{0x12}} + state.CurrentJustifiedCheckpoint = &phase0.Checkpoint{Epoch: 19, Root: phase0.Root{0x13}} + state.FinalizedCheckpoint = &phase0.Checkpoint{Epoch: 20, Root: phase0.Root{0x14}} + state.InactivityScores = []uint64{21, 22} + state.CurrentSyncCommittee = &altair.SyncCommittee{ + Pubkeys: []phase0.BLSPubKey{{0x15}}, + AggregatePubkey: phase0.BLSPubKey{0x16}, + } + state.NextSyncCommittee = &altair.SyncCommittee{ + Pubkeys: []phase0.BLSPubKey{{0x17}}, + AggregatePubkey: phase0.BLSPubKey{0x18}, + } + state.LatestBlockHash = phase0.Hash32{0x19} + state.NextWithdrawalIndex = 23 + state.NextWithdrawalValidatorIndex = 24 + state.HistoricalSummaries = []*capella.HistoricalSummary{{ + BlockSummaryRoot: phase0.Root{0x1a}, + StateSummaryRoot: phase0.Root{0x1b}, + }} + state.DepositRequestsStartIndex = 25 + state.DepositBalanceToConsume = 26 + state.ExitBalanceToConsume = 27 + state.EarliestExitEpoch = 28 + state.ConsolidationBalanceToConsume = 29 + state.EarliestConsolidationEpoch = 30 + state.PendingDeposits = []*electra.PendingDeposit{{ + Pubkey: phase0.BLSPubKey{0x1c}, + WithdrawalCredentials: make([]byte, 32), + Amount: 31, + Signature: phase0.BLSSignature{0x1d}, + Slot: 32, + }} + state.PendingPartialWithdrawals = []*electra.PendingPartialWithdrawal{{ + ValidatorIndex: 33, + Amount: 34, + WithdrawableEpoch: 35, + }} + state.PendingConsolidations = []*electra.PendingConsolidation{{ + SourceIndex: 36, + TargetIndex: 37, + }} + state.ProposerLookahead = []phase0.ValidatorIndex{38, 39} + state.Builders = []*gloas.Builder{{ + PublicKey: phase0.BLSPubKey{0x1e}, + Version: 1, + ExecutionAddress: bellatrix.ExecutionAddress{0x1f}, + Balance: 40, + DepositEpoch: 41, + WithdrawableEpoch: 42, + }} + state.NextWithdrawalBuilderIndex = 43 + state.ExecutionPayloadAvailability = []uint8{0xbf, 0xfb, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff} + state.BuilderPendingPayments = []*gloas.BuilderPendingPayment{{ + Weight: 44, + Withdrawal: &gloas.BuilderPendingWithdrawal{ + FeeRecipient: bellatrix.ExecutionAddress{0x20}, + Amount: 45, + BuilderIndex: 46, + }, + ProposerIndex: 47, + }} + state.BuilderPendingWithdrawals = []*gloas.BuilderPendingWithdrawal{{ + FeeRecipient: bellatrix.ExecutionAddress{0x21}, + Amount: 48, + BuilderIndex: 49, + }} + state.LatestExecutionPayloadBid = &gloas.ExecutionPayloadBid{ + ParentBlockHash: phase0.Hash32{0x22}, + ParentBlockRoot: phase0.Root{0x23}, + BlockHash: phase0.Hash32{0x24}, + FeeRecipient: bellatrix.ExecutionAddress{0x25}, + GasLimit: 50, + BuilderIndex: 51, + Slot: 52, + Value: 53, + PrevRandao: phase0.Root{0x26}, + ExecutionPayment: 57, + BlobKZGCommitments: []deneb.KZGCommitment{{0x28}}, + ExecutionRequestsRoot: phase0.Root{0x29}, + } + state.PayloadExpectedWithdrawals = []*capella.Withdrawal{{ + Index: 54, + ValidatorIndex: 55, + Address: bellatrix.ExecutionAddress{0x27}, + Amount: 56, + }} + state.PTCWindow = [][]phase0.ValidatorIndex{{57, 58}, {59, 60}} + + return state +} + +// rawField returns the verbatim JSON value the given key carries in input, +// which is what lets a test assert a field's wire shape and not merely its value. +func rawField(t *testing.T, input, key string) string { + t.Helper() + + var doc map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(input), &doc)) + require.Contains(t, doc, key) + + return string(doc[key]) +} + +// TestBeaconStateExecutionPayloadAvailabilityJSON verifies that +// execution_payload_availability is carried as a single 0x-prefixed hex string in +// both directions. beacon-APIs types/primitive.yaml defines Bitvector as +// {type: string, format: hex, pattern "^0x[a-fA-F0-9]{2,}$"}, and the consensus +// spec types this field Bitvector[SLOTS_PER_HISTORICAL_ROOT]. +// +// Pinning the marshalled field against the literal a node actually sent is what +// makes decoding our own output equivalent to decoding the node's. Without that +// anchor a codec wrong the same way in both directions would satisfy the decode +// half of this test. +func TestBeaconStateExecutionPayloadAvailabilityJSON(t *testing.T) { + // The value observed from a live Gloas node, whose minimal preset makes + // SLOTS_PER_HISTORICAL_ROOT 64 and so the bitvector 8 bytes wide. + const availability = `"0xbffbffffffffffff"` + + state := populatedBeaconState() + input := mustMarshal(t, state) + require.Equal(t, availability, rawField(t, input, "execution_payload_availability")) + + var decoded gloas.BeaconState + require.NoError(t, json.Unmarshal([]byte(input), &decoded)) + require.Equal(t, state.ExecutionPayloadAvailability, decoded.ExecutionPayloadAvailability) +} + +// TestBeaconStateExecutionPayloadAvailabilityYAML verifies that the bitvector +// survives a YAML round trip. UnmarshalYAML deliberately routes through +// beaconStateJSON rather than maintaining a second unmarshaler, so the JSON struct's +// field types govern the YAML read path too. That coupling is what made this defect +// break YAML as well: beaconstate_yaml.go already emitted the field as one hex +// string, which then met a []string and failed with "string was used where sequence +// is expected". +// +// Whole states are not compared because other fields have their own YAML gaps, +// tracked separately. +func TestBeaconStateExecutionPayloadAvailabilityYAML(t *testing.T) { + state := populatedBeaconState() + + data, err := state.MarshalYAML() + require.NoError(t, err) + + var decoded gloas.BeaconState + require.NoError(t, decoded.UnmarshalYAML(data)) + require.Equal(t, state.ExecutionPayloadAvailability, decoded.ExecutionPayloadAvailability) +} + +// TestBeaconStatePTCWindowJSONShapes verifies that both shapes a node may serve for +// ptc_window decode to the same window. +// +// The spec types the field Vector[Vector[ValidatorIndex, PTC_SIZE], (2 + +// MIN_SEED_LOOKAHEAD) * SLOTS_PER_EPOCH], whose JSON is nested arrays of decimal +// strings. Prysm instead wraps each inner vector in an object carrying +// validator_indices, because proto3 cannot express a nested repeated field without an +// intermediate message, so a Gloas state fetched from Prysm is rejected outright by a +// decoder that admits only the spec shape. +// +// Accepting the wrapper on the read path alone is deliberate: TestBeaconStateJSONWireShapes +// pins what we emit, so the deviation is tolerated where it arrives and never propagated. +func TestBeaconStatePTCWindowJSONShapes(t *testing.T) { + tests := []struct { + name string + ptcWindow string + err string + }{ + { + name: "Spec", + ptcWindow: `[["57","58"],["59","60"]]`, + }, + { + // Observed from prysm on a Gloas devnet. + name: "PrysmObjectWrapper", + ptcWindow: `[{"validator_indices":["57","58"]},{"validator_indices":["59","60"]}]`, + }, + { + // Neither shape: the spec shape's error is the one reported, so accepting + // a second shape does not cost the familiar diagnostic. + name: "NeitherShape", + ptcWindow: `{"validator_indices":["57"]}`, + err: "ptc_window: json: cannot unmarshal object into Go value of type [][]string", + }, + { + // The wrapper is a shape, not an escape from validation. + name: "PrysmObjectWrapperBadIndex", + ptcWindow: `[{"validator_indices":["fifty-seven"]}]`, + err: `ptc_window[0][0]: strconv.ParseUint: parsing "fifty-seven": invalid syntax`, + }, + } + + state := populatedBeaconState() + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var doc map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(mustMarshal(t, state)), &doc)) + doc["ptc_window"] = json.RawMessage(test.ptcWindow) + input, err := json.Marshal(doc) + require.NoError(t, err) + + var decoded gloas.BeaconState + err = json.Unmarshal(input, &decoded) + if test.err != "" { + require.EqualError(t, err, test.err) + + return + } + require.NoError(t, err) + require.Equal(t, [][]phase0.ValidatorIndex{{57, 58}, {59, 60}}, decoded.PTCWindow) + }) + } +} + +// TestBeaconStateJSONWireShapes pins the wire shape of the BeaconState fields +// whose SSZ type does not map onto JSON in the way their Go type suggests. These +// are the fields a round-trip cannot vouch for: a codec that is wrong the same +// way in both directions round-trips perfectly and still rejects a real node's +// JSON, so the shapes here are taken from the spec rather than from our output. +func TestBeaconStateJSONWireShapes(t *testing.T) { + doc := mustMarshal(t, populatedBeaconState()) + + // beacon-APIs types/primitive.yaml defines Bitvector as {type: string, format: + // hex, pattern "^0x[a-fA-F0-9]{2,}$"}. Both of BeaconState's bitvectors are + // therefore a single string: justification_bits is + // Bitvector[JUSTIFICATION_BITS_LENGTH] and execution_payload_availability is + // Bitvector[SLOTS_PER_HISTORICAL_ROOT]. Their Go types differ — + // bitfield.Bitvector4 against a plain []uint8 — but their wire shape does not. + bitvector := regexp.MustCompile(`^0x[a-fA-F0-9]{2,}$`) + for _, field := range []string{"justification_bits", "execution_payload_availability"} { + t.Run(field, func(t *testing.T) { + var value string + require.NoError(t, json.Unmarshal([]byte(rawField(t, doc, field)), &value)) + require.Regexp(t, bitvector, value) + }) + } + + // ptc_window is Vector[Vector[ValidatorIndex, PTC_SIZE], (2 + + // MIN_SEED_LOOKAHEAD) * SLOTS_PER_EPOCH] — nested arrays of decimal strings, + // uint64 entries rather than packed bits. The decimal-per-entry shape that was + // wrong for execution_payload_availability is the correct shape here, so this + // pins it against being "fixed" to match its neighbour. + var ptcWindow [][]string + require.NoError(t, json.Unmarshal([]byte(rawField(t, doc, "ptc_window")), &ptcWindow)) + require.Equal(t, [][]string{{"57", "58"}, {"59", "60"}}, ptcWindow) +} diff --git a/spec/gloas/beaconstate_yaml.go b/spec/gloas/beaconstate_yaml.go new file mode 100644 index 00000000..f4664d7b --- /dev/null +++ b/spec/gloas/beaconstate_yaml.go @@ -0,0 +1,150 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// beaconStateYAML is the spec representation of the struct. +type beaconStateYAML struct { + GenesisTime uint64 `yaml:"genesis_time"` + GenesisValidatorsRoot phase0.Root `yaml:"genesis_validators_root"` + Slot phase0.Slot `yaml:"slot"` + Fork *phase0.Fork `yaml:"fork"` + LatestBlockHeader *phase0.BeaconBlockHeader `yaml:"latest_block_header"` + BlockRoots []phase0.Root `yaml:"block_roots"` + StateRoots []phase0.Root `yaml:"state_roots"` + HistoricalRoots []phase0.Root `yaml:"historical_roots"` + ETH1Data *phase0.ETH1Data `yaml:"eth1_data"` + ETH1DataVotes []*phase0.ETH1Data `yaml:"eth1_data_votes"` + ETH1DepositIndex uint64 `yaml:"eth1_deposit_index"` + Validators []*phase0.Validator `yaml:"validators"` + Balances []phase0.Gwei `yaml:"balances"` + RANDAOMixes []phase0.Root `yaml:"randao_mixes"` + Slashings []phase0.Gwei `yaml:"slashings"` + PreviousEpochParticipation []altair.ParticipationFlags `yaml:"previous_epoch_participation"` + CurrentEpochParticipation []altair.ParticipationFlags `yaml:"current_epoch_participation"` + JustificationBits string `yaml:"justification_bits"` + PreviousJustifiedCheckpoint *phase0.Checkpoint `yaml:"previous_justified_checkpoint"` + CurrentJustifiedCheckpoint *phase0.Checkpoint `yaml:"current_justified_checkpoint"` + FinalizedCheckpoint *phase0.Checkpoint `yaml:"finalized_checkpoint"` + InactivityScores []uint64 `yaml:"inactivity_scores"` + CurrentSyncCommittee *altair.SyncCommittee `yaml:"current_sync_committee"` + NextSyncCommittee *altair.SyncCommittee `yaml:"next_sync_committee"` + LatestBlockHash phase0.Hash32 `yaml:"latest_block_hash"` + NextWithdrawalIndex capella.WithdrawalIndex `yaml:"next_withdrawal_index"` + NextWithdrawalValidatorIndex phase0.ValidatorIndex `yaml:"next_withdrawal_validator_index"` + HistoricalSummaries []*capella.HistoricalSummary `yaml:"historical_summaries"` + DepositRequestsStartIndex uint64 `yaml:"deposit_requests_start_index"` + DepositBalanceToConsume phase0.Gwei `yaml:"deposit_balance_to_consume"` + ExitBalanceToConsume phase0.Gwei `yaml:"exit_balance_to_consume"` + EarliestExitEpoch phase0.Epoch `yaml:"earliest_exit_epoch"` + ConsolidationBalanceToConsume phase0.Gwei `yaml:"consolidation_balance_to_consume"` + EarliestConsolidationEpoch phase0.Epoch `yaml:"earliest_consolidation_epoch"` + PendingDeposits []*electra.PendingDeposit `yaml:"pending_deposits"` + PendingPartialWithdrawals []*electra.PendingPartialWithdrawal `yaml:"pending_partial_withdrawals"` + PendingConsolidations []*electra.PendingConsolidation `yaml:"pending_consolidations"` + ProposerLookahead []phase0.ValidatorIndex `yaml:"proposer_lookahead"` + Builders []*Builder `yaml:"builders"` + NextWithdrawalBuilderIndex BuilderIndex `yaml:"next_withdrawal_builder_index"` + ExecutionPayloadAvailability string `yaml:"execution_payload_availability"` + BuilderPendingPayments []*BuilderPendingPayment `yaml:"builder_pending_payments"` + BuilderPendingWithdrawals []*BuilderPendingWithdrawal `yaml:"builder_pending_withdrawals"` + LatestExecutionPayloadBid *ExecutionPayloadBid `yaml:"latest_execution_payload_bid"` + PayloadExpectedWithdrawals []*capella.Withdrawal `yaml:"payload_expected_withdrawals"` + PTCWindow [][]phase0.ValidatorIndex `yaml:"ptc_window"` +} + +// MarshalYAML implements yaml.Marshaler. +func (b *BeaconState) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&beaconStateYAML{ + GenesisTime: b.GenesisTime, + GenesisValidatorsRoot: b.GenesisValidatorsRoot, + Slot: b.Slot, + Fork: b.Fork, + LatestBlockHeader: b.LatestBlockHeader, + BlockRoots: b.BlockRoots, + StateRoots: b.StateRoots, + HistoricalRoots: b.HistoricalRoots, + ETH1Data: b.ETH1Data, + ETH1DataVotes: b.ETH1DataVotes, + ETH1DepositIndex: b.ETH1DepositIndex, + Validators: b.Validators, + Balances: b.Balances, + RANDAOMixes: b.RANDAOMixes, + Slashings: b.Slashings, + PreviousEpochParticipation: b.PreviousEpochParticipation, + CurrentEpochParticipation: b.CurrentEpochParticipation, + JustificationBits: fmt.Sprintf("%#x", b.JustificationBits.Bytes()), + PreviousJustifiedCheckpoint: b.PreviousJustifiedCheckpoint, + CurrentJustifiedCheckpoint: b.CurrentJustifiedCheckpoint, + FinalizedCheckpoint: b.FinalizedCheckpoint, + InactivityScores: b.InactivityScores, + CurrentSyncCommittee: b.CurrentSyncCommittee, + NextSyncCommittee: b.NextSyncCommittee, + LatestBlockHash: b.LatestBlockHash, + NextWithdrawalIndex: b.NextWithdrawalIndex, + NextWithdrawalValidatorIndex: b.NextWithdrawalValidatorIndex, + HistoricalSummaries: b.HistoricalSummaries, + DepositRequestsStartIndex: b.DepositRequestsStartIndex, + DepositBalanceToConsume: b.DepositBalanceToConsume, + ExitBalanceToConsume: b.ExitBalanceToConsume, + EarliestExitEpoch: b.EarliestExitEpoch, + ConsolidationBalanceToConsume: b.ConsolidationBalanceToConsume, + EarliestConsolidationEpoch: b.EarliestConsolidationEpoch, + PendingDeposits: b.PendingDeposits, + PendingPartialWithdrawals: b.PendingPartialWithdrawals, + PendingConsolidations: b.PendingConsolidations, + ProposerLookahead: b.ProposerLookahead, + Builders: b.Builders, + NextWithdrawalBuilderIndex: b.NextWithdrawalBuilderIndex, + ExecutionPayloadAvailability: fmt.Sprintf("%#x", b.ExecutionPayloadAvailability), + BuilderPendingPayments: b.BuilderPendingPayments, + BuilderPendingWithdrawals: b.BuilderPendingWithdrawals, + LatestExecutionPayloadBid: b.LatestExecutionPayloadBid, + PayloadExpectedWithdrawals: b.PayloadExpectedWithdrawals, + PTCWindow: b.PTCWindow, + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (b *BeaconState) UnmarshalYAML(input []byte) error { + // This is very inefficient, but YAML is only used for spec tests so we do this + // rather than maintain a custom YAML unmarshaller. + var unmarshaled beaconStateJSON + if err := yaml.Unmarshal(input, &unmarshaled); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(unmarshaled) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return b.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/builder.go b/spec/gloas/builder.go new file mode 100644 index 00000000..8c7c5785 --- /dev/null +++ b/spec/gloas/builder.go @@ -0,0 +1,191 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// Builder is the Ethereum 2 builder structure. +type Builder struct { + PublicKey phase0.BLSPubKey `ssz-size:"48"` + Version uint8 + ExecutionAddress bellatrix.ExecutionAddress `ssz-size:"20"` + Balance phase0.Gwei + DepositEpoch phase0.Epoch + WithdrawableEpoch phase0.Epoch +} + +// builderJSON is the spec representation of the struct. +type builderJSON struct { + PublicKey string `json:"pubkey"` + Version uint8 `json:"version"` + ExecutionAddress string `json:"execution_address"` + Balance string `json:"balance"` + DepositEpoch string `json:"deposit_epoch"` + WithdrawableEpoch string `json:"withdrawable_epoch"` +} + +// builderYAML is the spec representation of the struct. +type builderYAML struct { + PublicKey string `yaml:"pubkey"` + Version uint8 `yaml:"version"` + ExecutionAddress string `yaml:"execution_address"` + Balance string `yaml:"balance"` + DepositEpoch string `yaml:"deposit_epoch"` + WithdrawableEpoch string `yaml:"withdrawable_epoch"` +} + +// MarshalJSON implements json.Marshaler. +func (v *Builder) MarshalJSON() ([]byte, error) { + return json.Marshal(&builderJSON{ + PublicKey: fmt.Sprintf("%#x", v.PublicKey), + Version: v.Version, + ExecutionAddress: v.ExecutionAddress.String(), + Balance: fmt.Sprintf("%d", v.Balance), + DepositEpoch: fmt.Sprintf("%d", v.DepositEpoch), + WithdrawableEpoch: fmt.Sprintf("%d", v.WithdrawableEpoch), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (v *Builder) UnmarshalJSON(input []byte) error { + var builderJSON builderJSON + if err := json.Unmarshal(input, &builderJSON); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + return v.unpack(&builderJSON) +} + +func (v *Builder) unpack(builderJSON *builderJSON) error { + if builderJSON.PublicKey == "" { + return errors.New("public key missing") + } + + publicKey, err := hex.DecodeString(strings.TrimPrefix(builderJSON.PublicKey, "0x")) + if err != nil { + return errors.Wrap(err, "invalid value for public key") + } + + if len(publicKey) != phase0.PublicKeyLength { + return fmt.Errorf("incorrect length %d for public key", len(publicKey)) + } + + copy(v.PublicKey[:], publicKey) + + v.Version = builderJSON.Version + + if builderJSON.ExecutionAddress == "" { + return errors.New("execution address missing") + } + + executionAddress, err := hex.DecodeString(strings.TrimPrefix(builderJSON.ExecutionAddress, "0x")) + if err != nil { + return errors.Wrap(err, "invalid value for execution address") + } + + if len(executionAddress) != bellatrix.ExecutionAddressLength { + return errors.New("incorrect length for fee recipient") + } + + copy(v.ExecutionAddress[:], executionAddress) + + if len(executionAddress) != bellatrix.ExecutionAddressLength { + return fmt.Errorf("incorrect length %d for execution address", len(executionAddress)) + } + + if builderJSON.Balance == "" { + return errors.New("balance missing") + } + + balance, err := strconv.ParseUint(builderJSON.Balance, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value for effective balance") + } + + v.Balance = phase0.Gwei(balance) + + if builderJSON.DepositEpoch == "" { + return errors.New("deposit epoch missing") + } + + depositEpoch, err := strconv.ParseUint(builderJSON.DepositEpoch, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value for deposit epoch") + } + + v.DepositEpoch = phase0.Epoch(depositEpoch) + + if builderJSON.WithdrawableEpoch == "" { + return errors.New("withdrawable epoch missing") + } + + withdrawableEpoch, err := strconv.ParseUint(builderJSON.WithdrawableEpoch, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value for withdrawable epoch") + } + + v.WithdrawableEpoch = phase0.Epoch(withdrawableEpoch) + + return nil +} + +// MarshalYAML implements yaml.Marshaler. +func (v *Builder) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&builderYAML{ + PublicKey: fmt.Sprintf("%#x", v.PublicKey), + Version: v.Version, + ExecutionAddress: v.ExecutionAddress.String(), + Balance: fmt.Sprintf("%d", v.Balance), + DepositEpoch: fmt.Sprintf("%d", v.DepositEpoch), + WithdrawableEpoch: fmt.Sprintf("%d", v.WithdrawableEpoch), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (v *Builder) UnmarshalYAML(input []byte) error { + // We unmarshal to the JSON struct to save on duplicate code. + var builderJSON builderJSON + if err := yaml.Unmarshal(input, &builderJSON); err != nil { + return err + } + + return v.unpack(&builderJSON) +} + +// String returns a string version of the structure. +func (v *Builder) String() string { + data, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/builder_ssz.go b/spec/gloas/builder_ssz.go new file mode 100644 index 00000000..5ca3d1df --- /dev/null +++ b/spec/gloas/builder_ssz.go @@ -0,0 +1,130 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 2814233bb3b8337c061ff6e590643587ba7f097131ad6f291a424c8a8ee5d512 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[Builder](`ssz-static:"true"`) + +// MarshalSSZ marshals the *Builder to SSZ-encoded bytes. +func (t *Builder) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *Builder to SSZ-encoded bytes, appending to the provided buffer. +func (t *Builder) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(Builder) + } + { // Static Field #0 'PublicKey' + dst = append(dst, t.PublicKey[:48]...) + } + { // Static Field #1 'Version' + dst = append(dst, byte(t.Version)) + } + { // Static Field #2 'ExecutionAddress' + dst = append(dst, t.ExecutionAddress[:20]...) + } + { // Static Field #3 'Balance' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.Balance)) + } + { // Static Field #4 'DepositEpoch' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.DepositEpoch)) + } + { // Static Field #5 'WithdrawableEpoch' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.WithdrawableEpoch)) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *Builder from SSZ-encoded bytes. +func (t *Builder) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 93 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 93) + } + if buflen > 93 { + return sszutils.ErrTrailingDataFn(buflen - 93) + } + { // Field #0 'PublicKey' (static) + buf := buf[0:48] + copy(t.PublicKey[:], buf) + } + { // Field #1 'Version' (static) + buf := buf[48:49] + t.Version = buf[0] + } + { // Field #2 'ExecutionAddress' (static) + buf := buf[49:69] + copy(t.ExecutionAddress[:], buf) + } + { // Field #3 'Balance' (static) + buf := buf[69:77] + t.Balance = phase0.Gwei(binary.LittleEndian.Uint64(buf)) + } + { // Field #4 'DepositEpoch' (static) + buf := buf[77:85] + t.DepositEpoch = phase0.Epoch(binary.LittleEndian.Uint64(buf)) + } + { // Field #5 'WithdrawableEpoch' (static) + buf := buf[85:93] + t.WithdrawableEpoch = phase0.Epoch(binary.LittleEndian.Uint64(buf)) + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *Builder. +func (t *Builder) SizeSSZ() (size int) { + return 93 +} + +// HashTreeRoot computes the SSZ hash tree root of the *Builder. +func (t *Builder) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *Builder using the given hash walker. +func (t *Builder) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(Builder) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'PublicKey' + hh.PutBytes(t.PublicKey[:48]) + } + { // Field #1 'Version' + hh.PutUint8(t.Version) + } + { // Field #2 'ExecutionAddress' + hh.PutBytes(t.ExecutionAddress[:20]) + } + { // Field #3 'Balance' + hh.PutUint64(uint64(t.Balance)) + } + { // Field #4 'DepositEpoch' + hh.PutUint64(uint64(t.DepositEpoch)) + } + { // Field #5 'WithdrawableEpoch' + hh.PutUint64(uint64(t.WithdrawableEpoch)) + } + hh.Merkleize(idx) + return nil +} diff --git a/spec/gloas/builder_test.go b/spec/gloas/builder_test.go new file mode 100644 index 00000000..be6b1045 --- /dev/null +++ b/spec/gloas/builder_test.go @@ -0,0 +1,74 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas_test + +import ( + "encoding/json" + "testing" + + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + require "github.com/stretchr/testify/require" +) + +// testBuilder returns a builder whose every field carries a distinctive value, so +// a field dropped by one of the four hand-written codec literals shows up as a +// difference rather than as a coincidentally-matching zero. +func testBuilder() *gloas.Builder { + return &gloas.Builder{ + PublicKey: phase0.BLSPubKey{0x01}, + Version: 1, + ExecutionAddress: bellatrix.ExecutionAddress{0x02}, + Balance: 3, + DepositEpoch: 4, + WithdrawableEpoch: 5, + } +} + +// TestBuilderVersionUnmarshal verifies that a builder's version survives a JSON +// decode. beacon-APIs types/gloas/builder.yaml lists version among Builder's +// required properties, and the SSZ codec carries it, so a JSON decode that drops +// it hands the caller a builder that silently claims version 0. +// +// The field is decoded into the intermediate JSON struct but was never copied +// across to the target, so every builder read over JSON lost it. +func TestBuilderVersionUnmarshal(t *testing.T) { + builder := testBuilder() + + var decoded gloas.Builder + require.NoError(t, json.Unmarshal([]byte(mustMarshal(t, builder)), &decoded)) + require.Equal(t, builder, &decoded) +} + +// TestBuilderVersionYAML verifies that a builder's version survives a YAML +// round-trip. The read direction is shared — UnmarshalYAML routes through the +// same unpack helper UnmarshalJSON uses — but MarshalJSON and MarshalYAML are two +// independent composite literals, so only the JSON one was ever populated. The +// consensus-spec vectors would catch it, but they are absent, so this is the only +// guard. +// +// The unquoted form asserted below is the current shape, not an endorsement of it: +// whether a spec Uint8 should be a quoted string here is a separate open question. +func TestBuilderVersionYAML(t *testing.T) { + builder := testBuilder() + + data, err := builder.MarshalYAML() + require.NoError(t, err) + require.Contains(t, string(data), "version: 1") + + var decoded gloas.Builder + require.NoError(t, decoded.UnmarshalYAML(data)) + require.Equal(t, builder, &decoded) +} diff --git a/spec/gloas/builderconfig.go b/spec/gloas/builderconfig.go new file mode 100644 index 00000000..04ab597f --- /dev/null +++ b/spec/gloas/builderconfig.go @@ -0,0 +1,45 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import "github.com/attestantio/go-eth2-client/spec/phase0" + +// BuilderRequestAuth authorizes a builder bid request for a proposal slot. +type BuilderRequestAuth struct { + Data []byte `ssz-index:"0" ssz-max:"4096"` + Slot phase0.Slot `ssz-index:"1"` +} + +// SignedBuilderRequestAuth is a signed builder bid request authorization. +type SignedBuilderRequestAuth struct { + Message *BuilderRequestAuth `ssz-index:"0"` + Signature phase0.BLSSignature `ssz-index:"1" ssz-size:"96"` +} + +// BuilderEntry configures one direct builder bid request. +type BuilderEntry struct { + URL []byte `ssz-index:"0" ssz-max:"2048"` + Auth *SignedBuilderRequestAuth `ssz-index:"1"` + BuilderPubkeys []phase0.BLSPubKey `ssz-index:"2" ssz-max:"64" ssz-size:"?,48"` + MaxExecutionPayment phase0.Gwei `ssz-index:"3"` + MinBid phase0.Gwei `ssz-index:"4"` + BuilderBoostFactor uint64 `ssz-index:"5"` +} + +// BuilderConfig configures P2P and direct builder bid selection for one proposal. +type BuilderConfig struct { + MinBid phase0.Gwei `ssz-index:"0"` + BuilderBoostFactor uint64 `ssz-index:"1"` + Builders []*BuilderEntry `ssz-index:"2" ssz-max:"64"` +} diff --git a/spec/gloas/builderconfig_json.go b/spec/gloas/builderconfig_json.go new file mode 100644 index 00000000..53d8e700 --- /dev/null +++ b/spec/gloas/builderconfig_json.go @@ -0,0 +1,252 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +type builderRequestAuthJSON struct { + Data string `json:"data"` + Slot string `json:"slot"` +} + +type signedBuilderRequestAuthJSON struct { + Message *BuilderRequestAuth `json:"message"` + Signature string `json:"signature"` +} + +type builderEntryJSON struct { + URL string `json:"url"` + Auth *SignedBuilderRequestAuth `json:"auth"` + BuilderPubkeys []string `json:"builder_pubkeys"` + MaxExecutionPayment string `json:"max_execution_payment"` + MinBid string `json:"min_bid"` + BuilderBoostFactor string `json:"builder_boost_factor"` +} + +type builderConfigJSON struct { + MinBid string `json:"min_bid"` + BuilderBoostFactor string `json:"builder_boost_factor"` + Builders []*BuilderEntry `json:"builders"` +} + +// MarshalJSON implements json.Marshaler. +func (b *BuilderRequestAuth) MarshalJSON() ([]byte, error) { + return json.Marshal(&builderRequestAuthJSON{ + Data: fmt.Sprintf("%#x", b.Data), + Slot: fmt.Sprintf("%d", b.Slot), + }) +} + +// MarshalJSON implements json.Marshaler. +func (b *SignedBuilderRequestAuth) MarshalJSON() ([]byte, error) { + return json.Marshal(&signedBuilderRequestAuthJSON{ + Message: b.Message, + Signature: fmt.Sprintf("%#x", b.Signature), + }) +} + +// MarshalJSON implements json.Marshaler. +func (b *BuilderEntry) MarshalJSON() ([]byte, error) { + builderPubkeys := make([]string, len(b.BuilderPubkeys)) + for i := range b.BuilderPubkeys { + builderPubkeys[i] = fmt.Sprintf("%#x", b.BuilderPubkeys[i]) + } + + return json.Marshal(&builderEntryJSON{ + URL: string(b.URL), + Auth: b.Auth, + BuilderPubkeys: builderPubkeys, + MaxExecutionPayment: fmt.Sprintf("%d", b.MaxExecutionPayment), + MinBid: fmt.Sprintf("%d", b.MinBid), + BuilderBoostFactor: fmt.Sprintf("%d", b.BuilderBoostFactor), + }) +} + +// MarshalJSON implements json.Marshaler. +func (b *BuilderConfig) MarshalJSON() ([]byte, error) { + return json.Marshal(&builderConfigJSON{ + MinBid: fmt.Sprintf("%d", b.MinBid), + BuilderBoostFactor: fmt.Sprintf("%d", b.BuilderBoostFactor), + Builders: b.buildersForWire(), + }) +} + +// buildersForWire normalises a nil builders slice to an empty one. builders is +// a required array on the wire and an empty list is meaningful -- it asks for +// no builder bids, leaving only p2p ones -- so a nil slice has to encode as [] +// rather than as null, which nothing will accept back. +func (b *BuilderConfig) buildersForWire() []*BuilderEntry { + if b.Builders == nil { + return []*BuilderEntry{} + } + + return b.Builders +} + +// UnmarshalJSON implements json.Unmarshaler. +func (b *BuilderRequestAuth) UnmarshalJSON(input []byte) error { + var data builderRequestAuthJSON + if err := json.Unmarshal(input, &data); err != nil { + return err + } + if data.Data == "" { + return errors.New("authorization data missing") + } + if !strings.HasPrefix(data.Data, "0x") { + return errors.New("authorization data missing 0x prefix") + } + + authData, err := hex.DecodeString(strings.TrimPrefix(data.Data, "0x")) + if err != nil { + return fmt.Errorf("invalid data: %w", err) + } + if len(authData) == 0 { + return errors.New("authorization data empty") + } + if len(authData) > 4096 { + return errors.New("authorization data exceeds 4096 bytes") + } + + slot, err := strconv.ParseUint(data.Slot, 10, 64) + if err != nil { + return fmt.Errorf("invalid slot: %w", err) + } + + b.Data = authData + b.Slot = phase0.Slot(slot) + + return nil +} + +// UnmarshalJSON implements json.Unmarshaler. +func (b *SignedBuilderRequestAuth) UnmarshalJSON(input []byte) error { + var data signedBuilderRequestAuthJSON + if err := json.Unmarshal(input, &data); err != nil { + return err + } + if !strings.HasPrefix(data.Signature, "0x") { + return errors.New("authorization signature missing 0x prefix") + } + + signature, err := hex.DecodeString(strings.TrimPrefix(data.Signature, "0x")) + if err != nil { + return fmt.Errorf("invalid signature: %w", err) + } + if len(signature) != phase0.SignatureLength { + return fmt.Errorf("incorrect signature length %d", len(signature)) + } + + b.Message = data.Message + copy(b.Signature[:], signature) + + return nil +} + +// UnmarshalJSON implements json.Unmarshaler. +func (b *BuilderEntry) UnmarshalJSON(input []byte) error { + var data builderEntryJSON + if err := json.Unmarshal(input, &data); err != nil { + return err + } + if data.URL == "" { + return errors.New("builder URL missing") + } + if len(data.URL) > 2048 { + return errors.New("builder URL exceeds 2048 bytes") + } + if data.Auth == nil || data.Auth.Message == nil { + return errors.New("builder authorization missing") + } + + maxExecutionPayment, err := strconv.ParseUint(data.MaxExecutionPayment, 10, 64) + if err != nil { + return fmt.Errorf("invalid max execution payment: %w", err) + } + minBid, err := strconv.ParseUint(data.MinBid, 10, 64) + if err != nil { + return fmt.Errorf("invalid min bid: %w", err) + } + builderBoostFactor, err := strconv.ParseUint(data.BuilderBoostFactor, 10, 64) + if err != nil { + return fmt.Errorf("invalid builder boost factor: %w", err) + } + + if len(data.BuilderPubkeys) > 64 { + return errors.New("too many builder public keys") + } + + builderPubkeys := make([]phase0.BLSPubKey, len(data.BuilderPubkeys)) + for i := range data.BuilderPubkeys { + if !strings.HasPrefix(data.BuilderPubkeys[i], "0x") { + return fmt.Errorf("builder public key %d missing 0x prefix", i) + } + + pubkey, err := hex.DecodeString(strings.TrimPrefix(data.BuilderPubkeys[i], "0x")) + if err != nil { + return fmt.Errorf("invalid builder public key %d: %w", i, err) + } + if len(pubkey) != phase0.PublicKeyLength { + return fmt.Errorf("incorrect builder public key length %d", len(pubkey)) + } + copy(builderPubkeys[i][:], pubkey) + } + + b.URL = []byte(data.URL) + b.Auth = data.Auth + b.BuilderPubkeys = builderPubkeys + b.MaxExecutionPayment = phase0.Gwei(maxExecutionPayment) + b.MinBid = phase0.Gwei(minBid) + b.BuilderBoostFactor = builderBoostFactor + + return nil +} + +// UnmarshalJSON implements json.Unmarshaler. +func (b *BuilderConfig) UnmarshalJSON(input []byte) error { + var data builderConfigJSON + if err := json.Unmarshal(input, &data); err != nil { + return err + } + + minBid, err := strconv.ParseUint(data.MinBid, 10, 64) + if err != nil { + return fmt.Errorf("invalid min bid: %w", err) + } + builderBoostFactor, err := strconv.ParseUint(data.BuilderBoostFactor, 10, 64) + if err != nil { + return fmt.Errorf("invalid builder boost factor: %w", err) + } + + if data.Builders == nil { + return errors.New("builders missing") + } + if len(data.Builders) > 64 { + return errors.New("too many builders") + } + + b.MinBid = phase0.Gwei(minBid) + b.BuilderBoostFactor = builderBoostFactor + b.Builders = data.Builders + + return nil +} diff --git a/spec/gloas/builderconfig_ssz.go b/spec/gloas/builderconfig_ssz.go new file mode 100644 index 00000000..d2014a95 --- /dev/null +++ b/spec/gloas/builderconfig_ssz.go @@ -0,0 +1,192 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 4881ce85735e368203948b2dff8996a991205aa6b1d602283340942846659abf +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[BuilderConfig](`ssz-static:"false"`) + +// MarshalSSZ marshals the *BuilderConfig to SSZ-encoded bytes. +func (t *BuilderConfig) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *BuilderConfig to SSZ-encoded bytes, appending to the provided buffer. +func (t *BuilderConfig) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(BuilderConfig) + } + dstlen := len(dst) + { // Static Field #0 'MinBid' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.MinBid)) + } + { // Static Field #1 'BuilderBoostFactor' + dst = binary.LittleEndian.AppendUint64(dst, t.BuilderBoostFactor) + } + // Offset Field #2 'Builders' + dst = append(dst, 0, 0, 0, 0) + { // Dynamic Field #2 'Builders' + binary.LittleEndian.PutUint32(dst[dstlen+16:], uint32(len(dst)-dstlen)) + t := t.Builders + vlen := len(t) + if vlen > 64 { + return nil, sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 64), "Builders") + } + dstlen := len(dst) + dst = sszutils.AppendZeroPadding(dst, vlen*4) + for idx1 := range vlen { + binary.LittleEndian.PutUint32(dst[dstlen+(idx1*4):], uint32(len(dst)-dstlen)) + t := t[idx1] + if t == nil { + t = new(BuilderEntry) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "Builders[%d]", idx1) + } + } + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *BuilderConfig from SSZ-encoded bytes. +func (t *BuilderConfig) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 20 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 20) + } + { // Field #0 'MinBid' (static) + buf := buf[0:8] + t.MinBid = phase0.Gwei(binary.LittleEndian.Uint64(buf)) + } + { // Field #1 'BuilderBoostFactor' (static) + buf := buf[8:16] + t.BuilderBoostFactor = binary.LittleEndian.Uint64(buf) + } + // Field #2 'Builders' (offset) + offset2 := int(binary.LittleEndian.Uint32(buf[16:20])) + if offset2 != 20 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset2, 20), "Builders:o") + } + { // Field #2 'Builders' (dynamic) + buf := buf[offset2:] + val1 := t.Builders + startOffset := int(0) + if len(buf) != 0 { + if len(buf) < 4 { + return sszutils.ErrorWithPath(sszutils.ErrListOffsetsEOFFn(len(buf), 4), "Builders") + } + startOffset = int(binary.LittleEndian.Uint32(buf[0:4])) + } + itemCount := startOffset / 4 + if startOffset%4 != 0 || len(buf) < startOffset || (len(buf) != 0 && startOffset == 0) { + return sszutils.ErrorWithPath(sszutils.ErrInvalidListStartOffsetFn(startOffset, len(buf)), "Builders") + } + if itemCount > 64 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(itemCount, 64), "Builders") + } + val1 = sszutils.ExpandSlice(val1, itemCount) + for idx1 := range itemCount { + var endOffset int + if idx1 < itemCount-1 { + endOffset = int(binary.LittleEndian.Uint32(buf[(idx1+1)*4 : (idx1+2)*4])) + } else { + endOffset = len(buf) + } + if endOffset < startOffset || endOffset > len(buf) { + return sszutils.ErrorWithPathf(sszutils.ErrElementOffsetOutOfRangeFn(endOffset, startOffset, len(buf)), "Builders[%d]", idx1) + } + buf := buf[startOffset:endOffset] + startOffset = endOffset + val2 := val1[idx1] + if val2 == nil { + val2 = new(BuilderEntry) + } + if err = val2.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "Builders[%d]", idx1) + } + val1[idx1] = val2 + } + t.Builders = val1 + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *BuilderConfig. +func (t *BuilderConfig) SizeSSZ() (size int) { + if t == nil { + t = new(BuilderConfig) + } + // Field #0 'MinBid' static (8 bytes) + // Field #1 'BuilderBoostFactor' static (8 bytes) + // Field #2 'Builders' offset (4 bytes) + size += 20 + { // Dynamic field #2 'Builders' + t := t.Builders + vlen := len(t) + size += vlen * 4 // Offsets + for i1 := range vlen { + size += t[i1].SizeSSZ() + } + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *BuilderConfig. +func (t *BuilderConfig) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *BuilderConfig using the given hash walker. +func (t *BuilderConfig) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(BuilderConfig) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'MinBid' + hh.PutUint64(uint64(t.MinBid)) + } + { // Field #1 'BuilderBoostFactor' + hh.PutUint64(t.BuilderBoostFactor) + } + { // Field #2 'Builders' + t := t.Builders + vlen := uint64(len(t)) + if vlen > 64 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 64), "Builders") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(BuilderEntry) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "Builders[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(64, vlen, 32)) + } + hh.MerkleizeProgressiveWithActiveFields(idx, []byte{0x07}) + return nil +} diff --git a/spec/gloas/builderconfig_test.go b/spec/gloas/builderconfig_test.go new file mode 100644 index 00000000..49aa2338 --- /dev/null +++ b/spec/gloas/builderconfig_test.go @@ -0,0 +1,310 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas_test + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" + "github.com/stretchr/testify/require" +) + +func validBuilderEntry() *gloas.BuilderEntry { + return &gloas.BuilderEntry{ + URL: []byte("https://builder.example"), + Auth: &gloas.SignedBuilderRequestAuth{ + Message: &gloas.BuilderRequestAuth{Data: []byte{0x01}, Slot: 123}, + Signature: phase0.BLSSignature{0x02}, + }, + BuilderPubkeys: []phase0.BLSPubKey{}, + MaxExecutionPayment: 4, + MinBid: 5, + BuilderBoostFactor: 6, + } +} + +func TestBuilderConfigUnmarshalJSON(t *testing.T) { + var config gloas.BuilderConfig + err := json.Unmarshal([]byte(`{ + "min_bid":"0", + "builder_boost_factor":"100", + "builders":[{ + "url":"https://builder.example", + "auth":{"message":{"data":"0x01","slot":"123"},"signature":"0x020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"}, + "builder_pubkeys":["0x030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"], + "max_execution_payment":"4", + "min_bid":"5", + "builder_boost_factor":"6" + }] + }`), &config) + require.NoError(t, err) + require.Equal(t, phase0.Gwei(0), config.MinBid) + require.Equal(t, uint64(100), config.BuilderBoostFactor) + require.Len(t, config.Builders, 1) + require.Equal(t, []byte("https://builder.example"), config.Builders[0].URL) + require.Equal(t, []byte{0x01}, config.Builders[0].Auth.Message.Data) + require.Equal(t, phase0.Slot(123), config.Builders[0].Auth.Message.Slot) + require.Equal(t, phase0.Gwei(4), config.Builders[0].MaxExecutionPayment) + require.Equal(t, phase0.Gwei(5), config.Builders[0].MinBid) + require.Equal(t, uint64(6), config.Builders[0].BuilderBoostFactor) +} + +func TestBuilderConfigUnmarshalJSONRejectsTooManyBuilders(t *testing.T) { + builders := make([]*gloas.BuilderEntry, 65) + for i := range builders { + builders[i] = validBuilderEntry() + } + input, err := json.Marshal(&gloas.BuilderConfig{Builders: builders}) + require.NoError(t, err) + + var config gloas.BuilderConfig + require.EqualError(t, json.Unmarshal(input, &config), "too many builders") +} + +func TestBuilderConfigUnmarshalJSONRequiresBuilders(t *testing.T) { + var config gloas.BuilderConfig + err := json.Unmarshal([]byte(`{"min_bid":"0","builder_boost_factor":"0"}`), &config) + require.EqualError(t, err, "builders missing") +} + +func TestSignedBuilderRequestAuthUnmarshalJSONRequiresHexPrefix(t *testing.T) { + input := fmt.Sprintf(`{ + "message":{"data":"0x01","slot":"123"}, + "signature":"%x" + }`, phase0.BLSSignature{0x02}) + + var auth gloas.SignedBuilderRequestAuth + require.EqualError(t, json.Unmarshal([]byte(input), &auth), "authorization signature missing 0x prefix") +} + +func TestBuilderRequestAuthUnmarshalJSONRejectsBadData(t *testing.T) { + tests := []struct { + name string + input string + err string + }{ + { + name: "MissingData", + input: `{"slot":"123"}`, + err: "authorization data missing", + }, + { + name: "UnprefixedData", + input: `{"data":"01","slot":"123"}`, + err: "authorization data missing 0x prefix", + }, + { + name: "EmptyData", + input: `{"data":"0x","slot":"123"}`, + err: "authorization data empty", + }, + { + name: "OversizedData", + input: fmt.Sprintf(`{"data":"0x%s","slot":"123"}`, strings.Repeat("01", 4097)), + err: "authorization data exceeds 4096 bytes", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var auth gloas.BuilderRequestAuth + err := json.Unmarshal([]byte(test.input), &auth) + require.EqualError(t, err, test.err) + }) + } +} + +func TestBuilderEntryUnmarshalJSONRequiresPubkeyHexPrefix(t *testing.T) { + entry := validBuilderEntry() + entry.BuilderPubkeys = []phase0.BLSPubKey{{0x03}} + input, err := json.Marshal(entry) + require.NoError(t, err) + input = []byte(strings.Replace(string(input), `"0x03`, `"03`, 1)) + + var decoded gloas.BuilderEntry + require.EqualError(t, json.Unmarshal(input, &decoded), "builder public key 0 missing 0x prefix") +} + +func TestBuilderEntryUnmarshalJSONRejectsTooManyPubkeys(t *testing.T) { + entry := validBuilderEntry() + entry.BuilderPubkeys = make([]phase0.BLSPubKey, 65) + input, err := json.Marshal(entry) + require.NoError(t, err) + + var decoded gloas.BuilderEntry + require.EqualError(t, json.Unmarshal(input, &decoded), "too many builder public keys") +} + +func TestBuilderEntryUnmarshalJSONRejectsOversizedURL(t *testing.T) { + entry := validBuilderEntry() + entry.URL = []byte(strings.Repeat("a", 2049)) + input, err := json.Marshal(entry) + require.NoError(t, err) + + var decoded gloas.BuilderEntry + require.EqualError(t, json.Unmarshal(input, &decoded), "builder URL exceeds 2048 bytes") +} + +func TestBuilderEntryUnmarshalJSONRejectsMissingFields(t *testing.T) { + tests := []struct { + name string + input string + err string + }{ + { + name: "MissingURL", + input: fmt.Sprintf(`{ + "auth":{"message":{"data":"0x01","slot":"123"},"signature":"%#x"}, + "builder_pubkeys":[], + "max_execution_payment":"4", + "min_bid":"5", + "builder_boost_factor":"6" + }`, phase0.BLSSignature{0x02}), + err: "builder URL missing", + }, + { + name: "MissingAuth", + input: `{ + "url":"https://builder.example", + "builder_pubkeys":[], + "max_execution_payment":"4", + "min_bid":"5", + "builder_boost_factor":"6" + }`, + err: "builder authorization missing", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var entry gloas.BuilderEntry + err := json.Unmarshal([]byte(test.input), &entry) + require.EqualError(t, err, test.err) + }) + } +} + +func TestBuilderConfigYAML(t *testing.T) { + config := &gloas.BuilderConfig{ + BuilderBoostFactor: 100, + Builders: []*gloas.BuilderEntry{}, + } + + data, err := yaml.Marshal(config) + require.NoError(t, err) + require.Contains(t, string(data), "min_bid: '0'") + require.Contains(t, string(data), "builder_boost_factor: '100'") + require.Contains(t, string(data), "builders: []") +} + +// TestBuilderConfigYAMLWithBuilders round-trips a populated config. An empty +// builders list exercises none of the entry encoding, so a nested entry has to +// be present for the wire names -- and the URL's "://", which a plain YAML +// scalar cannot carry in flow style -- to be checked at all. +func TestBuilderConfigYAMLWithBuilders(t *testing.T) { + config := &gloas.BuilderConfig{ + BuilderBoostFactor: 100, + Builders: []*gloas.BuilderEntry{validBuilderEntry()}, + } + config.Builders[0].BuilderPubkeys = []phase0.BLSPubKey{{0x03}} + + data, err := yaml.Marshal(config) + require.NoError(t, err) + require.Contains(t, string(data), "url: 'https://builder.example'") + require.Contains(t, string(data), "max_execution_payment: '4'") + require.Contains(t, string(data), "builder_pubkeys: ['0x03") + + var decoded gloas.BuilderConfig + require.NoError(t, yaml.Unmarshal(data, &decoded)) + require.Equal(t, config, &decoded) +} + +// TestBuilderConfigNilBuildersEncodeAsEmpty verifies a nil builders slice +// reaches the wire as [], not null: builders is a required array and an empty +// one is a meaningful request, so null would be rejected by the node and by +// this package's own decoder. +func TestBuilderConfigNilBuildersEncodeAsEmpty(t *testing.T) { + config := &gloas.BuilderConfig{BuilderBoostFactor: 100} + + data, err := json.Marshal(config) + require.NoError(t, err) + require.JSONEq(t, `{"min_bid":"0","builder_boost_factor":"100","builders":[]}`, string(data)) + + var decoded gloas.BuilderConfig + require.NoError(t, json.Unmarshal(data, &decoded)) + require.Empty(t, decoded.Builders) +} + +func TestBuilderConfigSSZ(t *testing.T) { + config := &gloas.BuilderConfig{ + MinBid: 0, + BuilderBoostFactor: 100, + Builders: []*gloas.BuilderEntry{{ + URL: []byte("https://builder.example"), + Auth: &gloas.SignedBuilderRequestAuth{ + Message: &gloas.BuilderRequestAuth{Data: []byte{0x01}, Slot: 123}, + Signature: phase0.BLSSignature{0x02}, + }, + BuilderPubkeys: []phase0.BLSPubKey{{0x03}}, + MaxExecutionPayment: 4, + MinBid: 5, + BuilderBoostFactor: 6, + }}, + } + + data, err := config.MarshalSSZ() + require.NoError(t, err) + + var decoded gloas.BuilderConfig + require.NoError(t, decoded.UnmarshalSSZ(data)) + require.Equal(t, config, &decoded) +} + +func TestBuilderConfigJSON(t *testing.T) { + config := &gloas.BuilderConfig{ + MinBid: 0, + BuilderBoostFactor: 100, + Builders: []*gloas.BuilderEntry{{ + URL: []byte("https://builder.example"), + Auth: &gloas.SignedBuilderRequestAuth{ + Message: &gloas.BuilderRequestAuth{Data: []byte{0x01}, Slot: 123}, + Signature: phase0.BLSSignature{0x02}, + }, + BuilderPubkeys: []phase0.BLSPubKey{{0x03}}, + MaxExecutionPayment: 4, + MinBid: 5, + BuilderBoostFactor: 6, + }}, + } + + data, err := json.Marshal(config) + require.NoError(t, err) + require.JSONEq(t, fmt.Sprintf(`{ + "min_bid":"0", + "builder_boost_factor":"100", + "builders":[{ + "url":"https://builder.example", + "auth":{"message":{"data":"0x01","slot":"123"},"signature":"%#x"}, + "builder_pubkeys":["0x030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"], + "max_execution_payment":"4", + "min_bid":"5", + "builder_boost_factor":"6" + }] + }`, config.Builders[0].Auth.Signature), string(data)) +} diff --git a/spec/gloas/builderconfig_yaml.go b/spec/gloas/builderconfig_yaml.go new file mode 100644 index 00000000..48633b56 --- /dev/null +++ b/spec/gloas/builderconfig_yaml.go @@ -0,0 +1,165 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (b *BuilderRequestAuth) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&builderRequestAuthJSON{ + Data: fmt.Sprintf("%#x", b.Data), + Slot: fmt.Sprintf("%d", b.Slot), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (b *BuilderRequestAuth) UnmarshalYAML(input []byte) error { + var data builderRequestAuthJSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return b.UnmarshalJSON(marshaled) +} + +// MarshalYAML implements yaml.Marshaler. +func (b *SignedBuilderRequestAuth) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&signedBuilderRequestAuthJSON{ + Message: b.Message, + Signature: fmt.Sprintf("%#x", b.Signature), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (b *SignedBuilderRequestAuth) UnmarshalYAML(input []byte) error { + var data signedBuilderRequestAuthJSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return b.UnmarshalJSON(marshaled) +} + +// yamlQuotedString renders as an explicitly single-quoted YAML scalar. A +// builder URL carries "://", which goccy emits as a plain scalar; in the flow +// style this package marshals with, the embedded colon then makes the enclosing +// document re-parse the value as a nested mapping and lose the URL. +type yamlQuotedString string + +// MarshalYAML implements yaml.BytesMarshaler. +func (q yamlQuotedString) MarshalYAML() ([]byte, error) { + return []byte("'" + strings.ReplaceAll(string(q), "'", "''") + "'"), nil +} + +// builderEntryYAML mirrors builderEntryJSON with the URL forced to a quoted +// scalar. Unmarshaling still goes through builderEntryJSON, which takes a +// plain string. +type builderEntryYAML struct { + URL yamlQuotedString `json:"url"` + Auth *SignedBuilderRequestAuth `json:"auth"` + BuilderPubkeys []string `json:"builder_pubkeys"` + MaxExecutionPayment string `json:"max_execution_payment"` + MinBid string `json:"min_bid"` + BuilderBoostFactor string `json:"builder_boost_factor"` +} + +// MarshalYAML implements yaml.Marshaler. +func (b *BuilderEntry) MarshalYAML() ([]byte, error) { + builderPubkeys := make([]string, len(b.BuilderPubkeys)) + for i := range b.BuilderPubkeys { + builderPubkeys[i] = fmt.Sprintf("%#x", b.BuilderPubkeys[i]) + } + + yamlBytes, err := yaml.MarshalWithOptions(&builderEntryYAML{ + URL: yamlQuotedString(b.URL), + Auth: b.Auth, + BuilderPubkeys: builderPubkeys, + MaxExecutionPayment: fmt.Sprintf("%d", b.MaxExecutionPayment), + MinBid: fmt.Sprintf("%d", b.MinBid), + BuilderBoostFactor: fmt.Sprintf("%d", b.BuilderBoostFactor), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (b *BuilderEntry) UnmarshalYAML(input []byte) error { + var data builderEntryJSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return b.UnmarshalJSON(marshaled) +} + +// MarshalYAML implements yaml.Marshaler. +func (b *BuilderConfig) MarshalYAML() ([]byte, error) { + data, err := yaml.MarshalWithOptions(&builderConfigJSON{ + MinBid: fmt.Sprintf("%d", b.MinBid), + BuilderBoostFactor: fmt.Sprintf("%d", b.BuilderBoostFactor), + Builders: b.buildersForWire(), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(data, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (b *BuilderConfig) UnmarshalYAML(input []byte) error { + var data builderConfigJSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return b.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/builderdepositrequest.go b/spec/gloas/builderdepositrequest.go new file mode 100644 index 00000000..ce32cd01 --- /dev/null +++ b/spec/gloas/builderdepositrequest.go @@ -0,0 +1,39 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// BuilderDepositRequest represents a builder deposit request (EIP-8282). +type BuilderDepositRequest struct { + Pubkey phase0.BLSPubKey `ssz-size:"48"` + WithdrawalCredentials []byte `ssz-size:"32"` + Amount phase0.Gwei + Signature phase0.BLSSignature `ssz-size:"96"` +} + +// String returns a string version of the structure. +func (b *BuilderDepositRequest) String() string { + data, err := yaml.Marshal(b) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/builderdepositrequest_json.go b/spec/gloas/builderdepositrequest_json.go new file mode 100644 index 00000000..2c0f876f --- /dev/null +++ b/spec/gloas/builderdepositrequest_json.go @@ -0,0 +1,110 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// builderDepositRequestJSON is the spec representation of the struct. +type builderDepositRequestJSON struct { + Pubkey string `json:"pubkey"` + WithdrawalCredentials string `json:"withdrawal_credentials"` + Amount string `json:"amount"` + Signature string `json:"signature"` +} + +// MarshalJSON implements json.Marshaler. +func (b *BuilderDepositRequest) MarshalJSON() ([]byte, error) { + return json.Marshal(&builderDepositRequestJSON{ + Pubkey: fmt.Sprintf("%#x", b.Pubkey), + WithdrawalCredentials: fmt.Sprintf("%#x", b.WithdrawalCredentials), + Amount: fmt.Sprintf("%d", b.Amount), + Signature: fmt.Sprintf("%#x", b.Signature), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (b *BuilderDepositRequest) UnmarshalJSON(input []byte) error { + var data builderDepositRequestJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + return b.unpack(&data) +} + +func (b *BuilderDepositRequest) unpack(data *builderDepositRequestJSON) error { + if data.Pubkey == "" { + return errors.New("public key missing") + } + + pubkey, err := hex.DecodeString(strings.TrimPrefix(data.Pubkey, "0x")) + if err != nil { + return errors.Wrap(err, "invalid value for public key") + } + + if len(pubkey) != phase0.PublicKeyLength { + return errors.New("incorrect length for public key") + } + + copy(b.Pubkey[:], pubkey) + + if data.WithdrawalCredentials == "" { + return errors.New("withdrawal credentials missing") + } + + if b.WithdrawalCredentials, err = hex.DecodeString(strings.TrimPrefix(data.WithdrawalCredentials, "0x")); err != nil { + return errors.Wrap(err, "invalid value for withdrawal credentials") + } + + if len(b.WithdrawalCredentials) != phase0.HashLength { + return errors.New("incorrect length for withdrawal credentials") + } + + if data.Amount == "" { + return errors.New("amount missing") + } + + amount, err := strconv.ParseUint(data.Amount, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value for amount") + } + + b.Amount = phase0.Gwei(amount) + + if data.Signature == "" { + return errors.New("signature missing") + } + + signature, err := hex.DecodeString(strings.TrimPrefix(data.Signature, "0x")) + if err != nil { + return errors.Wrap(err, "invalid value for signature") + } + + if len(signature) != phase0.SignatureLength { + return errors.New("incorrect length for signature") + } + + copy(b.Signature[:], signature) + + return nil +} diff --git a/spec/gloas/builderdepositrequest_ssz.go b/spec/gloas/builderdepositrequest_ssz.go new file mode 100644 index 00000000..61f70983 --- /dev/null +++ b/spec/gloas/builderdepositrequest_ssz.go @@ -0,0 +1,126 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 66ee5efe5c0a7b4add4b02bdb68c7eb0f4db97255ccbbefdf60fc603909db5dd +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[BuilderDepositRequest](`ssz-static:"true"`) + +// MarshalSSZ marshals the *BuilderDepositRequest to SSZ-encoded bytes. +func (t *BuilderDepositRequest) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *BuilderDepositRequest to SSZ-encoded bytes, appending to the provided buffer. +func (t *BuilderDepositRequest) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(BuilderDepositRequest) + } + { // Static Field #0 'Pubkey' + dst = append(dst, t.Pubkey[:48]...) + } + { // Static Field #1 'WithdrawalCredentials' + vlen := len(t.WithdrawalCredentials) + if vlen > 32 { + return nil, sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 32), "WithdrawalCredentials") + } + dst = append(dst, t.WithdrawalCredentials[:vlen]...) + if vlen < 32 { + dst = sszutils.AppendZeroPadding(dst, (32-vlen)*1) + } + } + { // Static Field #2 'Amount' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.Amount)) + } + { // Static Field #3 'Signature' + dst = append(dst, t.Signature[:96]...) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *BuilderDepositRequest from SSZ-encoded bytes. +func (t *BuilderDepositRequest) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 184 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 184) + } + if buflen > 184 { + return sszutils.ErrTrailingDataFn(buflen - 184) + } + { // Field #0 'Pubkey' (static) + buf := buf[0:48] + copy(t.Pubkey[:], buf) + } + { // Field #1 'WithdrawalCredentials' (static) + buf := buf[48:80] + t.WithdrawalCredentials = sszutils.ExpandSlice(t.WithdrawalCredentials, 32) + copy(t.WithdrawalCredentials[:], buf) + } + { // Field #2 'Amount' (static) + buf := buf[80:88] + t.Amount = phase0.Gwei(binary.LittleEndian.Uint64(buf)) + } + { // Field #3 'Signature' (static) + buf := buf[88:184] + copy(t.Signature[:], buf) + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *BuilderDepositRequest. +func (t *BuilderDepositRequest) SizeSSZ() (size int) { + return 184 +} + +// HashTreeRoot computes the SSZ hash tree root of the *BuilderDepositRequest. +func (t *BuilderDepositRequest) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *BuilderDepositRequest using the given hash walker. +func (t *BuilderDepositRequest) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(BuilderDepositRequest) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Pubkey' + hh.PutBytes(t.Pubkey[:48]) + } + { // Field #1 'WithdrawalCredentials' + vlen := len(t.WithdrawalCredentials) + if vlen > 32 { + return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 32), "WithdrawalCredentials") + } + val := t.WithdrawalCredentials[:] + if vlen < 32 { + val = sszutils.AppendZeroPadding(val, (32-vlen)*1) + } + hh.PutBytes(val[:32]) + } + { // Field #2 'Amount' + hh.PutUint64(uint64(t.Amount)) + } + { // Field #3 'Signature' + hh.PutBytes(t.Signature[:96]) + } + hh.Merkleize(idx) + return nil +} diff --git a/spec/gloas/builderdepositrequest_yaml.go b/spec/gloas/builderdepositrequest_yaml.go new file mode 100644 index 00000000..27d3ef4e --- /dev/null +++ b/spec/gloas/builderdepositrequest_yaml.go @@ -0,0 +1,55 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "fmt" + + "github.com/goccy/go-yaml" +) + +// builderDepositRequestYAML is the spec representation of the struct. +type builderDepositRequestYAML struct { + Pubkey string `yaml:"pubkey"` + WithdrawalCredentials string `yaml:"withdrawal_credentials"` + Amount uint64 `yaml:"amount"` + Signature string `yaml:"signature"` +} + +// MarshalYAML implements yaml.Marshaler. +func (b *BuilderDepositRequest) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&builderDepositRequestYAML{ + Pubkey: fmt.Sprintf("%#x", b.Pubkey), + WithdrawalCredentials: fmt.Sprintf("%#x", b.WithdrawalCredentials), + Amount: uint64(b.Amount), + Signature: fmt.Sprintf("%#x", b.Signature), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (b *BuilderDepositRequest) UnmarshalYAML(input []byte) error { + // We unmarshal to the JSON struct to save on duplicate code. + var data builderDepositRequestJSON + if err := yaml.Unmarshal(input, &data); err != nil { + return err + } + + return b.unpack(&data) +} diff --git a/spec/gloas/builderentry_ssz.go b/spec/gloas/builderentry_ssz.go new file mode 100644 index 00000000..72d8caaa --- /dev/null +++ b/spec/gloas/builderentry_ssz.go @@ -0,0 +1,228 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 1d46c7a69ea582e780052d50baf963d1c45d3069b18399b1fc15fb3c3c30182c +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[BuilderEntry](`ssz-static:"false"`) + +// MarshalSSZ marshals the *BuilderEntry to SSZ-encoded bytes. +func (t *BuilderEntry) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *BuilderEntry to SSZ-encoded bytes, appending to the provided buffer. +func (t *BuilderEntry) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + zeroBytes := sszutils.ZeroBytes() + if t == nil { + t = new(BuilderEntry) + } + dstlen := len(dst) + // Offset Field #0 'URL' + // Offset Field #1 'Auth' + // Offset Field #2 'BuilderPubkeys' + dst = append(dst, zeroBytes[:12]...) + { // Static Field #3 'MaxExecutionPayment' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.MaxExecutionPayment)) + } + { // Static Field #4 'MinBid' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.MinBid)) + } + { // Static Field #5 'BuilderBoostFactor' + dst = binary.LittleEndian.AppendUint64(dst, t.BuilderBoostFactor) + } + { // Dynamic Field #0 'URL' + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(len(dst)-dstlen)) + vlen := len(t.URL) + if vlen > 2048 { + return nil, sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 2048), "URL") + } + dst = append(dst, t.URL[:]...) + } + { // Dynamic Field #1 'Auth' + binary.LittleEndian.PutUint32(dst[dstlen+4:], uint32(len(dst)-dstlen)) + t := t.Auth + if t == nil { + t = new(SignedBuilderRequestAuth) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Auth") + } + } + { // Dynamic Field #2 'BuilderPubkeys' + binary.LittleEndian.PutUint32(dst[dstlen+8:], uint32(len(dst)-dstlen)) + t := t.BuilderPubkeys + vlen := len(t) + if vlen > 64 { + return nil, sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 64), "BuilderPubkeys") + } + dst = sszutils.MarshalFixedBytesSlice(dst, t[:vlen]) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *BuilderEntry from SSZ-encoded bytes. +func (t *BuilderEntry) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 36 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 36) + } + // Field #0 'URL' (offset) + offset0 := int(binary.LittleEndian.Uint32(buf[0:4])) + if offset0 != 36 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 36), "URL:o") + } + // Field #1 'Auth' (offset) + offset1 := int(binary.LittleEndian.Uint32(buf[4:8])) + if offset1 < offset0 || offset1 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset1, offset0, buflen), "Auth:o") + } + // Field #2 'BuilderPubkeys' (offset) + offset2 := int(binary.LittleEndian.Uint32(buf[8:12])) + if offset2 < offset1 || offset2 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset2, offset1, buflen), "BuilderPubkeys:o") + } + { // Field #3 'MaxExecutionPayment' (static) + buf := buf[12:20] + t.MaxExecutionPayment = phase0.Gwei(binary.LittleEndian.Uint64(buf)) + } + { // Field #4 'MinBid' (static) + buf := buf[20:28] + t.MinBid = phase0.Gwei(binary.LittleEndian.Uint64(buf)) + } + { // Field #5 'BuilderBoostFactor' (static) + buf := buf[28:36] + t.BuilderBoostFactor = binary.LittleEndian.Uint64(buf) + } + { // Field #0 'URL' (dynamic) + buf := buf[offset0:offset1] + if len(buf) > 2048 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(len(buf), 2048), "URL") + } + t.URL = sszutils.ExpandSlice(t.URL, len(buf)) + copy(t.URL[:], buf) + } + { // Field #1 'Auth' (dynamic) + buf := buf[offset1:offset2] + if t.Auth == nil { + t.Auth = new(SignedBuilderRequestAuth) + } + if err = t.Auth.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Auth") + } + } + { // Field #2 'BuilderPubkeys' (dynamic) + buf := buf[offset2:] + val1 := t.BuilderPubkeys + itemCount := len(buf) / 48 + if len(buf)%48 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 48), "BuilderPubkeys") + } + if itemCount > 64 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(itemCount, 64), "BuilderPubkeys") + } + val1 = sszutils.ExpandSlice(val1, itemCount) + sszutils.UnmarshalFixedBytesSlice(val1[:itemCount], buf) + t.BuilderPubkeys = val1 + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *BuilderEntry. +func (t *BuilderEntry) SizeSSZ() (size int) { + if t == nil { + t = new(BuilderEntry) + } + // Field #0 'URL' offset (4 bytes) + // Field #1 'Auth' offset (4 bytes) + // Field #2 'BuilderPubkeys' offset (4 bytes) + // Field #3 'MaxExecutionPayment' static (8 bytes) + // Field #4 'MinBid' static (8 bytes) + // Field #5 'BuilderBoostFactor' static (8 bytes) + size += 36 + { // Dynamic field #0 'URL' + size += len(t.URL) + } + { // Dynamic field #1 'Auth' + size += t.Auth.SizeSSZ() + } + { // Dynamic field #2 'BuilderPubkeys' + size += len(t.BuilderPubkeys) * 48 + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *BuilderEntry. +func (t *BuilderEntry) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *BuilderEntry using the given hash walker. +func (t *BuilderEntry) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(BuilderEntry) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'URL' + vlen := uint64(len(t.URL)) + if vlen > 2048 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 2048), "URL") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + hh.AppendBytes32(t.URL[:]) + hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(2048, vlen, 1)) + } + { // Field #1 'Auth' + t := t.Auth + if t == nil { + t = new(SignedBuilderRequestAuth) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Auth") + } + } + { // Field #2 'BuilderPubkeys' + t := t.BuilderPubkeys + vlen := uint64(len(t)) + if vlen > 64 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 64), "BuilderPubkeys") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + for idx1 := range int(vlen) { + hh.PutBytes(t[idx1][:48]) + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(64, vlen, 32)) + } + { // Field #3 'MaxExecutionPayment' + hh.PutUint64(uint64(t.MaxExecutionPayment)) + } + { // Field #4 'MinBid' + hh.PutUint64(uint64(t.MinBid)) + } + { // Field #5 'BuilderBoostFactor' + hh.PutUint64(t.BuilderBoostFactor) + } + hh.MerkleizeProgressiveWithActiveFields(idx, []byte{0x3f}) + return nil +} diff --git a/spec/gloas/builderexitrequest.go b/spec/gloas/builderexitrequest.go new file mode 100644 index 00000000..90c2b114 --- /dev/null +++ b/spec/gloas/builderexitrequest.go @@ -0,0 +1,38 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// BuilderExitRequest represents a builder exit request (EIP-8282). +type BuilderExitRequest struct { + SourceAddress bellatrix.ExecutionAddress `ssz-size:"20"` + Pubkey phase0.BLSPubKey `ssz-size:"48"` +} + +// String returns a string version of the structure. +func (b *BuilderExitRequest) String() string { + data, err := yaml.Marshal(b) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/builderexitrequest_json.go b/spec/gloas/builderexitrequest_json.go new file mode 100644 index 00000000..2990a72e --- /dev/null +++ b/spec/gloas/builderexitrequest_json.go @@ -0,0 +1,55 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/json" + + "github.com/attestantio/go-eth2-client/codecs" + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// builderExitRequestJSON is the spec representation of the struct. +type builderExitRequestJSON struct { + SourceAddress bellatrix.ExecutionAddress `json:"source_address"` + Pubkey phase0.BLSPubKey `json:"pubkey"` +} + +// MarshalJSON implements json.Marshaler. +func (b *BuilderExitRequest) MarshalJSON() ([]byte, error) { + return json.Marshal(&builderExitRequestJSON{ + SourceAddress: b.SourceAddress, + Pubkey: b.Pubkey, + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (b *BuilderExitRequest) UnmarshalJSON(input []byte) error { + raw, err := codecs.RawJSON(&builderExitRequestJSON{}, input) + if err != nil { + return err + } + + if err := b.SourceAddress.UnmarshalJSON(raw["source_address"]); err != nil { + return errors.Wrap(err, "source_address") + } + + if err := b.Pubkey.UnmarshalJSON(raw["pubkey"]); err != nil { + return errors.Wrap(err, "pubkey") + } + + return nil +} diff --git a/spec/gloas/builderexitrequest_ssz.go b/spec/gloas/builderexitrequest_ssz.go new file mode 100644 index 00000000..5903e3f0 --- /dev/null +++ b/spec/gloas/builderexitrequest_ssz.go @@ -0,0 +1,87 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 6744f46015a151229eea5c50db45670c0ca6575b5ef67d8601cdc56c3d21905b +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[BuilderExitRequest](`ssz-static:"true"`) + +// MarshalSSZ marshals the *BuilderExitRequest to SSZ-encoded bytes. +func (t *BuilderExitRequest) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *BuilderExitRequest to SSZ-encoded bytes, appending to the provided buffer. +func (t *BuilderExitRequest) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(BuilderExitRequest) + } + { // Static Field #0 'SourceAddress' + dst = append(dst, t.SourceAddress[:20]...) + } + { // Static Field #1 'Pubkey' + dst = append(dst, t.Pubkey[:48]...) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *BuilderExitRequest from SSZ-encoded bytes. +func (t *BuilderExitRequest) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 68 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 68) + } + if buflen > 68 { + return sszutils.ErrTrailingDataFn(buflen - 68) + } + { // Field #0 'SourceAddress' (static) + buf := buf[0:20] + copy(t.SourceAddress[:], buf) + } + { // Field #1 'Pubkey' (static) + buf := buf[20:68] + copy(t.Pubkey[:], buf) + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *BuilderExitRequest. +func (t *BuilderExitRequest) SizeSSZ() (size int) { + return 68 +} + +// HashTreeRoot computes the SSZ hash tree root of the *BuilderExitRequest. +func (t *BuilderExitRequest) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *BuilderExitRequest using the given hash walker. +func (t *BuilderExitRequest) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(BuilderExitRequest) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'SourceAddress' + hh.PutBytes(t.SourceAddress[:20]) + } + { // Field #1 'Pubkey' + hh.PutBytes(t.Pubkey[:48]) + } + hh.Merkleize(idx) + return nil +} diff --git a/spec/gloas/builderexitrequest_yaml.go b/spec/gloas/builderexitrequest_yaml.go new file mode 100644 index 00000000..5dd0349b --- /dev/null +++ b/spec/gloas/builderexitrequest_yaml.go @@ -0,0 +1,60 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// builderExitRequestYAML is the spec representation of the struct. +type builderExitRequestYAML struct { + SourceAddress bellatrix.ExecutionAddress `yaml:"source_address"` + Pubkey phase0.BLSPubKey `yaml:"pubkey"` +} + +// MarshalYAML implements yaml.Marshaler. +func (b *BuilderExitRequest) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&builderExitRequestYAML{ + SourceAddress: b.SourceAddress, + Pubkey: b.Pubkey, + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (b *BuilderExitRequest) UnmarshalYAML(input []byte) error { + // This is very inefficient, but YAML is only used for spec tests so we do this + // rather than maintain a custom YAML unmarshaller. + var unmarshaled builderExitRequestJSON + if err := yaml.Unmarshal(input, &unmarshaled); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + + marshaled, err := json.Marshal(&unmarshaled) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return b.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/builderindex.go b/spec/gloas/builderindex.go new file mode 100644 index 00000000..b540a24c --- /dev/null +++ b/spec/gloas/builderindex.go @@ -0,0 +1,61 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "fmt" + "strconv" + + "github.com/pkg/errors" +) + +// BuilderIndex is the index of the builder. +type BuilderIndex uint64 + +// UnmarshalJSON implements json.Unmarshaler. +func (v *BuilderIndex) UnmarshalJSON(input []byte) error { + if len(input) == 0 { + return errors.New("input missing") + } + + if len(input) < 3 { + return errors.New("input malformed") + } + + if !bytes.HasPrefix(input, []byte{'"'}) { + return errors.New("invalid prefix") + } + + if !bytes.HasSuffix(input, []byte{'"'}) { + return errors.New("invalid suffix") + } + + val, err := strconv.ParseUint(string(input[1:len(input)-1]), 10, 64) + if err != nil { + return errors.Wrapf(err, "invalid value %s", string(input[1:len(input)-1])) + } + + *v = BuilderIndex(val) + + return nil +} + +// MarshalJSON implements json.Marshaler. +func (v BuilderIndex) MarshalJSON() ([]byte, error) { + return fmt.Appendf(nil, `"%d"`, v), nil +} + +// BuilderIndexSelfBuild identifies a self-built payload. +const BuilderIndexSelfBuild BuilderIndex = 18446744073709551615 diff --git a/spec/gloas/builderpendingpayment.go b/spec/gloas/builderpendingpayment.go new file mode 100644 index 00000000..b6584b34 --- /dev/null +++ b/spec/gloas/builderpendingpayment.go @@ -0,0 +1,38 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// BuilderPendingPayment represents a builder pending payment. +type BuilderPendingPayment struct { + Weight phase0.Gwei `json:"weight" yaml:"weight"` + Withdrawal *BuilderPendingWithdrawal `json:"withdrawal" yaml:"withdrawal"` + ProposerIndex phase0.ValidatorIndex `json:"proposer_index" yaml:"proposer_index"` +} + +// String returns a string version of the structure. +func (p *BuilderPendingPayment) String() string { + data, err := yaml.Marshal(p) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/builderpendingpayment_ssz.go b/spec/gloas/builderpendingpayment_ssz.go new file mode 100644 index 00000000..acad482f --- /dev/null +++ b/spec/gloas/builderpendingpayment_ssz.go @@ -0,0 +1,117 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 58af077c89deeb7ce2248ff3ff397aff72293d08a25a33a2d0fedf75538f01c9 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[BuilderPendingPayment](`ssz-static:"true"`) + +// MarshalSSZ marshals the *BuilderPendingPayment to SSZ-encoded bytes. +func (t *BuilderPendingPayment) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *BuilderPendingPayment to SSZ-encoded bytes, appending to the provided buffer. +func (t *BuilderPendingPayment) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(BuilderPendingPayment) + } + { // Static Field #0 'Weight' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.Weight)) + } + { // Static Field #1 'Withdrawal' + t := t.Withdrawal + if t == nil { + t = new(BuilderPendingWithdrawal) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Withdrawal") + } + } + { // Static Field #2 'ProposerIndex' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.ProposerIndex)) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *BuilderPendingPayment from SSZ-encoded bytes. +func (t *BuilderPendingPayment) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 52 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 52) + } + if buflen > 52 { + return sszutils.ErrTrailingDataFn(buflen - 52) + } + { // Field #0 'Weight' (static) + buf := buf[0:8] + t.Weight = phase0.Gwei(binary.LittleEndian.Uint64(buf)) + } + { // Field #1 'Withdrawal' (static) + buf := buf[8:44] + if t.Withdrawal == nil { + t.Withdrawal = new(BuilderPendingWithdrawal) + } + if err = t.Withdrawal.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Withdrawal") + } + } + { // Field #2 'ProposerIndex' (static) + buf := buf[44:52] + t.ProposerIndex = phase0.ValidatorIndex(binary.LittleEndian.Uint64(buf)) + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *BuilderPendingPayment. +func (t *BuilderPendingPayment) SizeSSZ() (size int) { + return 52 +} + +// HashTreeRoot computes the SSZ hash tree root of the *BuilderPendingPayment. +func (t *BuilderPendingPayment) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *BuilderPendingPayment using the given hash walker. +func (t *BuilderPendingPayment) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(BuilderPendingPayment) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Weight' + hh.PutUint64(uint64(t.Weight)) + } + { // Field #1 'Withdrawal' + t := t.Withdrawal + if t == nil { + t = new(BuilderPendingWithdrawal) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Withdrawal") + } + } + { // Field #2 'ProposerIndex' + hh.PutUint64(uint64(t.ProposerIndex)) + } + hh.Merkleize(idx) + return nil +} diff --git a/spec/gloas/builderpendingpayment_test.go b/spec/gloas/builderpendingpayment_test.go new file mode 100644 index 00000000..c03da982 --- /dev/null +++ b/spec/gloas/builderpendingpayment_test.go @@ -0,0 +1,48 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas_test + +import ( + "encoding/json" + "testing" + + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/gloas" + require "github.com/stretchr/testify/require" +) + +func TestBuilderPendingPaymentJSON(t *testing.T) { + payment := &gloas.BuilderPendingPayment{ + Weight: 1, + ProposerIndex: 2, + Withdrawal: &gloas.BuilderPendingWithdrawal{ + FeeRecipient: bellatrix.ExecutionAddress{0x03}, + Amount: 4, + BuilderIndex: 5, + }, + } + + data, err := json.Marshal(payment) + require.NoError(t, err) + require.Contains(t, string(data), `"weight":"1"`) + require.Contains(t, string(data), `"withdrawal":{`) + require.Contains(t, string(data), `"proposer_index":"2"`) + require.Contains(t, string(data), `"fee_recipient":"0x0300000000000000000000000000000000000000"`) + require.Contains(t, string(data), `"amount":"4"`) + require.Contains(t, string(data), `"builder_index":"5"`) + + var decoded gloas.BuilderPendingPayment + require.NoError(t, json.Unmarshal(data, &decoded)) + require.Equal(t, payment, &decoded) +} diff --git a/spec/gloas/builderpendingwithdrawal.go b/spec/gloas/builderpendingwithdrawal.go new file mode 100644 index 00000000..a0058786 --- /dev/null +++ b/spec/gloas/builderpendingwithdrawal.go @@ -0,0 +1,39 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// BuilderPendingWithdrawal represents a builder pending withdrawal. +type BuilderPendingWithdrawal struct { + FeeRecipient bellatrix.ExecutionAddress `json:"fee_recipient" ssz-size:"20" yaml:"fee_recipient"` + Amount phase0.Gwei `json:"amount" yaml:"amount"` + BuilderIndex BuilderIndex `json:"builder_index" yaml:"builder_index"` +} + +// String returns a string version of the structure. +func (p *BuilderPendingWithdrawal) String() string { + data, err := yaml.Marshal(p) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/builderpendingwithdrawal_ssz.go b/spec/gloas/builderpendingwithdrawal_ssz.go new file mode 100644 index 00000000..f4726a0c --- /dev/null +++ b/spec/gloas/builderpendingwithdrawal_ssz.go @@ -0,0 +1,100 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 93a3638d29d1033f5d77582e002a25ce16b7709e67d9a577dca2e2eee05fc03a +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[BuilderPendingWithdrawal](`ssz-static:"true"`) + +// MarshalSSZ marshals the *BuilderPendingWithdrawal to SSZ-encoded bytes. +func (t *BuilderPendingWithdrawal) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *BuilderPendingWithdrawal to SSZ-encoded bytes, appending to the provided buffer. +func (t *BuilderPendingWithdrawal) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(BuilderPendingWithdrawal) + } + { // Static Field #0 'FeeRecipient' + dst = append(dst, t.FeeRecipient[:20]...) + } + { // Static Field #1 'Amount' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.Amount)) + } + { // Static Field #2 'BuilderIndex' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.BuilderIndex)) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *BuilderPendingWithdrawal from SSZ-encoded bytes. +func (t *BuilderPendingWithdrawal) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 36 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 36) + } + if buflen > 36 { + return sszutils.ErrTrailingDataFn(buflen - 36) + } + { // Field #0 'FeeRecipient' (static) + buf := buf[0:20] + copy(t.FeeRecipient[:], buf) + } + { // Field #1 'Amount' (static) + buf := buf[20:28] + t.Amount = phase0.Gwei(binary.LittleEndian.Uint64(buf)) + } + { // Field #2 'BuilderIndex' (static) + buf := buf[28:36] + t.BuilderIndex = BuilderIndex(binary.LittleEndian.Uint64(buf)) + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *BuilderPendingWithdrawal. +func (t *BuilderPendingWithdrawal) SizeSSZ() (size int) { + return 36 +} + +// HashTreeRoot computes the SSZ hash tree root of the *BuilderPendingWithdrawal. +func (t *BuilderPendingWithdrawal) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *BuilderPendingWithdrawal using the given hash walker. +func (t *BuilderPendingWithdrawal) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(BuilderPendingWithdrawal) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'FeeRecipient' + hh.PutBytes(t.FeeRecipient[:20]) + } + { // Field #1 'Amount' + hh.PutUint64(uint64(t.Amount)) + } + { // Field #2 'BuilderIndex' + hh.PutUint64(uint64(t.BuilderIndex)) + } + hh.Merkleize(idx) + return nil +} diff --git a/spec/gloas/builderrequestauth_ssz.go b/spec/gloas/builderrequestauth_ssz.go new file mode 100644 index 00000000..8cabf216 --- /dev/null +++ b/spec/gloas/builderrequestauth_ssz.go @@ -0,0 +1,119 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: edcf434f9f5d43a2893b886f062a48c7bc8d4899d0fe317e6c6c09fbb9360ddf +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[BuilderRequestAuth](`ssz-static:"false"`) + +// MarshalSSZ marshals the *BuilderRequestAuth to SSZ-encoded bytes. +func (t *BuilderRequestAuth) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *BuilderRequestAuth to SSZ-encoded bytes, appending to the provided buffer. +func (t *BuilderRequestAuth) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(BuilderRequestAuth) + } + dstlen := len(dst) + // Offset Field #0 'Data' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #1 'Slot' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.Slot)) + } + { // Dynamic Field #0 'Data' + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(len(dst)-dstlen)) + vlen := len(t.Data) + if vlen > 4096 { + return nil, sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 4096), "Data") + } + dst = append(dst, t.Data[:]...) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *BuilderRequestAuth from SSZ-encoded bytes. +func (t *BuilderRequestAuth) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 12 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 12) + } + // Field #0 'Data' (offset) + offset0 := int(binary.LittleEndian.Uint32(buf[0:4])) + if offset0 != 12 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 12), "Data:o") + } + { // Field #1 'Slot' (static) + buf := buf[4:12] + t.Slot = phase0.Slot(binary.LittleEndian.Uint64(buf)) + } + { // Field #0 'Data' (dynamic) + buf := buf[offset0:] + if len(buf) > 4096 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(len(buf), 4096), "Data") + } + t.Data = sszutils.ExpandSlice(t.Data, len(buf)) + copy(t.Data[:], buf) + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *BuilderRequestAuth. +func (t *BuilderRequestAuth) SizeSSZ() (size int) { + if t == nil { + t = new(BuilderRequestAuth) + } + // Field #0 'Data' offset (4 bytes) + // Field #1 'Slot' static (8 bytes) + size += 12 + { // Dynamic field #0 'Data' + size += len(t.Data) + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *BuilderRequestAuth. +func (t *BuilderRequestAuth) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *BuilderRequestAuth using the given hash walker. +func (t *BuilderRequestAuth) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(BuilderRequestAuth) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Data' + vlen := uint64(len(t.Data)) + if vlen > 4096 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 4096), "Data") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + hh.AppendBytes32(t.Data[:]) + hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(4096, vlen, 1)) + } + { // Field #1 'Slot' + hh.PutUint64(uint64(t.Slot)) + } + hh.MerkleizeProgressiveWithActiveFields(idx, []byte{0x03}) + return nil +} diff --git a/spec/gloas/consensusspec_test.go b/spec/gloas/consensusspec_test.go new file mode 100644 index 00000000..5d02a602 --- /dev/null +++ b/spec/gloas/consensusspec_test.go @@ -0,0 +1,394 @@ +// Copyright © 2025 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas_test + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" + "github.com/golang/snappy" + clone "github.com/huandu/go-clone/generic" + "github.com/pk910/dynamic-ssz/sszutils" + require "github.com/stretchr/testify/require" + specyaml "gopkg.in/yaml.v3" +) + +// TestConsensusSpec tests the types against the Ethereum consensus spec tests. +func TestConsensusSpec(t *testing.T) { + if os.Getenv("CONSENSUS_SPEC_TESTS_DIR") == "" { + t.Skip("CONSENSUS_SPEC_TESTS_DIR not supplied, not running spec tests") + } + + tests := []struct { + name string + s any + yamlValue any + }{ + { + name: "AggregateAndProof", + s: &gloas.AggregateAndProof{}, + }, + { + name: "Attestation", + s: &gloas.Attestation{}, + }, + { + name: "AttestationData", + s: &phase0.AttestationData{}, + }, + { + name: "AttesterSlashing", + s: &gloas.AttesterSlashing{}, + }, + { + name: "BeaconBlock", + s: &gloas.BeaconBlock{}, + }, + { + name: "BeaconBlockBody", + s: &gloas.BeaconBlockBody{}, + }, + { + name: "BeaconBlockHeader", + s: &phase0.BeaconBlockHeader{}, + }, + { + name: "BeaconState", + s: &gloas.BeaconState{}, + }, + { + name: "BLSToExecutionChange", + s: &capella.BLSToExecutionChange{}, + }, + { + name: "Builder", + s: &gloas.Builder{}, + }, + { + name: "BuilderDepositRequest", + s: &gloas.BuilderDepositRequest{}, + }, + { + name: "BuilderExitRequest", + s: &gloas.BuilderExitRequest{}, + }, + { + name: "BuilderPendingPayment", + s: &gloas.BuilderPendingPayment{}, + }, + { + name: "BuilderPendingWithdrawal", + s: &gloas.BuilderPendingWithdrawal{}, + }, + { + name: "Checkpoint", + s: &phase0.Checkpoint{}, + }, + { + name: "ConsolidationRequest", + s: &electra.ConsolidationRequest{}, + }, + { + name: "ContributionAndProof", + s: &altair.ContributionAndProof{}, + }, + { + name: "Deposit", + s: &phase0.Deposit{}, + }, + { + name: "DepositData", + s: &phase0.DepositData{}, + }, + { + name: "DepositMessage", + s: &phase0.DepositMessage{}, + }, + { + name: "DepositRequest", + s: &electra.DepositRequest{}, + }, + { + name: "Eth1Data", + s: &phase0.ETH1Data{}, + }, + { + name: "ExecutionPayload", + s: &gloas.ExecutionPayload{}, + }, + { + name: "ExecutionPayloadBid", + s: &gloas.ExecutionPayloadBid{}, + }, + { + name: "ExecutionPayloadEnvelope", + s: &gloas.ExecutionPayloadEnvelope{}, + }, + { + name: "ExecutionRequests", + s: &gloas.ExecutionRequests{}, + }, + { + name: "Fork", + s: &phase0.Fork{}, + }, + { + name: "ForkData", + s: &phase0.ForkData{}, + }, + { + name: "HistoricalSummary", + s: &capella.HistoricalSummary{}, + }, + { + name: "IndexedAttestation", + s: &gloas.IndexedAttestation{}, + }, + { + name: "IndexedPayloadAttestation", + s: &gloas.IndexedPayloadAttestation{}, + }, + { + name: "PayloadAttestation", + s: &gloas.PayloadAttestation{}, + }, + { + name: "PayloadAttestationData", + s: &gloas.PayloadAttestationData{}, + }, + { + name: "PayloadAttestationMessage", + s: &gloas.PayloadAttestationMessage{}, + }, + { + name: "PendingConsolidation", + s: &electra.PendingConsolidation{}, + }, + { + name: "PendingDeposit", + s: &electra.PendingDeposit{}, + }, + { + name: "PendingPartialWithdrawal", + s: &electra.PendingPartialWithdrawal{}, + }, + { + name: "ProposerPreferences", + s: &gloas.ProposerPreferences{}, + }, + { + name: "ProposerSlashing", + s: &phase0.ProposerSlashing{}, + }, + { + name: "SignedAggregateAndProof", + s: &gloas.SignedAggregateAndProof{}, + }, + { + name: "SignedBeaconBlock", + s: &gloas.SignedBeaconBlock{}, + }, + { + name: "SignedBeaconBlockHeader", + s: &phase0.SignedBeaconBlockHeader{}, + }, + { + name: "SignedBLSToExecutionChange", + s: &capella.SignedBLSToExecutionChange{}, + }, + { + name: "SignedContributionAndProof", + s: &altair.SignedContributionAndProof{}, + }, + { + name: "SignedExecutionPayloadBid", + s: &gloas.SignedExecutionPayloadBid{}, + }, + { + name: "SignedExecutionPayloadEnvelope", + s: &gloas.SignedExecutionPayloadEnvelope{}, + }, + { + name: "SignedProposerPreferences", + s: &gloas.SignedProposerPreferences{}, + }, + { + name: "SignedVoluntaryExit", + s: &phase0.SignedVoluntaryExit{}, + }, + { + name: "SingleAttestation", + s: &electra.SingleAttestation{}, + }, + { + name: "SyncAggregate", + s: &altair.SyncAggregate{}, + }, + { + name: "SyncAggregatorSelectionData", + s: &altair.SyncAggregatorSelectionData{}, + yamlValue: &syncAggregatorSelectionDataVector{}, + }, + { + name: "SyncCommittee", + s: &altair.SyncCommittee{}, + }, + { + name: "SyncCommitteeContribution", + s: &altair.SyncCommitteeContribution{}, + }, + { + name: "SyncCommitteeMessage", + s: &altair.SyncCommitteeMessage{}, + }, + { + name: "Validator", + s: &phase0.Validator{}, + }, + { + name: "VoluntaryExit", + s: &phase0.VoluntaryExit{}, + }, + { + name: "Withdrawal", + s: &capella.Withdrawal{}, + }, + { + name: "WithdrawalRequest", + s: &electra.WithdrawalRequest{}, + }, + } + + baseDir := filepath.Join(os.Getenv("CONSENSUS_SPEC_TESTS_DIR"), "tests", "mainnet", "gloas", "ssz_static") + for _, test := range tests { + dir := filepath.Join(baseDir, test.name, "ssz_random") + require.NoError(t, filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if path == dir { + // Only interested in subdirectories. + return nil + } + require.NoError(t, err) + if info.IsDir() { + t.Run(fmt.Sprintf("%s/%s", test.name, info.Name()), func(t *testing.T) { + yamlValue := test.s + if test.yamlValue != nil { + yamlValue = test.yamlValue + } + s1 := clone.Clone(yamlValue) + // Obtain the struct from the YAML. + specYAML, err := os.ReadFile(filepath.Join(path, "value.yaml")) + require.NoError(t, err) + var vector specyaml.Node + require.NoError(t, specyaml.Unmarshal(specYAML, &vector)) + blockVectorFlow(&vector) + specYAML, err = specyaml.Marshal(&vector) + require.NoError(t, err) + require.NoError(t, yaml.Unmarshal(specYAML, s1)) + // Confirm we can return to the YAML. + remarshalledSpecYAML, err := yaml.Marshal(s1) + require.NoError(t, err) + require.YAMLEq(t, testYAMLFormat(specYAML), testYAMLFormat(remarshalledSpecYAML)) + + // Obtain the struct from the SSZ. + s2 := clone.Clone(test.s) + compressedSpecSSZ, err := os.ReadFile(filepath.Join(path, "serialized.ssz_snappy")) + require.NoError(t, err) + var specSSZ []byte + specSSZ, err = snappy.Decode(specSSZ, compressedSpecSSZ) + require.NoError(t, err) + require.NoError(t, s2.(sszutils.FastsszUnmarshaler).UnmarshalSSZ(specSSZ)) + // Confirm we can return to the SSZ. + remarshalledSpecSSZ, err := s2.(sszutils.FastsszMarshaler).MarshalSSZ() + require.NoError(t, err) + require.Equal(t, specSSZ, remarshalledSpecSSZ) + + // Obtain the hash tree root from the YAML. + specYAMLRoot, err := os.ReadFile(filepath.Join(path, "roots.yaml")) + require.NoError(t, err) + // Confirm we calculate the same root. + generatedRootBytes, err := s2.(sszutils.FastsszHashRoot).HashTreeRoot() + require.NoError(t, err) + generatedRoot := fmt.Sprintf("{root: '%#x'}\n", string(generatedRootBytes[:])) + require.YAMLEq(t, string(specYAMLRoot), generatedRoot) + }) + } + + return nil + })) + } +} + +func testYAMLFormat(input []byte) string { + val := make(map[string]any) + if err := yaml.Unmarshal(input, &val); err != nil { + panic(err) + } + + res, err := yaml.MarshalWithOptions(normalizeVectorNumbers(val), yaml.Flow(true)) + if err != nil { + panic(err) + } + + replacements := [][][]byte{ + {[]byte(`"`), []byte(`'`)}, + // Field 'extra_data' in ExecutionPayloadHeader/case_1 has a non-standard format, fix here. + {[]byte(`extra_data: 0,`), []byte(`extra_data: '0x',`)}, + } + for _, replacement := range replacements { + res = bytes.ReplaceAll(res, replacement[0], replacement[1]) + } + + return string(bytes.ToLower(res)) +} + +// The Altair type has no YAML field tag; this fixture shape does not change its public codec. +type syncAggregatorSelectionDataVector struct { + Slot phase0.Slot `yaml:"slot"` + SubcommitteeIndex uint64 `yaml:"subcommittee_index"` +} + +func blockVectorFlow(node *specyaml.Node) { + if node.Kind == specyaml.MappingNode || node.Kind == specyaml.SequenceNode { + node.Style &^= specyaml.FlowStyle + } + for _, child := range node.Content { + blockVectorFlow(child) + } +} + +func normalizeVectorNumbers(value any) any { + switch v := value.(type) { + case map[string]any: + for key, item := range v { + v[key] = normalizeVectorNumbers(item) + } + case []any: + for i, item := range v { + v[i] = normalizeVectorNumbers(item) + } + case int, int64, uint, uint64: + return fmt.Sprint(value) + } + + return value +} diff --git a/spec/gloas/executionpayload.go b/spec/gloas/executionpayload.go new file mode 100644 index 00000000..4c2b3d85 --- /dev/null +++ b/spec/gloas/executionpayload.go @@ -0,0 +1,60 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" + "github.com/holiman/uint256" +) + +// BlockAccessList is the RLP-encoded block access list (EIP-7928). +type BlockAccessList []byte + +// ExecutionPayload represents an execution layer payload. +type ExecutionPayload struct { + ParentHash phase0.Hash32 `ssz-index:"0"` + FeeRecipient bellatrix.ExecutionAddress `ssz-index:"1"` + StateRoot phase0.Root `ssz-index:"2"` + ReceiptsRoot phase0.Root `ssz-index:"3"` + LogsBloom [256]byte `ssz-index:"4"` + PrevRandao [32]byte `ssz-index:"5"` + BlockNumber uint64 `ssz-index:"6"` + GasLimit uint64 `ssz-index:"7"` + GasUsed uint64 `ssz-index:"8"` + Timestamp uint64 `ssz-index:"9"` + ExtraData []byte `dynssz-max:"MAX_EXTRA_DATA_BYTES" ssz-index:"10" ssz-max:"32"` + BaseFeePerGas *uint256.Int `ssz-index:"11" ssz-type:"uint256"` + BlockHash phase0.Hash32 `ssz-index:"12"` + Transactions []bellatrix.Transaction `ssz-index:"13" ssz-type:"progressive-list,progressive-list"` + Withdrawals []*capella.Withdrawal `ssz-index:"14" ssz-type:"progressive-list"` + BlobGasUsed uint64 `ssz-index:"15"` + ExcessBlobGas uint64 `ssz-index:"16"` + BlockAccessList BlockAccessList `ssz-index:"17" ssz-type:"progressive-list"` + SlotNumber uint64 `ssz-index:"18"` +} + +// String returns a string version of the structure. +func (e *ExecutionPayload) String() string { + data, err := yaml.Marshal(e) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/executionpayload_json.go b/spec/gloas/executionpayload_json.go new file mode 100644 index 00000000..1a10983b --- /dev/null +++ b/spec/gloas/executionpayload_json.go @@ -0,0 +1,321 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + + "github.com/attestantio/go-eth2-client/codecs" + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/holiman/uint256" + "github.com/pkg/errors" +) + +// executionPayloadJSON is the spec representation of the struct. +type executionPayloadJSON struct { + ParentHash phase0.Hash32 `json:"parent_hash"` + FeeRecipient bellatrix.ExecutionAddress `json:"fee_recipient"` + StateRoot phase0.Root `json:"state_root"` + ReceiptsRoot phase0.Root `json:"receipts_root"` + LogsBloom string `json:"logs_bloom"` + PrevRandao string `json:"prev_randao"` + BlockNumber string `json:"block_number"` + GasLimit string `json:"gas_limit"` + GasUsed string `json:"gas_used"` + Timestamp string `json:"timestamp"` + ExtraData string `json:"extra_data"` + BaseFeePerGas string `json:"base_fee_per_gas"` + BlockHash phase0.Hash32 `json:"block_hash"` + Transactions []string `json:"transactions"` + Withdrawals []*capella.Withdrawal `json:"withdrawals"` + BlobGasUsed string `json:"blob_gas_used"` + ExcessBlobGas string `json:"excess_blob_gas"` + BlockAccessList string `json:"block_access_list"` + SlotNumber string `json:"slot_number"` +} + +// MarshalJSON implements json.Marshaler. +func (e *ExecutionPayload) MarshalJSON() ([]byte, error) { + transactions := make([]string, len(e.Transactions)) + for i := range e.Transactions { + transactions[i] = "0x" + if len(e.Transactions[i]) > 0 { + transactions[i] = fmt.Sprintf("%#x", e.Transactions[i]) + } + } + + extraData := "0x" + if len(e.ExtraData) > 0 { + extraData = fmt.Sprintf("%#x", e.ExtraData) + } + + blockAccessList := "0x" + if len(e.BlockAccessList) > 0 { + blockAccessList = fmt.Sprintf("%#x", e.BlockAccessList) + } + + // BaseFeePerGas is a nilable *uint256.Int; guard the nil case as the SSZ + // marshaler does so a zero-value payload does not panic on .Dec(). + baseFeePerGas := e.BaseFeePerGas + if baseFeePerGas == nil { + baseFeePerGas = new(uint256.Int) + } + + return json.Marshal(&executionPayloadJSON{ + ParentHash: e.ParentHash, + FeeRecipient: e.FeeRecipient, + StateRoot: e.StateRoot, + ReceiptsRoot: e.ReceiptsRoot, + LogsBloom: fmt.Sprintf("%#x", e.LogsBloom), + PrevRandao: fmt.Sprintf("%#x", e.PrevRandao), + BlockNumber: strconv.FormatUint(e.BlockNumber, 10), + GasLimit: strconv.FormatUint(e.GasLimit, 10), + GasUsed: strconv.FormatUint(e.GasUsed, 10), + Timestamp: strconv.FormatUint(e.Timestamp, 10), + ExtraData: extraData, + BaseFeePerGas: baseFeePerGas.Dec(), + BlockHash: e.BlockHash, + Transactions: transactions, + Withdrawals: e.Withdrawals, + BlobGasUsed: strconv.FormatUint(e.BlobGasUsed, 10), + ExcessBlobGas: strconv.FormatUint(e.ExcessBlobGas, 10), + BlockAccessList: blockAccessList, + SlotNumber: strconv.FormatUint(e.SlotNumber, 10), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +// +//nolint:gocyclo +func (e *ExecutionPayload) UnmarshalJSON(input []byte) error { + raw, err := codecs.RawJSON(&executionPayloadJSON{}, input) + if err != nil { + return err + } + + if err := e.ParentHash.UnmarshalJSON(raw["parent_hash"]); err != nil { + return errors.Wrap(err, "parent_hash") + } + + if err := e.FeeRecipient.UnmarshalJSON(raw["fee_recipient"]); err != nil { + return errors.Wrap(err, "fee_recipient") + } + + if err := e.StateRoot.UnmarshalJSON(raw["state_root"]); err != nil { + return errors.Wrap(err, "state_root") + } + + if err := e.ReceiptsRoot.UnmarshalJSON(raw["receipts_root"]); err != nil { + return errors.Wrap(err, "receipts_root") + } + + logsBloom := raw["logs_bloom"] + if !bytes.HasPrefix(logsBloom, []byte{'"', '0', 'x'}) { + return errors.New("logs_bloom: invalid prefix") + } + + if !bytes.HasSuffix(logsBloom, []byte{'"'}) { + return errors.New("logs_bloom: invalid suffix") + } + + if len(logsBloom) != 1+2+256*2+1 { + return errors.New("logs_bloom: incorrect length") + } + + length, err := hex.Decode(e.LogsBloom[:], logsBloom[3:3+256*2]) + if err != nil { + return errors.Wrap(err, "logs_bloom") + } + + if length != 256 { + return errors.New("logs_bloom: incorrect length") + } + + prevRandao := raw["prev_randao"] + if !bytes.HasPrefix(prevRandao, []byte{'"', '0', 'x'}) { + return errors.New("prev_randao: invalid prefix") + } + + if !bytes.HasSuffix(prevRandao, []byte{'"'}) { + return errors.New("prev_randao: invalid suffix") + } + + if len(prevRandao) != 1+2+32*2+1 { + return errors.New("prev_randao: incorrect length") + } + + length, err = hex.Decode(e.PrevRandao[:], prevRandao[3:3+32*2]) + if err != nil { + return errors.Wrap(err, "prev_randao") + } + + if length != 32 { + return errors.New("prev_randao: incorrect length") + } + + tmpUint, err := strconv.ParseUint(string(bytes.Trim(raw["block_number"], `"`)), 10, 64) + if err != nil { + return errors.Wrap(err, "block_number") + } + + e.BlockNumber = tmpUint + + tmpUint, err = strconv.ParseUint(string(bytes.Trim(raw["gas_limit"], `"`)), 10, 64) + if err != nil { + return errors.Wrap(err, "gas_limit") + } + + e.GasLimit = tmpUint + + tmpUint, err = strconv.ParseUint(string(bytes.Trim(raw["gas_used"], `"`)), 10, 64) + if err != nil { + return errors.Wrap(err, "gas_used") + } + + e.GasUsed = tmpUint + + tmpUint, err = strconv.ParseUint(string(bytes.Trim(raw["timestamp"], `"`)), 10, 64) + if err != nil { + return errors.Wrap(err, "timestamp") + } + + e.Timestamp = tmpUint + + var tmpBytes []byte + + switch { + case bytes.Equal(raw["extra_data"], []byte{'0', 'x'}), bytes.Equal(raw["extra_data"], []byte{'0'}): + // Empty. + default: + tmpBytes = bytes.TrimPrefix(bytes.Trim(raw["extra_data"], `"`), []byte{'0', 'x'}) + if len(tmpBytes)%2 == 1 { + tmpBytes = fmt.Appendf(nil, "0%s", string(tmpBytes)) + } + + tmp, err := hex.DecodeString(string(tmpBytes)) + if err != nil { + return errors.Wrap(err, "extra_data") + } + + if len(tmp) > 32 { + return errors.New("extra_data: incorrect length") + } + + e.ExtraData = tmp + } + + tmpBytes = bytes.Trim(raw["base_fee_per_gas"], `"`) + + tmpBytes = bytes.TrimPrefix(tmpBytes, []byte{'0', 'x'}) + if bytes.HasPrefix(tmpBytes, []byte{'0', 'x'}) { + e.BaseFeePerGas, err = uint256.FromHex(string(tmpBytes)) + } else { + e.BaseFeePerGas, err = uint256.FromDecimal(string(tmpBytes)) + } + + if err != nil { + return errors.Wrap(err, "base_fee_per_gas") + } + + if err := e.BlockHash.UnmarshalJSON(raw["block_hash"]); err != nil { + return errors.Wrap(err, "block_hash") + } + + transactions := make([]json.RawMessage, 0) + if err := json.Unmarshal(raw["transactions"], &transactions); err != nil { + return errors.Wrap(err, "transactions") + } + + if len(transactions) > bellatrix.MaxTransactionsPerPayload { + return errors.Wrap(err, "incorrect length for transactions") + } + + e.Transactions = make([]bellatrix.Transaction, len(transactions)) + for i := range transactions { + // "0x" is a valid empty transaction. Shorter values cannot carry + // the prefix and would underflow the allocation below. + if len(transactions[i]) < 4 { + return fmt.Errorf("transaction %d: missing or malformed", i) + } + + e.Transactions[i] = make([]byte, (len(transactions[i])-4)/2) + if err := json.Unmarshal(transactions[i], &e.Transactions[i]); err != nil { + return errors.Wrapf(err, "transaction %d", i) + } + + if len(e.Transactions[i]) > bellatrix.MaxBytesPerTransaction { + return errors.Wrapf(err, "incorrect length for transaction %d", i) + } + } + + if err := json.Unmarshal(raw["withdrawals"], &e.Withdrawals); err != nil { + return errors.Wrap(err, "withdrawals") + } + + for i := range e.Withdrawals { + if e.Withdrawals[i] == nil { + return fmt.Errorf("withdrawals entry %d missing", i) + } + } + + tmpUint, err = strconv.ParseUint(string(bytes.Trim(raw["blob_gas_used"], `"`)), 10, 64) + if err != nil { + return errors.Wrap(err, "blob_gas_used") + } + + e.BlobGasUsed = tmpUint + + tmpUint, err = strconv.ParseUint(string(bytes.Trim(raw["excess_blob_gas"], `"`)), 10, 64) + if err != nil { + return errors.Wrap(err, "excess_blob_gas") + } + + e.ExcessBlobGas = tmpUint + + switch { + case bytes.Equal(raw["block_access_list"], []byte{'0', 'x'}), bytes.Equal(raw["block_access_list"], []byte{'0'}): + // Empty. + default: + tmpBytes = bytes.TrimPrefix(bytes.Trim(raw["block_access_list"], `"`), []byte{'0', 'x'}) + if len(tmpBytes)%2 == 1 { + tmpBytes = fmt.Appendf(nil, "0%s", string(tmpBytes)) + } + + tmp, err := hex.DecodeString(string(tmpBytes)) + if err != nil { + return errors.Wrap(err, "block_access_list") + } + + if len(tmp) > bellatrix.MaxBytesPerTransaction { + return errors.New("block_access_list: incorrect length") + } + + e.BlockAccessList = tmp + } + + tmpUint, err = strconv.ParseUint(string(bytes.Trim(raw["slot_number"], `"`)), 10, 64) + if err != nil { + return errors.Wrap(err, "slot_number") + } + + e.SlotNumber = tmpUint + + return nil +} diff --git a/spec/gloas/executionpayload_ssz.go b/spec/gloas/executionpayload_ssz.go new file mode 100644 index 00000000..35ba6686 --- /dev/null +++ b/spec/gloas/executionpayload_ssz.go @@ -0,0 +1,460 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 81e5a47d626f6521c02c01a2b484460fd986437545dc852251014056c19596d0 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/holiman/uint256" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[ExecutionPayload](`ssz-static:"false"`) + +// MarshalSSZ marshals the *ExecutionPayload to SSZ-encoded bytes. +func (t *ExecutionPayload) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *ExecutionPayload to SSZ-encoded bytes, appending to the provided buffer. +func (t *ExecutionPayload) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(ExecutionPayload) + } + dstlen := len(dst) + { // Static Field #0 'ParentHash' + dst = append(dst, t.ParentHash[:32]...) + } + { // Static Field #1 'FeeRecipient' + dst = append(dst, t.FeeRecipient[:20]...) + } + { // Static Field #2 'StateRoot' + dst = append(dst, t.StateRoot[:32]...) + } + { // Static Field #3 'ReceiptsRoot' + dst = append(dst, t.ReceiptsRoot[:32]...) + } + { // Static Field #4 'LogsBloom' + dst = append(dst, t.LogsBloom[:256]...) + } + { // Static Field #5 'PrevRandao' + dst = append(dst, t.PrevRandao[:32]...) + } + { // Static Field #6 'BlockNumber' + dst = binary.LittleEndian.AppendUint64(dst, t.BlockNumber) + } + { // Static Field #7 'GasLimit' + dst = binary.LittleEndian.AppendUint64(dst, t.GasLimit) + } + { // Static Field #8 'GasUsed' + dst = binary.LittleEndian.AppendUint64(dst, t.GasUsed) + } + { // Static Field #9 'Timestamp' + dst = binary.LittleEndian.AppendUint64(dst, t.Timestamp) + } + // Offset Field #10 'ExtraData' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #11 'BaseFeePerGas' + t := t.BaseFeePerGas + if t == nil { + t = new(uint256.Int) + } + dst = sszutils.MarshalUint64Slice(dst, (*t)[:4]) + } + { // Static Field #12 'BlockHash' + dst = append(dst, t.BlockHash[:32]...) + } + // Offset Field #13 'Transactions' + // Offset Field #14 'Withdrawals' + dst = append(dst, 0, 0, 0, 0, 0, 0, 0, 0) + { // Static Field #15 'BlobGasUsed' + dst = binary.LittleEndian.AppendUint64(dst, t.BlobGasUsed) + } + { // Static Field #16 'ExcessBlobGas' + dst = binary.LittleEndian.AppendUint64(dst, t.ExcessBlobGas) + } + // Offset Field #17 'BlockAccessList' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #18 'SlotNumber' + dst = binary.LittleEndian.AppendUint64(dst, t.SlotNumber) + } + { // Dynamic Field #10 'ExtraData' + binary.LittleEndian.PutUint32(dst[dstlen+436:], uint32(len(dst)-dstlen)) + vlen := len(t.ExtraData) + if vlen > 32 { + return nil, sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 32), "ExtraData") + } + dst = append(dst, t.ExtraData[:]...) + } + { // Dynamic Field #13 'Transactions' + binary.LittleEndian.PutUint32(dst[dstlen+504:], uint32(len(dst)-dstlen)) + t := t.Transactions + dstlen := len(dst) + vlen := len(t) + dst = sszutils.AppendZeroPadding(dst, vlen*4) + for idx1 := range vlen { + binary.LittleEndian.PutUint32(dst[dstlen+(idx1*4):], uint32(len(dst)-dstlen)) + t := t[idx1] + dst = append(dst, t[:]...) + } + } + { // Dynamic Field #14 'Withdrawals' + binary.LittleEndian.PutUint32(dst[dstlen+508:], uint32(len(dst)-dstlen)) + t := t.Withdrawals + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(capella.Withdrawal) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "Withdrawals[%d]", idx1) + } + } + } + { // Dynamic Field #17 'BlockAccessList' + binary.LittleEndian.PutUint32(dst[dstlen+528:], uint32(len(dst)-dstlen)) + t := t.BlockAccessList + dst = append(dst, t[:]...) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *ExecutionPayload from SSZ-encoded bytes. +func (t *ExecutionPayload) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 540 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 540) + } + { // Field #0 'ParentHash' (static) + buf := buf[0:32] + copy(t.ParentHash[:], buf) + } + { // Field #1 'FeeRecipient' (static) + buf := buf[32:52] + copy(t.FeeRecipient[:], buf) + } + { // Field #2 'StateRoot' (static) + buf := buf[52:84] + copy(t.StateRoot[:], buf) + } + { // Field #3 'ReceiptsRoot' (static) + buf := buf[84:116] + copy(t.ReceiptsRoot[:], buf) + } + { // Field #4 'LogsBloom' (static) + buf := buf[116:372] + copy(t.LogsBloom[:], buf) + } + { // Field #5 'PrevRandao' (static) + buf := buf[372:404] + copy(t.PrevRandao[:], buf) + } + { // Field #6 'BlockNumber' (static) + buf := buf[404:412] + t.BlockNumber = binary.LittleEndian.Uint64(buf) + } + { // Field #7 'GasLimit' (static) + buf := buf[412:420] + t.GasLimit = binary.LittleEndian.Uint64(buf) + } + { // Field #8 'GasUsed' (static) + buf := buf[420:428] + t.GasUsed = binary.LittleEndian.Uint64(buf) + } + { // Field #9 'Timestamp' (static) + buf := buf[428:436] + t.Timestamp = binary.LittleEndian.Uint64(buf) + } + // Field #10 'ExtraData' (offset) + offset10 := int(binary.LittleEndian.Uint32(buf[436:440])) + if offset10 != 540 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset10, 540), "ExtraData:o") + } + { // Field #11 'BaseFeePerGas' (static) + buf := buf[440:472] + val1 := t.BaseFeePerGas + if val1 == nil { + val1 = new(uint256.Int) + } + sszutils.UnmarshalUint64Slice((*val1)[:4], buf) + t.BaseFeePerGas = val1 + } + { // Field #12 'BlockHash' (static) + buf := buf[472:504] + copy(t.BlockHash[:], buf) + } + // Field #13 'Transactions' (offset) + offset13 := int(binary.LittleEndian.Uint32(buf[504:508])) + if offset13 < offset10 || offset13 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset13, offset10, buflen), "Transactions:o") + } + // Field #14 'Withdrawals' (offset) + offset14 := int(binary.LittleEndian.Uint32(buf[508:512])) + if offset14 < offset13 || offset14 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset14, offset13, buflen), "Withdrawals:o") + } + { // Field #15 'BlobGasUsed' (static) + buf := buf[512:520] + t.BlobGasUsed = binary.LittleEndian.Uint64(buf) + } + { // Field #16 'ExcessBlobGas' (static) + buf := buf[520:528] + t.ExcessBlobGas = binary.LittleEndian.Uint64(buf) + } + // Field #17 'BlockAccessList' (offset) + offset17 := int(binary.LittleEndian.Uint32(buf[528:532])) + if offset17 < offset14 || offset17 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset17, offset14, buflen), "BlockAccessList:o") + } + { // Field #18 'SlotNumber' (static) + buf := buf[532:540] + t.SlotNumber = binary.LittleEndian.Uint64(buf) + } + { // Field #10 'ExtraData' (dynamic) + buf := buf[offset10:offset13] + if len(buf) > 32 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(len(buf), 32), "ExtraData") + } + t.ExtraData = sszutils.ExpandSlice(t.ExtraData, len(buf)) + copy(t.ExtraData[:], buf) + } + { // Field #13 'Transactions' (dynamic) + buf := buf[offset13:offset14] + val2 := t.Transactions + startOffset := int(0) + if len(buf) != 0 { + if len(buf) < 4 { + return sszutils.ErrorWithPath(sszutils.ErrListOffsetsEOFFn(len(buf), 4), "Transactions") + } + startOffset = int(binary.LittleEndian.Uint32(buf[0:4])) + } + itemCount := startOffset / 4 + if startOffset%4 != 0 || len(buf) < startOffset || (len(buf) != 0 && startOffset == 0) { + return sszutils.ErrorWithPath(sszutils.ErrInvalidListStartOffsetFn(startOffset, len(buf)), "Transactions") + } + val2 = sszutils.ExpandSlice(val2, itemCount) + for idx1 := range itemCount { + var endOffset int + if idx1 < itemCount-1 { + endOffset = int(binary.LittleEndian.Uint32(buf[(idx1+1)*4 : (idx1+2)*4])) + } else { + endOffset = len(buf) + } + if endOffset < startOffset || endOffset > len(buf) { + return sszutils.ErrorWithPathf(sszutils.ErrElementOffsetOutOfRangeFn(endOffset, startOffset, len(buf)), "Transactions[%d]", idx1) + } + buf := buf[startOffset:endOffset] + startOffset = endOffset + val3 := val2[idx1] + val3 = sszutils.ExpandSlice(val3, len(buf)) + copy(val3[:], buf) + val2[idx1] = val3 + } + t.Transactions = val2 + } + { // Field #14 'Withdrawals' (dynamic) + buf := buf[offset14:offset17] + val4 := t.Withdrawals + itemCount := len(buf) / 44 + if len(buf)%44 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 44), "Withdrawals") + } + val4 = sszutils.ExpandSlice(val4, itemCount) + for idx1 := range itemCount { + if val4[idx1] == nil { + val4[idx1] = new(capella.Withdrawal) + } + buf := buf[44*idx1 : 44*(idx1+1)] + if err = val4[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "Withdrawals[%d]", idx1) + } + } + t.Withdrawals = val4 + } + { // Field #17 'BlockAccessList' (dynamic) + buf := buf[offset17:] + val5 := t.BlockAccessList + val5 = sszutils.ExpandSlice(val5, len(buf)) + copy(val5[:], buf) + t.BlockAccessList = val5 + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *ExecutionPayload. +func (t *ExecutionPayload) SizeSSZ() (size int) { + if t == nil { + t = new(ExecutionPayload) + } + // Field #0 'ParentHash' static (32 bytes) + // Field #1 'FeeRecipient' static (20 bytes) + // Field #2 'StateRoot' static (32 bytes) + // Field #3 'ReceiptsRoot' static (32 bytes) + // Field #4 'LogsBloom' static (256 bytes) + // Field #5 'PrevRandao' static (32 bytes) + // Field #6 'BlockNumber' static (8 bytes) + // Field #7 'GasLimit' static (8 bytes) + // Field #8 'GasUsed' static (8 bytes) + // Field #9 'Timestamp' static (8 bytes) + // Field #10 'ExtraData' offset (4 bytes) + // Field #11 'BaseFeePerGas' static (32 bytes) + // Field #12 'BlockHash' static (32 bytes) + // Field #13 'Transactions' offset (4 bytes) + // Field #14 'Withdrawals' offset (4 bytes) + // Field #15 'BlobGasUsed' static (8 bytes) + // Field #16 'ExcessBlobGas' static (8 bytes) + // Field #17 'BlockAccessList' offset (4 bytes) + // Field #18 'SlotNumber' static (8 bytes) + size += 540 + { // Dynamic field #10 'ExtraData' + size += len(t.ExtraData) + } + { // Dynamic field #13 'Transactions' + t := t.Transactions + vlen := len(t) + size += vlen * 4 // Offsets + for i1 := range vlen { + size += len(t[i1]) + } + } + { // Dynamic field #14 'Withdrawals' + size += len(t.Withdrawals) * 44 + } + { // Dynamic field #17 'BlockAccessList' + size += len(t.BlockAccessList) + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *ExecutionPayload. +func (t *ExecutionPayload) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *ExecutionPayload using the given hash walker. +func (t *ExecutionPayload) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(ExecutionPayload) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'ParentHash' + hh.PutBytes(t.ParentHash[:32]) + } + { // Field #1 'FeeRecipient' + hh.PutBytes(t.FeeRecipient[:20]) + } + { // Field #2 'StateRoot' + hh.PutBytes(t.StateRoot[:32]) + } + { // Field #3 'ReceiptsRoot' + hh.PutBytes(t.ReceiptsRoot[:32]) + } + { // Field #4 'LogsBloom' + hh.PutBytes(t.LogsBloom[:256]) + } + { // Field #5 'PrevRandao' + hh.PutBytes(t.PrevRandao[:32]) + } + { // Field #6 'BlockNumber' + hh.PutUint64(t.BlockNumber) + } + { // Field #7 'GasLimit' + hh.PutUint64(t.GasLimit) + } + { // Field #8 'GasUsed' + hh.PutUint64(t.GasUsed) + } + { // Field #9 'Timestamp' + hh.PutUint64(t.Timestamp) + } + { // Field #10 'ExtraData' + vlen := uint64(len(t.ExtraData)) + if vlen > 32 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 32), "ExtraData") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + hh.AppendBytes32(t.ExtraData[:]) + hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(32, vlen, 1)) + } + { // Field #11 'BaseFeePerGas' + t := t.BaseFeePerGas + if t == nil { + t = new(uint256.Int) + } + if root, err := t.HashTreeRoot(); err != nil { + return sszutils.ErrorWithPath(err, "BaseFeePerGas") + } else { + hh.AppendBytes32(root[:]) + } + } + { // Field #12 'BlockHash' + hh.PutBytes(t.BlockHash[:32]) + } + { // Field #13 'Transactions' + t := t.Transactions + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + idx := hh.StartTree(sszutils.TreeTypeProgressive) + hh.AppendBytes32(t[:]) + vlen := uint64(len(t)) + hh.MerkleizeProgressiveWithMixin(idx, vlen) + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #14 'Withdrawals' + t := t.Withdrawals + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(capella.Withdrawal) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "Withdrawals[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #15 'BlobGasUsed' + hh.PutUint64(t.BlobGasUsed) + } + { // Field #16 'ExcessBlobGas' + hh.PutUint64(t.ExcessBlobGas) + } + { // Field #17 'BlockAccessList' + t := t.BlockAccessList + idx := hh.StartTree(sszutils.TreeTypeProgressive) + hh.AppendBytes32(t[:]) + vlen := uint64(len(t)) + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #18 'SlotNumber' + hh.PutUint64(t.SlotNumber) + } + hh.MerkleizeProgressiveWithActiveFields(idx, []byte{0xff, 0xff, 0x07}) + return nil +} diff --git a/spec/gloas/executionpayload_test.go b/spec/gloas/executionpayload_test.go new file mode 100644 index 00000000..7ecb3ba8 --- /dev/null +++ b/spec/gloas/executionpayload_test.go @@ -0,0 +1,157 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas_test + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/goccy/go-yaml" + "github.com/holiman/uint256" + require "github.com/stretchr/testify/require" +) + +// TestExecutionPayloadMarshalJSON verifies that marshaling does not panic on a +// zero-value payload, whose BaseFeePerGas (*uint256.Int) is nil — mirroring the +// nil guard the SSZ marshaler already applies. +func TestExecutionPayloadMarshalJSON(t *testing.T) { + tests := []struct { + name string + payload *gloas.ExecutionPayload + expected string + }{ + { + name: "NilBaseFeePerGas", + payload: &gloas.ExecutionPayload{}, + expected: `"base_fee_per_gas":"0"`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var data []byte + var err error + require.NotPanics(t, func() { + data, err = json.Marshal(test.payload) + }) + require.NoError(t, err) + require.Contains(t, string(data), test.expected) + }) + } +} + +// TestExecutionPayloadMarshalYAML is the YAML counterpart of +// TestExecutionPayloadMarshalJSON: the hand-written YAML marshaler dereferences the +// same nilable BaseFeePerGas (*uint256.Int) and needs the same nil guard, and the pair +// must not drift. +func TestExecutionPayloadMarshalYAML(t *testing.T) { + tests := []struct { + name string + payload *gloas.ExecutionPayload + expected string + }{ + { + name: "NilBaseFeePerGas", + payload: &gloas.ExecutionPayload{}, + expected: `base_fee_per_gas: '0'`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var data []byte + var err error + require.NotPanics(t, func() { + data, err = yaml.Marshal(test.payload) + }) + require.NoError(t, err) + require.Contains(t, string(data), test.expected) + + // String() marshals through MarshalYAML, so it inherits the same guard. + var str string + require.NotPanics(t, func() { + str = test.payload.String() + }) + require.Contains(t, str, test.expected) + }) + } +} + +func TestExecutionPayloadEmptyTransactionEncoding(t *testing.T) { + payload := &gloas.ExecutionPayload{ + BaseFeePerGas: uint256.NewInt(0), + Transactions: []bellatrix.Transaction{{}}, + } + + jsonData, err := json.Marshal(payload) + require.NoError(t, err) + require.Contains(t, string(jsonData), `"transactions":["0x"]`) + + yamlData, err := yaml.Marshal(payload) + require.NoError(t, err) + require.Contains(t, string(yamlData), "transactions: ['0x']") +} + +// TestExecutionPayloadUnmarshalJSON rejects short transaction elements without +// panicking and accepts the empty "0x" transaction. +func TestExecutionPayloadUnmarshalJSON(t *testing.T) { + // A valid payload marshaled to JSON gives a well-formed prefix up to the + // (empty) transactions array; each case replaces it with a single raw + // element too short to carry the "0x" framing, reaching the decode guard. + // The names count the raw JSON element's bytes (e.g. `[1]` is one byte). + base, err := json.Marshal(&gloas.ExecutionPayload{BaseFeePerGas: uint256.NewInt(0)}) + require.NoError(t, err) + + tests := []struct { + name string + transactions string + err string + }{ + { + name: "OneByteRawElement", + transactions: `[1]`, + err: "transaction 0: missing or malformed", + }, + { + name: "TwoByteRawElement", + transactions: `[12]`, + err: "transaction 0: missing or malformed", + }, + { + name: "EmptyTransaction", + transactions: `["0x"]`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + input := bytes.Replace(base, []byte(`"transactions":[]`), []byte(`"transactions":`+test.transactions), 1) + var payload gloas.ExecutionPayload + var err error + require.NotPanics(t, func() { + err = json.Unmarshal(input, &payload) + }) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + require.Len(t, payload.Transactions, 1) + require.Empty(t, payload.Transactions[0]) + } + }) + } +} diff --git a/spec/gloas/executionpayload_yaml.go b/spec/gloas/executionpayload_yaml.go new file mode 100644 index 00000000..08bc160a --- /dev/null +++ b/spec/gloas/executionpayload_yaml.go @@ -0,0 +1,120 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/attestantio/go-eth2-client/spec/capella" + "github.com/goccy/go-yaml" + "github.com/holiman/uint256" + "github.com/pkg/errors" +) + +// executionPayloadYAML is the spec representation of the struct. +type executionPayloadYAML struct { + ParentHash string `yaml:"parent_hash"` + FeeRecipient string `yaml:"fee_recipient"` + StateRoot string `yaml:"state_root"` + ReceiptsRoot string `yaml:"receipts_root"` + LogsBloom string `yaml:"logs_bloom"` + PrevRandao string `yaml:"prev_randao"` + BlockNumber uint64 `yaml:"block_number"` + GasLimit uint64 `yaml:"gas_limit"` + GasUsed uint64 `yaml:"gas_used"` + Timestamp uint64 `yaml:"timestamp"` + ExtraData string `yaml:"extra_data"` + BaseFeePerGas string `yaml:"base_fee_per_gas"` + BlockHash string `yaml:"block_hash"` + Transactions []string `yaml:"transactions"` + Withdrawals []*capella.Withdrawal `yaml:"withdrawals"` + BlobGasUsed uint64 `yaml:"blob_gas_used"` + ExcessBlobGas uint64 `yaml:"excess_blob_gas"` + BlockAccessList string `yaml:"block_access_list"` + SlotNumber uint64 `yaml:"slot_number"` +} + +// MarshalYAML implements yaml.Marshaler. +func (e *ExecutionPayload) MarshalYAML() ([]byte, error) { + transactions := make([]string, len(e.Transactions)) + for i := range e.Transactions { + transactions[i] = "0x" + if len(e.Transactions[i]) > 0 { + transactions[i] = fmt.Sprintf("%#x", e.Transactions[i]) + } + } + + extraData := "0x" + if len(e.ExtraData) > 0 { + extraData = fmt.Sprintf("%#x", e.ExtraData) + } + + blockAccessList := "0x" + if len(e.BlockAccessList) > 0 { + blockAccessList = fmt.Sprintf("%#x", e.BlockAccessList) + } + + // BaseFeePerGas is a nilable *uint256.Int; guard the nil case as the SSZ + // marshaler does so a zero-value payload does not panic on .Dec(). + baseFeePerGas := e.BaseFeePerGas + if baseFeePerGas == nil { + baseFeePerGas = new(uint256.Int) + } + + yamlBytes, err := yaml.MarshalWithOptions(&executionPayloadYAML{ + ParentHash: e.ParentHash.String(), + FeeRecipient: e.FeeRecipient.String(), + StateRoot: e.StateRoot.String(), + ReceiptsRoot: e.ReceiptsRoot.String(), + LogsBloom: fmt.Sprintf("%#x", e.LogsBloom), + PrevRandao: fmt.Sprintf("%#x", e.PrevRandao), + BlockNumber: e.BlockNumber, + GasLimit: e.GasLimit, + GasUsed: e.GasUsed, + Timestamp: e.Timestamp, + ExtraData: extraData, + BaseFeePerGas: baseFeePerGas.Dec(), + BlockHash: fmt.Sprintf("%#x", e.BlockHash), + Transactions: transactions, + Withdrawals: e.Withdrawals, + BlobGasUsed: e.BlobGasUsed, + ExcessBlobGas: e.ExcessBlobGas, + BlockAccessList: blockAccessList, + SlotNumber: e.SlotNumber, + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (e *ExecutionPayload) UnmarshalYAML(input []byte) error { + // This is very inefficient, but YAML is only used for spec tests so we do this + // rather than maintain a custom YAML unmarshaller. + var unmarshaled executionPayloadJSON + if err := yaml.Unmarshal(input, &unmarshaled); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + + marshaled, err := json.Marshal(unmarshaled) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return e.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/executionpayloadbid.go b/spec/gloas/executionpayloadbid.go new file mode 100644 index 00000000..61df3689 --- /dev/null +++ b/spec/gloas/executionpayloadbid.go @@ -0,0 +1,49 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// ExecutionPayloadBid represents an execution payload bid for EIP-7732. +type ExecutionPayloadBid struct { + ParentBlockHash phase0.Hash32 `ssz-index:"0" ssz-size:"32"` + ParentBlockRoot phase0.Root `ssz-index:"1" ssz-size:"32"` + BlockHash phase0.Hash32 `ssz-index:"2" ssz-size:"32"` + PrevRandao phase0.Root `ssz-index:"3" ssz-size:"32"` + FeeRecipient bellatrix.ExecutionAddress `ssz-index:"4" ssz-size:"20"` + GasLimit uint64 `ssz-index:"5"` + BuilderIndex BuilderIndex `ssz-index:"6"` + Slot phase0.Slot `ssz-index:"7"` + Value phase0.Gwei `ssz-index:"8"` + ExecutionPayment phase0.Gwei `ssz-index:"9"` + BlobKZGCommitments []deneb.KZGCommitment `ssz-index:"10" ssz-type:"progressive-list"` + ExecutionRequestsRoot phase0.Root `ssz-index:"11"` +} + +// String returns a string version of the structure. +func (e *ExecutionPayloadBid) String() string { + data, err := yaml.Marshal(e) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/executionpayloadbid_json.go b/spec/gloas/executionpayloadbid_json.go new file mode 100644 index 00000000..d7c8b2df --- /dev/null +++ b/spec/gloas/executionpayloadbid_json.go @@ -0,0 +1,222 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// executionPayloadBidJSON is the spec representation of the struct. +type executionPayloadBidJSON struct { + ParentBlockHash string `json:"parent_block_hash"` + ParentBlockRoot string `json:"parent_block_root"` + BlockHash string `json:"block_hash"` + PrevRandao string `json:"prev_randao"` + FeeRecipient string `json:"fee_recipient"` + GasLimit string `json:"gas_limit"` + BuilderIndex string `json:"builder_index"` + Slot string `json:"slot"` + Value string `json:"value"` + ExecutionPayment string `json:"execution_payment"` + BlobKZGCommitments []string `json:"blob_kzg_commitments"` + ExecutionRequestsRoot string `json:"execution_requests_root"` +} + +// MarshalJSON implements json.Marshaler. +func (e *ExecutionPayloadBid) MarshalJSON() ([]byte, error) { + blobKZGCommitments := make([]string, len(e.BlobKZGCommitments)) + for i := range e.BlobKZGCommitments { + blobKZGCommitments[i] = fmt.Sprintf("%#x", e.BlobKZGCommitments[i]) + } + + return json.Marshal(&executionPayloadBidJSON{ + ParentBlockHash: fmt.Sprintf("%#x", e.ParentBlockHash), + ParentBlockRoot: fmt.Sprintf("%#x", e.ParentBlockRoot), + BlockHash: fmt.Sprintf("%#x", e.BlockHash), + PrevRandao: fmt.Sprintf("%#x", e.PrevRandao), + FeeRecipient: fmt.Sprintf("%#x", e.FeeRecipient), + GasLimit: fmt.Sprintf("%d", e.GasLimit), + BuilderIndex: fmt.Sprintf("%d", e.BuilderIndex), + Slot: fmt.Sprintf("%d", e.Slot), + Value: fmt.Sprintf("%d", e.Value), + ExecutionPayment: fmt.Sprintf("%d", e.ExecutionPayment), + BlobKZGCommitments: blobKZGCommitments, + ExecutionRequestsRoot: fmt.Sprintf("%#x", e.ExecutionRequestsRoot), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +// +//nolint:gocyclo +func (e *ExecutionPayloadBid) UnmarshalJSON(input []byte) error { + var data executionPayloadBidJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + // Parent block hash + if data.ParentBlockHash == "" { + return errors.New("parent block hash missing") + } + parentBlockHash, err := hex.DecodeString(strings.TrimPrefix(data.ParentBlockHash, "0x")) + if err != nil { + return errors.Wrap(err, "invalid parent block hash") + } + if len(parentBlockHash) != phase0.Hash32Length { + return errors.New("incorrect length for parent block hash") + } + copy(e.ParentBlockHash[:], parentBlockHash) + + // Parent block root + if data.ParentBlockRoot == "" { + return errors.New("parent block root missing") + } + parentBlockRoot, err := hex.DecodeString(strings.TrimPrefix(data.ParentBlockRoot, "0x")) + if err != nil { + return errors.Wrap(err, "invalid parent block root") + } + if len(parentBlockRoot) != phase0.RootLength { + return errors.New("incorrect length for parent block root") + } + copy(e.ParentBlockRoot[:], parentBlockRoot) + + // Block hash + if data.BlockHash == "" { + return errors.New("block hash missing") + } + blockHash, err := hex.DecodeString(strings.TrimPrefix(data.BlockHash, "0x")) + if err != nil { + return errors.Wrap(err, "invalid block hash") + } + if len(blockHash) != phase0.Hash32Length { + return errors.New("incorrect length for block hash") + } + copy(e.BlockHash[:], blockHash) + + // Prev randao + if data.PrevRandao == "" { + return errors.New("prev randao missing") + } + prevRandao, err := hex.DecodeString(strings.TrimPrefix(data.PrevRandao, "0x")) + if err != nil { + return errors.Wrap(err, "invalid prev randao") + } + if len(prevRandao) != phase0.RootLength { + return errors.New("incorrect length for prev randao") + } + copy(e.PrevRandao[:], prevRandao) + + // Fee recipient + if data.FeeRecipient == "" { + return errors.New("fee recipient missing") + } + feeRecipient, err := hex.DecodeString(strings.TrimPrefix(data.FeeRecipient, "0x")) + if err != nil { + return errors.Wrap(err, "invalid fee recipient") + } + if len(feeRecipient) != bellatrix.FeeRecipientLength { + return errors.New("incorrect length for fee recipient") + } + copy(e.FeeRecipient[:], feeRecipient) + + // Gas limit + if data.GasLimit == "" { + return errors.New("gas limit missing") + } + gasLimit, err := strconv.ParseUint(data.GasLimit, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid gas limit") + } + e.GasLimit = gasLimit + + // Builder index + if data.BuilderIndex == "" { + return errors.New("builder index missing") + } + builderIndex, err := strconv.ParseUint(data.BuilderIndex, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid builder index") + } + e.BuilderIndex = BuilderIndex(builderIndex) + + // Slot + if data.Slot == "" { + return errors.New("slot missing") + } + slot, err := strconv.ParseUint(data.Slot, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid slot") + } + e.Slot = phase0.Slot(slot) + + // Value + if data.Value == "" { + return errors.New("value missing") + } + value, err := strconv.ParseUint(data.Value, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value") + } + e.Value = phase0.Gwei(value) + + // Execution payment + if data.ExecutionPayment == "" { + return errors.New("execution payment missing") + } + executionPayment, err := strconv.ParseUint(data.ExecutionPayment, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid execution payment") + } + e.ExecutionPayment = phase0.Gwei(executionPayment) + + // Blob KZG commitments + if data.BlobKZGCommitments == nil { + data.BlobKZGCommitments = []string{} + } + e.BlobKZGCommitments = make([]deneb.KZGCommitment, len(data.BlobKZGCommitments)) + for i, commitment := range data.BlobKZGCommitments { + commitmentBytes, err := hex.DecodeString(strings.TrimPrefix(commitment, "0x")) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("invalid blob KZG commitment %d", i)) + } + if len(commitmentBytes) != deneb.KZGCommitmentLength { + return fmt.Errorf("blob KZG commitment %d has incorrect length %d", i, len(commitmentBytes)) + } + copy(e.BlobKZGCommitments[i][:], commitmentBytes) + } + + // Execution requests root + if data.ExecutionRequestsRoot == "" { + return errors.New("execution requests root missing") + } + executionRequestsRoot, err := hex.DecodeString(strings.TrimPrefix(data.ExecutionRequestsRoot, "0x")) + if err != nil { + return errors.Wrap(err, "invalid execution requests root") + } + if len(executionRequestsRoot) != phase0.RootLength { + return errors.New("incorrect length for execution requests root") + } + copy(e.ExecutionRequestsRoot[:], executionRequestsRoot) + + return nil +} diff --git a/spec/gloas/executionpayloadbid_ssz.go b/spec/gloas/executionpayloadbid_ssz.go new file mode 100644 index 00000000..f62d3645 --- /dev/null +++ b/spec/gloas/executionpayloadbid_ssz.go @@ -0,0 +1,233 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: c029e3715523491291a5c9fbc094b18145e9ac7d7f446b81b318085036a64b97 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[ExecutionPayloadBid](`ssz-static:"false"`) + +// MarshalSSZ marshals the *ExecutionPayloadBid to SSZ-encoded bytes. +func (t *ExecutionPayloadBid) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *ExecutionPayloadBid to SSZ-encoded bytes, appending to the provided buffer. +func (t *ExecutionPayloadBid) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(ExecutionPayloadBid) + } + dstlen := len(dst) + { // Static Field #0 'ParentBlockHash' + dst = append(dst, t.ParentBlockHash[:32]...) + } + { // Static Field #1 'ParentBlockRoot' + dst = append(dst, t.ParentBlockRoot[:32]...) + } + { // Static Field #2 'BlockHash' + dst = append(dst, t.BlockHash[:32]...) + } + { // Static Field #3 'PrevRandao' + dst = append(dst, t.PrevRandao[:32]...) + } + { // Static Field #4 'FeeRecipient' + dst = append(dst, t.FeeRecipient[:20]...) + } + { // Static Field #5 'GasLimit' + dst = binary.LittleEndian.AppendUint64(dst, t.GasLimit) + } + { // Static Field #6 'BuilderIndex' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.BuilderIndex)) + } + { // Static Field #7 'Slot' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.Slot)) + } + { // Static Field #8 'Value' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.Value)) + } + { // Static Field #9 'ExecutionPayment' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.ExecutionPayment)) + } + // Offset Field #10 'BlobKZGCommitments' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #11 'ExecutionRequestsRoot' + dst = append(dst, t.ExecutionRequestsRoot[:32]...) + } + { // Dynamic Field #10 'BlobKZGCommitments' + binary.LittleEndian.PutUint32(dst[dstlen+188:], uint32(len(dst)-dstlen)) + t := t.BlobKZGCommitments + vlen := len(t) + dst = sszutils.MarshalFixedBytesSlice(dst, t[:vlen]) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *ExecutionPayloadBid from SSZ-encoded bytes. +func (t *ExecutionPayloadBid) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 224 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 224) + } + { // Field #0 'ParentBlockHash' (static) + buf := buf[0:32] + copy(t.ParentBlockHash[:], buf) + } + { // Field #1 'ParentBlockRoot' (static) + buf := buf[32:64] + copy(t.ParentBlockRoot[:], buf) + } + { // Field #2 'BlockHash' (static) + buf := buf[64:96] + copy(t.BlockHash[:], buf) + } + { // Field #3 'PrevRandao' (static) + buf := buf[96:128] + copy(t.PrevRandao[:], buf) + } + { // Field #4 'FeeRecipient' (static) + buf := buf[128:148] + copy(t.FeeRecipient[:], buf) + } + { // Field #5 'GasLimit' (static) + buf := buf[148:156] + t.GasLimit = binary.LittleEndian.Uint64(buf) + } + { // Field #6 'BuilderIndex' (static) + buf := buf[156:164] + t.BuilderIndex = BuilderIndex(binary.LittleEndian.Uint64(buf)) + } + { // Field #7 'Slot' (static) + buf := buf[164:172] + t.Slot = phase0.Slot(binary.LittleEndian.Uint64(buf)) + } + { // Field #8 'Value' (static) + buf := buf[172:180] + t.Value = phase0.Gwei(binary.LittleEndian.Uint64(buf)) + } + { // Field #9 'ExecutionPayment' (static) + buf := buf[180:188] + t.ExecutionPayment = phase0.Gwei(binary.LittleEndian.Uint64(buf)) + } + // Field #10 'BlobKZGCommitments' (offset) + offset10 := int(binary.LittleEndian.Uint32(buf[188:192])) + if offset10 != 224 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset10, 224), "BlobKZGCommitments:o") + } + { // Field #11 'ExecutionRequestsRoot' (static) + buf := buf[192:224] + copy(t.ExecutionRequestsRoot[:], buf) + } + { // Field #10 'BlobKZGCommitments' (dynamic) + buf := buf[offset10:] + val1 := t.BlobKZGCommitments + itemCount := len(buf) / 48 + if len(buf)%48 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 48), "BlobKZGCommitments") + } + val1 = sszutils.ExpandSlice(val1, itemCount) + sszutils.UnmarshalFixedBytesSlice(val1[:itemCount], buf) + t.BlobKZGCommitments = val1 + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *ExecutionPayloadBid. +func (t *ExecutionPayloadBid) SizeSSZ() (size int) { + if t == nil { + t = new(ExecutionPayloadBid) + } + // Field #0 'ParentBlockHash' static (32 bytes) + // Field #1 'ParentBlockRoot' static (32 bytes) + // Field #2 'BlockHash' static (32 bytes) + // Field #3 'PrevRandao' static (32 bytes) + // Field #4 'FeeRecipient' static (20 bytes) + // Field #5 'GasLimit' static (8 bytes) + // Field #6 'BuilderIndex' static (8 bytes) + // Field #7 'Slot' static (8 bytes) + // Field #8 'Value' static (8 bytes) + // Field #9 'ExecutionPayment' static (8 bytes) + // Field #10 'BlobKZGCommitments' offset (4 bytes) + // Field #11 'ExecutionRequestsRoot' static (32 bytes) + size += 224 + { // Dynamic field #10 'BlobKZGCommitments' + size += len(t.BlobKZGCommitments) * 48 + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *ExecutionPayloadBid. +func (t *ExecutionPayloadBid) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *ExecutionPayloadBid using the given hash walker. +func (t *ExecutionPayloadBid) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(ExecutionPayloadBid) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'ParentBlockHash' + hh.PutBytes(t.ParentBlockHash[:32]) + } + { // Field #1 'ParentBlockRoot' + hh.PutBytes(t.ParentBlockRoot[:32]) + } + { // Field #2 'BlockHash' + hh.PutBytes(t.BlockHash[:32]) + } + { // Field #3 'PrevRandao' + hh.PutBytes(t.PrevRandao[:32]) + } + { // Field #4 'FeeRecipient' + hh.PutBytes(t.FeeRecipient[:20]) + } + { // Field #5 'GasLimit' + hh.PutUint64(t.GasLimit) + } + { // Field #6 'BuilderIndex' + hh.PutUint64(uint64(t.BuilderIndex)) + } + { // Field #7 'Slot' + hh.PutUint64(uint64(t.Slot)) + } + { // Field #8 'Value' + hh.PutUint64(uint64(t.Value)) + } + { // Field #9 'ExecutionPayment' + hh.PutUint64(uint64(t.ExecutionPayment)) + } + { // Field #10 'BlobKZGCommitments' + t := t.BlobKZGCommitments + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + hh.PutBytes(t[idx1][:48]) + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #11 'ExecutionRequestsRoot' + hh.PutBytes(t.ExecutionRequestsRoot[:32]) + } + hh.MerkleizeProgressiveWithActiveFields(idx, []byte{0xff, 0x0f}) + return nil +} diff --git a/spec/gloas/executionpayloadbid_yaml.go b/spec/gloas/executionpayloadbid_yaml.go new file mode 100644 index 00000000..e3a97238 --- /dev/null +++ b/spec/gloas/executionpayloadbid_yaml.go @@ -0,0 +1,65 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (e *ExecutionPayloadBid) MarshalYAML() ([]byte, error) { + blobKZGCommitments := make([]string, len(e.BlobKZGCommitments)) + for i := range e.BlobKZGCommitments { + blobKZGCommitments[i] = fmt.Sprintf("%#x", e.BlobKZGCommitments[i]) + } + + yamlBytes, err := yaml.MarshalWithOptions(&executionPayloadBidJSON{ + ParentBlockHash: fmt.Sprintf("%#x", e.ParentBlockHash), + ParentBlockRoot: fmt.Sprintf("%#x", e.ParentBlockRoot), + BlockHash: fmt.Sprintf("%#x", e.BlockHash), + PrevRandao: fmt.Sprintf("%#x", e.PrevRandao), + FeeRecipient: fmt.Sprintf("%#x", e.FeeRecipient), + GasLimit: fmt.Sprintf("%d", e.GasLimit), + BuilderIndex: fmt.Sprintf("%d", e.BuilderIndex), + Slot: fmt.Sprintf("%d", e.Slot), + Value: fmt.Sprintf("%d", e.Value), + ExecutionPayment: fmt.Sprintf("%d", e.ExecutionPayment), + BlobKZGCommitments: blobKZGCommitments, + ExecutionRequestsRoot: fmt.Sprintf("%#x", e.ExecutionRequestsRoot), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (e *ExecutionPayloadBid) UnmarshalYAML(input []byte) error { + var data executionPayloadBidJSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return e.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/executionpayloadenvelope.go b/spec/gloas/executionpayloadenvelope.go new file mode 100644 index 00000000..9f92ad06 --- /dev/null +++ b/spec/gloas/executionpayloadenvelope.go @@ -0,0 +1,40 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// ExecutionPayloadEnvelope represents an execution payload envelope. +type ExecutionPayloadEnvelope struct { + Payload *ExecutionPayload `ssz-index:"0"` + ExecutionRequests *ExecutionRequests `ssz-index:"1"` + BuilderIndex BuilderIndex `ssz-index:"2"` + BeaconBlockRoot phase0.Root `ssz-index:"3" ssz-size:"32"` + ParentBeaconBlockRoot phase0.Root `ssz-index:"4" ssz-size:"32"` +} + +// String returns a string version of the structure. +func (e *ExecutionPayloadEnvelope) String() string { + data, err := yaml.Marshal(e) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/executionpayloadenvelope_json.go b/spec/gloas/executionpayloadenvelope_json.go new file mode 100644 index 00000000..a35f835f --- /dev/null +++ b/spec/gloas/executionpayloadenvelope_json.go @@ -0,0 +1,98 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// executionPayloadEnvelopeJSON is the spec representation of the struct. +type executionPayloadEnvelopeJSON struct { + Payload *ExecutionPayload `json:"payload"` + ExecutionRequests *ExecutionRequests `json:"execution_requests"` + BuilderIndex string `json:"builder_index"` + BeaconBlockRoot string `json:"beacon_block_root"` + ParentBeaconBlockRoot string `json:"parent_beacon_block_root"` +} + +// MarshalJSON implements json.Marshaler. +func (e *ExecutionPayloadEnvelope) MarshalJSON() ([]byte, error) { + return json.Marshal(&executionPayloadEnvelopeJSON{ + Payload: e.Payload, + ExecutionRequests: e.ExecutionRequests, + BuilderIndex: fmt.Sprintf("%d", e.BuilderIndex), + BeaconBlockRoot: fmt.Sprintf("%#x", e.BeaconBlockRoot), + ParentBeaconBlockRoot: fmt.Sprintf("%#x", e.ParentBeaconBlockRoot), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (e *ExecutionPayloadEnvelope) UnmarshalJSON(input []byte) error { + var data executionPayloadEnvelopeJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if data.Payload == nil { + return errors.New("payload missing") + } + e.Payload = data.Payload + + if data.ExecutionRequests == nil { + return errors.New("execution requests missing") + } + e.ExecutionRequests = data.ExecutionRequests + + if data.BuilderIndex == "" { + return errors.New("builder index missing") + } + builderIndex, err := strconv.ParseUint(data.BuilderIndex, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid builder index") + } + e.BuilderIndex = BuilderIndex(builderIndex) + + if data.BeaconBlockRoot == "" { + return errors.New("beacon block root missing") + } + root, err := hex.DecodeString(strings.TrimPrefix(data.BeaconBlockRoot, "0x")) + if err != nil { + return errors.Wrap(err, "invalid beacon block root") + } + if len(root) != phase0.RootLength { + return errors.New("incorrect length for beacon block root") + } + copy(e.BeaconBlockRoot[:], root) + + if data.ParentBeaconBlockRoot == "" { + return errors.New("parent beacon block root missing") + } + parentRoot, err := hex.DecodeString(strings.TrimPrefix(data.ParentBeaconBlockRoot, "0x")) + if err != nil { + return errors.Wrap(err, "invalid parent beacon block root") + } + if len(parentRoot) != phase0.RootLength { + return errors.New("incorrect length for parent beacon block root") + } + copy(e.ParentBeaconBlockRoot[:], parentRoot) + + return nil +} diff --git a/spec/gloas/executionpayloadenvelope_ssz.go b/spec/gloas/executionpayloadenvelope_ssz.go new file mode 100644 index 00000000..ee715b8e --- /dev/null +++ b/spec/gloas/executionpayloadenvelope_ssz.go @@ -0,0 +1,181 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: a36cbe07a5d8c38f27b08333e0ea138079daab656596e7d604c7b242cb786667 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[ExecutionPayloadEnvelope](`ssz-static:"false"`) + +// MarshalSSZ marshals the *ExecutionPayloadEnvelope to SSZ-encoded bytes. +func (t *ExecutionPayloadEnvelope) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *ExecutionPayloadEnvelope to SSZ-encoded bytes, appending to the provided buffer. +func (t *ExecutionPayloadEnvelope) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(ExecutionPayloadEnvelope) + } + dstlen := len(dst) + // Offset Field #0 'Payload' + // Offset Field #1 'ExecutionRequests' + dst = append(dst, 0, 0, 0, 0, 0, 0, 0, 0) + { // Static Field #2 'BuilderIndex' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.BuilderIndex)) + } + { // Static Field #3 'BeaconBlockRoot' + dst = append(dst, t.BeaconBlockRoot[:32]...) + } + { // Static Field #4 'ParentBeaconBlockRoot' + dst = append(dst, t.ParentBeaconBlockRoot[:32]...) + } + { // Dynamic Field #0 'Payload' + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(len(dst)-dstlen)) + t := t.Payload + if t == nil { + t = new(ExecutionPayload) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Payload") + } + } + { // Dynamic Field #1 'ExecutionRequests' + binary.LittleEndian.PutUint32(dst[dstlen+4:], uint32(len(dst)-dstlen)) + t := t.ExecutionRequests + if t == nil { + t = new(ExecutionRequests) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "ExecutionRequests") + } + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *ExecutionPayloadEnvelope from SSZ-encoded bytes. +func (t *ExecutionPayloadEnvelope) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 80 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 80) + } + // Field #0 'Payload' (offset) + offset0 := int(binary.LittleEndian.Uint32(buf[0:4])) + if offset0 != 80 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 80), "Payload:o") + } + // Field #1 'ExecutionRequests' (offset) + offset1 := int(binary.LittleEndian.Uint32(buf[4:8])) + if offset1 < offset0 || offset1 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset1, offset0, buflen), "ExecutionRequests:o") + } + { // Field #2 'BuilderIndex' (static) + buf := buf[8:16] + t.BuilderIndex = BuilderIndex(binary.LittleEndian.Uint64(buf)) + } + { // Field #3 'BeaconBlockRoot' (static) + buf := buf[16:48] + copy(t.BeaconBlockRoot[:], buf) + } + { // Field #4 'ParentBeaconBlockRoot' (static) + buf := buf[48:80] + copy(t.ParentBeaconBlockRoot[:], buf) + } + { // Field #0 'Payload' (dynamic) + buf := buf[offset0:offset1] + if t.Payload == nil { + t.Payload = new(ExecutionPayload) + } + if err = t.Payload.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Payload") + } + } + { // Field #1 'ExecutionRequests' (dynamic) + buf := buf[offset1:] + if t.ExecutionRequests == nil { + t.ExecutionRequests = new(ExecutionRequests) + } + if err = t.ExecutionRequests.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "ExecutionRequests") + } + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *ExecutionPayloadEnvelope. +func (t *ExecutionPayloadEnvelope) SizeSSZ() (size int) { + if t == nil { + t = new(ExecutionPayloadEnvelope) + } + // Field #0 'Payload' offset (4 bytes) + // Field #1 'ExecutionRequests' offset (4 bytes) + // Field #2 'BuilderIndex' static (8 bytes) + // Field #3 'BeaconBlockRoot' static (32 bytes) + // Field #4 'ParentBeaconBlockRoot' static (32 bytes) + size += 80 + { // Dynamic field #0 'Payload' + size += t.Payload.SizeSSZ() + } + { // Dynamic field #1 'ExecutionRequests' + size += t.ExecutionRequests.SizeSSZ() + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *ExecutionPayloadEnvelope. +func (t *ExecutionPayloadEnvelope) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *ExecutionPayloadEnvelope using the given hash walker. +func (t *ExecutionPayloadEnvelope) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(ExecutionPayloadEnvelope) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Payload' + t := t.Payload + if t == nil { + t = new(ExecutionPayload) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Payload") + } + } + { // Field #1 'ExecutionRequests' + t := t.ExecutionRequests + if t == nil { + t = new(ExecutionRequests) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "ExecutionRequests") + } + } + { // Field #2 'BuilderIndex' + hh.PutUint64(uint64(t.BuilderIndex)) + } + { // Field #3 'BeaconBlockRoot' + hh.PutBytes(t.BeaconBlockRoot[:32]) + } + { // Field #4 'ParentBeaconBlockRoot' + hh.PutBytes(t.ParentBeaconBlockRoot[:32]) + } + hh.MerkleizeProgressiveWithActiveFields(idx, []byte{0x1f}) + return nil +} diff --git a/spec/gloas/executionpayloadenvelope_test.go b/spec/gloas/executionpayloadenvelope_test.go new file mode 100644 index 00000000..e4771a6a --- /dev/null +++ b/spec/gloas/executionpayloadenvelope_test.go @@ -0,0 +1,59 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas_test + +import ( + "testing" + + "github.com/attestantio/go-eth2-client/spec/gloas" + require "github.com/stretchr/testify/require" +) + +// TestExecutionPayloadEnvelopeString verifies that stringifying an envelope does not +// panic when its payload carries a nil BaseFeePerGas. The envelope's YAML marshaler +// embeds the payload, so String() descends into ExecutionPayload.MarshalYAML and +// depends on that marshaler's nil guard — this is the shape +// mock.Service.SignedExecutionPayloadEnvelope builds, which made logging a mocked or +// fetched envelope crash the caller. +func TestExecutionPayloadEnvelopeString(t *testing.T) { + tests := []struct { + name string + envelope *gloas.ExecutionPayloadEnvelope + // expected is a substring the rendering must contain. + expected string + }{ + { + // A nil payload is rendered as null by the YAML encoder, so this case + // never reaches the payload marshaler. + name: "ZeroValue", + envelope: &gloas.ExecutionPayloadEnvelope{}, + expected: "payload: null", + }, + { + name: "NilBaseFeePerGas", + envelope: &gloas.ExecutionPayloadEnvelope{Payload: &gloas.ExecutionPayload{}}, + expected: `base_fee_per_gas: '0'`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var str string + require.NotPanics(t, func() { + str = test.envelope.String() + }) + require.Contains(t, str, test.expected) + }) + } +} diff --git a/spec/gloas/executionpayloadenvelope_yaml.go b/spec/gloas/executionpayloadenvelope_yaml.go new file mode 100644 index 00000000..11b857dd --- /dev/null +++ b/spec/gloas/executionpayloadenvelope_yaml.go @@ -0,0 +1,53 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (e *ExecutionPayloadEnvelope) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&executionPayloadEnvelopeJSON{ + Payload: e.Payload, + ExecutionRequests: e.ExecutionRequests, + BuilderIndex: fmt.Sprintf("%d", e.BuilderIndex), + BeaconBlockRoot: fmt.Sprintf("%#x", e.BeaconBlockRoot), + ParentBeaconBlockRoot: fmt.Sprintf("%#x", e.ParentBeaconBlockRoot), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (e *ExecutionPayloadEnvelope) UnmarshalYAML(input []byte) error { + var data executionPayloadEnvelopeJSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return e.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/executionrequests.go b/spec/gloas/executionrequests.go new file mode 100644 index 00000000..b570df73 --- /dev/null +++ b/spec/gloas/executionrequests.go @@ -0,0 +1,40 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/goccy/go-yaml" +) + +// ExecutionRequests represents the execution layer triggered requests of a payload (EIP-8282). +type ExecutionRequests struct { + Deposits []*electra.DepositRequest `ssz-index:"0" ssz-type:"progressive-list"` + Withdrawals []*electra.WithdrawalRequest `ssz-index:"1" ssz-type:"progressive-list"` + Consolidations []*electra.ConsolidationRequest `ssz-index:"2" ssz-type:"progressive-list"` + BuilderDeposits []*BuilderDepositRequest `ssz-index:"3" ssz-type:"progressive-list"` + BuilderExits []*BuilderExitRequest `ssz-index:"4" ssz-type:"progressive-list"` +} + +// String returns a string version of the structure. +func (e *ExecutionRequests) String() string { + data, err := yaml.Marshal(e) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/executionrequests_json.go b/spec/gloas/executionrequests_json.go new file mode 100644 index 00000000..884d70d7 --- /dev/null +++ b/spec/gloas/executionrequests_json.go @@ -0,0 +1,103 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/json" + "fmt" + + "github.com/attestantio/go-eth2-client/codecs" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/pkg/errors" +) + +// executionRequestsJSON is the spec representation of the struct. +type executionRequestsJSON struct { + Deposits []*electra.DepositRequest `json:"deposits"` + Withdrawals []*electra.WithdrawalRequest `json:"withdrawals"` + Consolidations []*electra.ConsolidationRequest `json:"consolidations"` + BuilderDeposits []*BuilderDepositRequest `json:"builder_deposits"` + BuilderExits []*BuilderExitRequest `json:"builder_exits"` +} + +// MarshalJSON implements json.Marshaler. +func (e *ExecutionRequests) MarshalJSON() ([]byte, error) { + return json.Marshal(&executionRequestsJSON{ + Deposits: e.Deposits, + Withdrawals: e.Withdrawals, + Consolidations: e.Consolidations, + BuilderDeposits: e.BuilderDeposits, + BuilderExits: e.BuilderExits, + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (e *ExecutionRequests) UnmarshalJSON(input []byte) error { + raw, err := codecs.RawJSON(&executionRequestsJSON{}, input) + if err != nil { + return err + } + + if err := json.Unmarshal(raw["deposits"], &e.Deposits); err != nil { + return errors.Wrap(err, "deposits") + } + + for i := range e.Deposits { + if e.Deposits[i] == nil { + return fmt.Errorf("deposits entry %d missing", i) + } + } + + if err := json.Unmarshal(raw["withdrawals"], &e.Withdrawals); err != nil { + return errors.Wrap(err, "withdrawals") + } + + for i := range e.Withdrawals { + if e.Withdrawals[i] == nil { + return fmt.Errorf("withdrawals entry %d missing", i) + } + } + + if err := json.Unmarshal(raw["consolidations"], &e.Consolidations); err != nil { + return errors.Wrap(err, "consolidations") + } + + for i := range e.Consolidations { + if e.Consolidations[i] == nil { + return fmt.Errorf("consolidation requests entry %d missing", i) + } + } + + if err := json.Unmarshal(raw["builder_deposits"], &e.BuilderDeposits); err != nil { + return errors.Wrap(err, "builder_deposits") + } + + for i := range e.BuilderDeposits { + if e.BuilderDeposits[i] == nil { + return fmt.Errorf("builder deposits entry %d missing", i) + } + } + + if err := json.Unmarshal(raw["builder_exits"], &e.BuilderExits); err != nil { + return errors.Wrap(err, "builder_exits") + } + + for i := range e.BuilderExits { + if e.BuilderExits[i] == nil { + return fmt.Errorf("builder exits entry %d missing", i) + } + } + + return nil +} diff --git a/spec/gloas/executionrequests_ssz.go b/spec/gloas/executionrequests_ssz.go new file mode 100644 index 00000000..99d086e7 --- /dev/null +++ b/spec/gloas/executionrequests_ssz.go @@ -0,0 +1,379 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: d23bfbfe177e790f259c72ecceeb7dbb69430c0b7695b247d261574b8958b0f9 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/electra" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[ExecutionRequests](`ssz-static:"false"`) + +// MarshalSSZ marshals the *ExecutionRequests to SSZ-encoded bytes. +func (t *ExecutionRequests) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *ExecutionRequests to SSZ-encoded bytes, appending to the provided buffer. +func (t *ExecutionRequests) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + zeroBytes := sszutils.ZeroBytes() + if t == nil { + t = new(ExecutionRequests) + } + dstlen := len(dst) + // Offset Field #0 'Deposits' + // Offset Field #1 'Withdrawals' + // Offset Field #2 'Consolidations' + // Offset Field #3 'BuilderDeposits' + // Offset Field #4 'BuilderExits' + dst = append(dst, zeroBytes[:20]...) + { // Dynamic Field #0 'Deposits' + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(len(dst)-dstlen)) + t := t.Deposits + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(electra.DepositRequest) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "Deposits[%d]", idx1) + } + } + } + { // Dynamic Field #1 'Withdrawals' + binary.LittleEndian.PutUint32(dst[dstlen+4:], uint32(len(dst)-dstlen)) + t := t.Withdrawals + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(electra.WithdrawalRequest) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "Withdrawals[%d]", idx1) + } + } + } + { // Dynamic Field #2 'Consolidations' + binary.LittleEndian.PutUint32(dst[dstlen+8:], uint32(len(dst)-dstlen)) + t := t.Consolidations + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(electra.ConsolidationRequest) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "Consolidations[%d]", idx1) + } + } + } + { // Dynamic Field #3 'BuilderDeposits' + binary.LittleEndian.PutUint32(dst[dstlen+12:], uint32(len(dst)-dstlen)) + t := t.BuilderDeposits + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(BuilderDepositRequest) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "BuilderDeposits[%d]", idx1) + } + } + } + { // Dynamic Field #4 'BuilderExits' + binary.LittleEndian.PutUint32(dst[dstlen+16:], uint32(len(dst)-dstlen)) + t := t.BuilderExits + vlen := len(t) + for idx1 := range vlen { + t := t[idx1] + if t == nil { + t = new(BuilderExitRequest) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPathf(err, "BuilderExits[%d]", idx1) + } + } + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *ExecutionRequests from SSZ-encoded bytes. +func (t *ExecutionRequests) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 20 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 20) + } + // Field #0 'Deposits' (offset) + offset0 := int(binary.LittleEndian.Uint32(buf[0:4])) + if offset0 != 20 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 20), "Deposits:o") + } + // Field #1 'Withdrawals' (offset) + offset1 := int(binary.LittleEndian.Uint32(buf[4:8])) + if offset1 < offset0 || offset1 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset1, offset0, buflen), "Withdrawals:o") + } + // Field #2 'Consolidations' (offset) + offset2 := int(binary.LittleEndian.Uint32(buf[8:12])) + if offset2 < offset1 || offset2 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset2, offset1, buflen), "Consolidations:o") + } + // Field #3 'BuilderDeposits' (offset) + offset3 := int(binary.LittleEndian.Uint32(buf[12:16])) + if offset3 < offset2 || offset3 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset3, offset2, buflen), "BuilderDeposits:o") + } + // Field #4 'BuilderExits' (offset) + offset4 := int(binary.LittleEndian.Uint32(buf[16:20])) + if offset4 < offset3 || offset4 > buflen { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset4, offset3, buflen), "BuilderExits:o") + } + { // Field #0 'Deposits' (dynamic) + buf := buf[offset0:offset1] + val1 := t.Deposits + itemCount := len(buf) / 192 + if len(buf)%192 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 192), "Deposits") + } + val1 = sszutils.ExpandSlice(val1, itemCount) + for idx1 := range itemCount { + if val1[idx1] == nil { + val1[idx1] = new(electra.DepositRequest) + } + buf := buf[192*idx1 : 192*(idx1+1)] + if err = val1[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "Deposits[%d]", idx1) + } + } + t.Deposits = val1 + } + { // Field #1 'Withdrawals' (dynamic) + buf := buf[offset1:offset2] + val2 := t.Withdrawals + itemCount := len(buf) / 76 + if len(buf)%76 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 76), "Withdrawals") + } + val2 = sszutils.ExpandSlice(val2, itemCount) + for idx1 := range itemCount { + if val2[idx1] == nil { + val2[idx1] = new(electra.WithdrawalRequest) + } + buf := buf[76*idx1 : 76*(idx1+1)] + if err = val2[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "Withdrawals[%d]", idx1) + } + } + t.Withdrawals = val2 + } + { // Field #2 'Consolidations' (dynamic) + buf := buf[offset2:offset3] + val3 := t.Consolidations + itemCount := len(buf) / 116 + if len(buf)%116 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 116), "Consolidations") + } + val3 = sszutils.ExpandSlice(val3, itemCount) + for idx1 := range itemCount { + if val3[idx1] == nil { + val3[idx1] = new(electra.ConsolidationRequest) + } + buf := buf[116*idx1 : 116*(idx1+1)] + if err = val3[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "Consolidations[%d]", idx1) + } + } + t.Consolidations = val3 + } + { // Field #3 'BuilderDeposits' (dynamic) + buf := buf[offset3:offset4] + val4 := t.BuilderDeposits + itemCount := len(buf) / 184 + if len(buf)%184 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 184), "BuilderDeposits") + } + val4 = sszutils.ExpandSlice(val4, itemCount) + for idx1 := range itemCount { + if val4[idx1] == nil { + val4[idx1] = new(BuilderDepositRequest) + } + buf := buf[184*idx1 : 184*(idx1+1)] + if err = val4[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "BuilderDeposits[%d]", idx1) + } + } + t.BuilderDeposits = val4 + } + { // Field #4 'BuilderExits' (dynamic) + buf := buf[offset4:] + val5 := t.BuilderExits + itemCount := len(buf) / 68 + if len(buf)%68 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 68), "BuilderExits") + } + val5 = sszutils.ExpandSlice(val5, itemCount) + for idx1 := range itemCount { + if val5[idx1] == nil { + val5[idx1] = new(BuilderExitRequest) + } + buf := buf[68*idx1 : 68*(idx1+1)] + if err = val5[idx1].UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPathf(err, "BuilderExits[%d]", idx1) + } + } + t.BuilderExits = val5 + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *ExecutionRequests. +func (t *ExecutionRequests) SizeSSZ() (size int) { + if t == nil { + t = new(ExecutionRequests) + } + // Field #0 'Deposits' offset (4 bytes) + // Field #1 'Withdrawals' offset (4 bytes) + // Field #2 'Consolidations' offset (4 bytes) + // Field #3 'BuilderDeposits' offset (4 bytes) + // Field #4 'BuilderExits' offset (4 bytes) + size += 20 + { // Dynamic field #0 'Deposits' + size += len(t.Deposits) * 192 + } + { // Dynamic field #1 'Withdrawals' + size += len(t.Withdrawals) * 76 + } + { // Dynamic field #2 'Consolidations' + size += len(t.Consolidations) * 116 + } + { // Dynamic field #3 'BuilderDeposits' + size += len(t.BuilderDeposits) * 184 + } + { // Dynamic field #4 'BuilderExits' + size += len(t.BuilderExits) * 68 + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *ExecutionRequests. +func (t *ExecutionRequests) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *ExecutionRequests using the given hash walker. +func (t *ExecutionRequests) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(ExecutionRequests) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Deposits' + t := t.Deposits + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(electra.DepositRequest) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "Deposits[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #1 'Withdrawals' + t := t.Withdrawals + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(electra.WithdrawalRequest) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "Withdrawals[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #2 'Consolidations' + t := t.Consolidations + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(electra.ConsolidationRequest) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "Consolidations[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #3 'BuilderDeposits' + t := t.BuilderDeposits + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(BuilderDepositRequest) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "BuilderDeposits[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #4 'BuilderExits' + t := t.BuilderExits + idx := hh.StartTree(sszutils.TreeTypeProgressive) + vlen := uint64(len(t)) + for idx1 := range int(vlen) { + t := t[idx1] + if t == nil { + t = new(BuilderExitRequest) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPathf(err, "BuilderExits[%d]", idx1) + } + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + hh.MerkleizeProgressiveWithActiveFields(idx, []byte{0x1f}) + return nil +} diff --git a/spec/gloas/executionrequests_yaml.go b/spec/gloas/executionrequests_yaml.go new file mode 100644 index 00000000..fff70711 --- /dev/null +++ b/spec/gloas/executionrequests_yaml.go @@ -0,0 +1,65 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// executionRequestsYAML is the spec representation of the struct. +type executionRequestsYAML struct { + Deposits []*electra.DepositRequest `yaml:"deposits"` + Withdrawals []*electra.WithdrawalRequest `yaml:"withdrawals"` + Consolidations []*electra.ConsolidationRequest `yaml:"consolidations"` + BuilderDeposits []*BuilderDepositRequest `yaml:"builder_deposits"` + BuilderExits []*BuilderExitRequest `yaml:"builder_exits"` +} + +// MarshalYAML implements yaml.Marshaler. +func (e *ExecutionRequests) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&executionRequestsYAML{ + Deposits: e.Deposits, + Withdrawals: e.Withdrawals, + Consolidations: e.Consolidations, + BuilderDeposits: e.BuilderDeposits, + BuilderExits: e.BuilderExits, + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (e *ExecutionRequests) UnmarshalYAML(input []byte) error { + // This is very inefficient, but YAML is only used for spec tests so we do this + // rather than maintain a custom YAML unmarshaller. + var unmarshaled executionRequestsJSON + if err := yaml.Unmarshal(input, &unmarshaled); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + + marshaled, err := json.Marshal(unmarshaled) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return e.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/generate.go b/spec/gloas/generate.go new file mode 100644 index 00000000..b3dc2440 --- /dev/null +++ b/spec/gloas/generate.go @@ -0,0 +1,17 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +//go:generate rm -f *_ssz.go +//go:generate go tool dynssz-gen -config generate.yaml diff --git a/spec/gloas/generate.yaml b/spec/gloas/generate.yaml new file mode 100644 index 00000000..eabbfa22 --- /dev/null +++ b/spec/gloas/generate.yaml @@ -0,0 +1,65 @@ +package: . +legacy: true +without-dynamic-expressions: true + +types: + - name: AggregateAndProof + output: aggregateandproof_ssz.go + - name: Attestation + output: attestation_ssz.go + - name: AttesterSlashing + output: attesterslashing_ssz.go + - name: BeaconBlockBody + output: beaconblockbody_ssz.go + - name: BeaconBlock + output: beaconblock_ssz.go + - name: BeaconState + output: beaconstate_ssz.go + - name: Builder + output: builder_ssz.go + - name: BuilderRequestAuth + output: builderrequestauth_ssz.go + - name: SignedBuilderRequestAuth + output: signedbuilderrequestauth_ssz.go + - name: BuilderEntry + output: builderentry_ssz.go + - name: BuilderConfig + output: builderconfig_ssz.go + - name: BuilderPendingPayment + output: builderpendingpayment_ssz.go + - name: BuilderPendingWithdrawal + output: builderpendingwithdrawal_ssz.go + - name: BuilderDepositRequest + output: builderdepositrequest_ssz.go + - name: BuilderExitRequest + output: builderexitrequest_ssz.go + - name: ExecutionPayload + output: executionpayload_ssz.go + - name: ExecutionRequests + output: executionrequests_ssz.go + - name: ExecutionPayloadBid + output: executionpayloadbid_ssz.go + - name: ExecutionPayloadEnvelope + output: executionpayloadenvelope_ssz.go + - name: IndexedAttestation + output: indexedattestation_ssz.go + - name: IndexedPayloadAttestation + output: indexedpayloadattestation_ssz.go + - name: PayloadAttestation + output: payloadattestation_ssz.go + - name: PayloadAttestationData + output: payloadattestationdata_ssz.go + - name: PayloadAttestationMessage + output: payloadattestationmessage_ssz.go + - name: ProposerPreferences + output: proposerpreferences_ssz.go + - name: SignedAggregateAndProof + output: signedaggregateandproof_ssz.go + - name: SignedBeaconBlock + output: signedbeaconblock_ssz.go + - name: SignedExecutionPayloadBid + output: signedexecutionpayloadbid_ssz.go + - name: SignedExecutionPayloadEnvelope + output: signedexecutionpayloadenvelope_ssz.go + - name: SignedProposerPreferences + output: signedproposerpreferences_ssz.go diff --git a/spec/gloas/indexedattestation.go b/spec/gloas/indexedattestation.go new file mode 100644 index 00000000..e3f202f6 --- /dev/null +++ b/spec/gloas/indexedattestation.go @@ -0,0 +1,140 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// IndexedAttestation provides a signed attestation with a list of attesting indices. +type IndexedAttestation struct { + // Currently using primitives as sszgen does not handle []ValidatorIndex + AttestingIndices []uint64 `ssz-index:"0" ssz-type:"progressive-list"` + Data *phase0.AttestationData `ssz-index:"1"` + Signature phase0.BLSSignature `ssz-index:"2" ssz-size:"96"` +} + +// indexedAttestationJSON is the spec representation of the struct. +type indexedAttestationJSON struct { + AttestingIndices []string `json:"attesting_indices"` + Data *phase0.AttestationData `json:"data"` + Signature string `json:"signature"` +} + +// indexedAttestationYAML is a raw representation of the struct. +type indexedAttestationYAML struct { + AttestingIndices []uint64 `yaml:"attesting_indices"` + Data *phase0.AttestationData `yaml:"data"` + Signature string `yaml:"signature"` +} + +// MarshalJSON implements json.Marshaler. +func (i *IndexedAttestation) MarshalJSON() ([]byte, error) { + attestingIndices := make([]string, len(i.AttestingIndices)) + for j := range i.AttestingIndices { + attestingIndices[j] = strconv.FormatUint(i.AttestingIndices[j], 10) + } + + return json.Marshal(&indexedAttestationJSON{ + AttestingIndices: attestingIndices, + Data: i.Data, + Signature: fmt.Sprintf("%#x", i.Signature), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (i *IndexedAttestation) UnmarshalJSON(input []byte) error { + var indexedAttestationJSON indexedAttestationJSON + if err := json.Unmarshal(input, &indexedAttestationJSON); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + return i.unpack(&indexedAttestationJSON) +} + +// MarshalYAML implements yaml.Marshaler. +func (i *IndexedAttestation) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&indexedAttestationYAML{ + AttestingIndices: i.AttestingIndices, + Data: i.Data, + Signature: fmt.Sprintf("%#x", i.Signature), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (i *IndexedAttestation) UnmarshalYAML(input []byte) error { + // We unmarshal to the JSON struct to save on duplicate code. + var indexedAttestationJSON indexedAttestationJSON + if err := yaml.Unmarshal(input, &indexedAttestationJSON); err != nil { + return err + } + + return i.unpack(&indexedAttestationJSON) +} + +// String returns a string version of the structure. +func (i *IndexedAttestation) String() string { + data, err := yaml.Marshal(i) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} + +func (i *IndexedAttestation) unpack(indexedAttestationJSON *indexedAttestationJSON) error { + var err error + i.AttestingIndices = make([]uint64, len(indexedAttestationJSON.AttestingIndices)) + for j := range indexedAttestationJSON.AttestingIndices { + if i.AttestingIndices[j], err = strconv.ParseUint(indexedAttestationJSON.AttestingIndices[j], 10, 64); err != nil { + return errors.Wrap(err, "failed to parse attesting index") + } + } + + if indexedAttestationJSON.Data == nil { + return errors.New("data missing") + } + + i.Data = indexedAttestationJSON.Data + if indexedAttestationJSON.Signature == "" { + return errors.New("signature missing") + } + + signature, err := hex.DecodeString(strings.TrimPrefix(indexedAttestationJSON.Signature, "0x")) + if err != nil { + return errors.Wrap(err, "invalid value for signature") + } + + if len(signature) != phase0.SignatureLength { + return errors.New("incorrect length for signature") + } + + copy(i.Signature[:], signature) + + return nil +} diff --git a/spec/gloas/indexedattestation_ssz.go b/spec/gloas/indexedattestation_ssz.go new file mode 100644 index 00000000..0879cec4 --- /dev/null +++ b/spec/gloas/indexedattestation_ssz.go @@ -0,0 +1,147 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 4333584b59b5cad6eb3c9eeade45c584ec1f19d51a5d74c1efe7aa5a5402e7d8 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[IndexedAttestation](`ssz-static:"false"`) + +// MarshalSSZ marshals the *IndexedAttestation to SSZ-encoded bytes. +func (t *IndexedAttestation) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *IndexedAttestation to SSZ-encoded bytes, appending to the provided buffer. +func (t *IndexedAttestation) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(IndexedAttestation) + } + dstlen := len(dst) + // Offset Field #0 'AttestingIndices' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #1 'Data' + t := t.Data + if t == nil { + t = new(phase0.AttestationData) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Data") + } + } + { // Static Field #2 'Signature' + dst = append(dst, t.Signature[:96]...) + } + { // Dynamic Field #0 'AttestingIndices' + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(len(dst)-dstlen)) + t := t.AttestingIndices + vlen := len(t) + dst = sszutils.MarshalUint64Slice(dst, t[:vlen]) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *IndexedAttestation from SSZ-encoded bytes. +func (t *IndexedAttestation) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 228 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 228) + } + // Field #0 'AttestingIndices' (offset) + offset0 := int(binary.LittleEndian.Uint32(buf[0:4])) + if offset0 != 228 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 228), "AttestingIndices:o") + } + { // Field #1 'Data' (static) + buf := buf[4:132] + if t.Data == nil { + t.Data = new(phase0.AttestationData) + } + if err = t.Data.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Data") + } + } + { // Field #2 'Signature' (static) + buf := buf[132:228] + copy(t.Signature[:], buf) + } + { // Field #0 'AttestingIndices' (dynamic) + buf := buf[offset0:] + val1 := t.AttestingIndices + itemCount := len(buf) / 8 + if len(buf)%8 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 8), "AttestingIndices") + } + val1 = sszutils.ExpandSlice(val1, itemCount) + sszutils.UnmarshalUint64Slice(val1, buf) + t.AttestingIndices = val1 + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *IndexedAttestation. +func (t *IndexedAttestation) SizeSSZ() (size int) { + if t == nil { + t = new(IndexedAttestation) + } + // Field #0 'AttestingIndices' offset (4 bytes) + // Field #1 'Data' static (128 bytes) + // Field #2 'Signature' static (96 bytes) + size += 228 + { // Dynamic field #0 'AttestingIndices' + size += len(t.AttestingIndices) * 8 + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *IndexedAttestation. +func (t *IndexedAttestation) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *IndexedAttestation using the given hash walker. +func (t *IndexedAttestation) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(IndexedAttestation) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'AttestingIndices' + t := t.AttestingIndices + idx := hh.StartTree(sszutils.TreeTypeProgressive) + sszutils.HashUint64Slice(hh, t) + hh.FillUpTo32() + vlen := uint64(len(t)) + hh.MerkleizeProgressiveWithMixin(idx, vlen) + } + { // Field #1 'Data' + t := t.Data + if t == nil { + t = new(phase0.AttestationData) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Data") + } + } + { // Field #2 'Signature' + hh.PutBytes(t.Signature[:96]) + } + hh.MerkleizeProgressiveWithActiveFields(idx, []byte{0x07}) + return nil +} diff --git a/spec/gloas/indexedpayloadattestation.go b/spec/gloas/indexedpayloadattestation.go new file mode 100644 index 00000000..fc810120 --- /dev/null +++ b/spec/gloas/indexedpayloadattestation.go @@ -0,0 +1,38 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// IndexedPayloadAttestation represents an indexed payload attestation. +type IndexedPayloadAttestation struct { + AttestingIndices []phase0.ValidatorIndex `dynssz-max:"PTC_SIZE" ssz-index:"0" ssz-max:"512"` + Data *PayloadAttestationData `ssz-index:"1"` + Signature phase0.BLSSignature `ssz-index:"2" ssz-size:"96"` +} + +// String returns a string version of the structure. +func (i *IndexedPayloadAttestation) String() string { + data, err := yaml.Marshal(i) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/indexedpayloadattestation_json.go b/spec/gloas/indexedpayloadattestation_json.go new file mode 100644 index 00000000..4afd977c --- /dev/null +++ b/spec/gloas/indexedpayloadattestation_json.go @@ -0,0 +1,82 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// indexedPayloadAttestationJSON is the spec representation of the struct. +type indexedPayloadAttestationJSON struct { + AttestingIndices []string `json:"attesting_indices"` + Data *PayloadAttestationData `json:"data"` + Signature string `json:"signature"` +} + +// MarshalJSON implements json.Marshaler. +func (i *IndexedPayloadAttestation) MarshalJSON() ([]byte, error) { + attestingIndices := make([]string, len(i.AttestingIndices)) + for index := range i.AttestingIndices { + attestingIndices[index] = fmt.Sprintf("%d", i.AttestingIndices[index]) + } + + return json.Marshal(&indexedPayloadAttestationJSON{ + AttestingIndices: attestingIndices, + Data: i.Data, + Signature: fmt.Sprintf("%#x", i.Signature), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (i *IndexedPayloadAttestation) UnmarshalJSON(input []byte) error { + var data indexedPayloadAttestationJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + i.AttestingIndices = make([]phase0.ValidatorIndex, len(data.AttestingIndices)) + for index := range data.AttestingIndices { + validatorIndex, err := strconv.ParseUint(data.AttestingIndices[index], 10, 64) + if err != nil { + return errors.Wrap(err, "invalid validator index") + } + i.AttestingIndices[index] = phase0.ValidatorIndex(validatorIndex) + } + + if data.Data == nil { + return errors.New("data missing") + } + i.Data = data.Data + + if data.Signature == "" { + return errors.New("signature missing") + } + signature, err := hex.DecodeString(strings.TrimPrefix(data.Signature, "0x")) + if err != nil { + return errors.Wrap(err, "invalid signature") + } + if len(signature) != phase0.SignatureLength { + return errors.New("incorrect length for signature") + } + copy(i.Signature[:], signature) + + return nil +} diff --git a/spec/gloas/indexedpayloadattestation_ssz.go b/spec/gloas/indexedpayloadattestation_ssz.go new file mode 100644 index 00000000..264986bb --- /dev/null +++ b/spec/gloas/indexedpayloadattestation_ssz.go @@ -0,0 +1,155 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: e5106e1b343855cf77f85e7f88bdd3b5a8e65aaa84980b9159905404a08de64f +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[IndexedPayloadAttestation](`ssz-static:"false"`) + +// MarshalSSZ marshals the *IndexedPayloadAttestation to SSZ-encoded bytes. +func (t *IndexedPayloadAttestation) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *IndexedPayloadAttestation to SSZ-encoded bytes, appending to the provided buffer. +func (t *IndexedPayloadAttestation) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(IndexedPayloadAttestation) + } + dstlen := len(dst) + // Offset Field #0 'AttestingIndices' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #1 'Data' + t := t.Data + if t == nil { + t = new(PayloadAttestationData) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Data") + } + } + { // Static Field #2 'Signature' + dst = append(dst, t.Signature[:96]...) + } + { // Dynamic Field #0 'AttestingIndices' + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(len(dst)-dstlen)) + t := t.AttestingIndices + vlen := len(t) + if vlen > 512 { + return nil, sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 512), "AttestingIndices") + } + dst = sszutils.MarshalUint64Slice(dst, t[:vlen]) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *IndexedPayloadAttestation from SSZ-encoded bytes. +func (t *IndexedPayloadAttestation) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 142 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 142) + } + // Field #0 'AttestingIndices' (offset) + offset0 := int(binary.LittleEndian.Uint32(buf[0:4])) + if offset0 != 142 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 142), "AttestingIndices:o") + } + { // Field #1 'Data' (static) + buf := buf[4:46] + if t.Data == nil { + t.Data = new(PayloadAttestationData) + } + if err = t.Data.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Data") + } + } + { // Field #2 'Signature' (static) + buf := buf[46:142] + copy(t.Signature[:], buf) + } + { // Field #0 'AttestingIndices' (dynamic) + buf := buf[offset0:] + val1 := t.AttestingIndices + itemCount := len(buf) / 8 + if len(buf)%8 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 8), "AttestingIndices") + } + if itemCount > 512 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(itemCount, 512), "AttestingIndices") + } + val1 = sszutils.ExpandSlice(val1, itemCount) + sszutils.UnmarshalUint64Slice(val1, buf) + t.AttestingIndices = val1 + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *IndexedPayloadAttestation. +func (t *IndexedPayloadAttestation) SizeSSZ() (size int) { + if t == nil { + t = new(IndexedPayloadAttestation) + } + // Field #0 'AttestingIndices' offset (4 bytes) + // Field #1 'Data' static (42 bytes) + // Field #2 'Signature' static (96 bytes) + size += 142 + { // Dynamic field #0 'AttestingIndices' + size += len(t.AttestingIndices) * 8 + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *IndexedPayloadAttestation. +func (t *IndexedPayloadAttestation) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *IndexedPayloadAttestation using the given hash walker. +func (t *IndexedPayloadAttestation) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(IndexedPayloadAttestation) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'AttestingIndices' + t := t.AttestingIndices + vlen := uint64(len(t)) + if vlen > 512 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 512), "AttestingIndices") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + sszutils.HashUint64Slice(hh, t) + hh.FillUpTo32() + hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(512, vlen, 8)) + } + { // Field #1 'Data' + t := t.Data + if t == nil { + t = new(PayloadAttestationData) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Data") + } + } + { // Field #2 'Signature' + hh.PutBytes(t.Signature[:96]) + } + hh.MerkleizeProgressiveWithActiveFields(idx, []byte{0x07}) + return nil +} diff --git a/spec/gloas/indexedpayloadattestation_yaml.go b/spec/gloas/indexedpayloadattestation_yaml.go new file mode 100644 index 00000000..a5065680 --- /dev/null +++ b/spec/gloas/indexedpayloadattestation_yaml.go @@ -0,0 +1,55 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (i *IndexedPayloadAttestation) MarshalYAML() ([]byte, error) { + attestingIndices := make([]string, len(i.AttestingIndices)) + for index := range i.AttestingIndices { + attestingIndices[index] = fmt.Sprintf("%d", i.AttestingIndices[index]) + } + yamlBytes, err := yaml.MarshalWithOptions(&indexedPayloadAttestationJSON{ + AttestingIndices: attestingIndices, + Data: i.Data, + Signature: fmt.Sprintf("%#x", i.Signature), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (i *IndexedPayloadAttestation) UnmarshalYAML(input []byte) error { + var data indexedPayloadAttestationJSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return i.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/jsonlength_test.go b/spec/gloas/jsonlength_test.go new file mode 100644 index 00000000..b4c5cc1f --- /dev/null +++ b/spec/gloas/jsonlength_test.go @@ -0,0 +1,357 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas_test + +import ( + "encoding/json" + "regexp" + "strings" + "testing" + + bitfield "github.com/OffchainLabs/go-bitfield" + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/holiman/uint256" + require "github.com/stretchr/testify/require" +) + +// hexOfLen returns a 0x-prefixed hex string that decodes to exactly n bytes. +func hexOfLen(n int) string { + return "0x" + strings.Repeat("ab", n) +} + +// mustMarshal renders v as JSON, which the tests use to build a valid document +// for the types whose JSON is too large to spell out by hand. +func mustMarshal(t *testing.T, v any) string { + t.Helper() + + data, err := json.Marshal(v) + require.NoError(t, err) + + return string(data) +} + +// withHexField returns input with the hex value of the given JSON key replaced, +// making a single field wrong-length while the rest of the document stays valid. +// The key must appear exactly once, so the mutation is unambiguous. Input comes +// from mustMarshal, so the pattern can assume compact JSON and lowercase hex. +func withHexField(t *testing.T, input, key, value string) string { + t.Helper() + + re := regexp.MustCompile(`"` + key + `":"0x[0-9a-f]*"`) + require.Len(t, re.FindAllString(input, -1), 1, "key %q must appear exactly once", key) + + return re.ReplaceAllString(input, `"`+key+`":"`+value+`"`) +} + +// validExecutionPayloadEnvelope returns an envelope whose nested payload and +// execution requests satisfy the checks preceding the fields under test. +func validExecutionPayloadEnvelope() *gloas.ExecutionPayloadEnvelope { + return &gloas.ExecutionPayloadEnvelope{ + Payload: &gloas.ExecutionPayload{BaseFeePerGas: uint256.NewInt(0)}, + ExecutionRequests: &gloas.ExecutionRequests{}, + } +} + +// validBeaconState returns a state populated just far enough for the +// unmarshaler to reach latest_block_hash: every field checked before it must be +// present and well-formed. Fields after it are left at their zero value, which +// is harmless because a wrong-length latest_block_hash short-circuits first. +func validBeaconState() *gloas.BeaconState { + // One pubkey per committee suffices: altair.SyncCommittee's unpack only + // requires a non-empty list, and gloas.BeaconState adds no size check. A + // spec-sized 512 would inflate this fixture from ~2.6 KB to ~106 KB. + pubkeys := make([]phase0.BLSPubKey, 1) + + return &gloas.BeaconState{ + Fork: &phase0.Fork{}, + LatestBlockHeader: &phase0.BeaconBlockHeader{}, + ETH1Data: &phase0.ETH1Data{BlockHash: make([]byte, phase0.Hash32Length)}, + JustificationBits: bitfield.NewBitvector4(), + PreviousJustifiedCheckpoint: &phase0.Checkpoint{}, + CurrentJustifiedCheckpoint: &phase0.Checkpoint{}, + FinalizedCheckpoint: &phase0.Checkpoint{}, + CurrentSyncCommittee: &altair.SyncCommittee{Pubkeys: pubkeys}, + NextSyncCommittee: &altair.SyncCommittee{Pubkeys: pubkeys}, + } +} + +// validBeaconBlock returns a block whose body satisfies the nested checks that +// precede a signed block's own signature. +func validBeaconBlock() *gloas.BeaconBlock { + return &gloas.BeaconBlock{ + Body: &gloas.BeaconBlockBody{ + ETH1Data: &phase0.ETH1Data{BlockHash: make([]byte, phase0.Hash32Length)}, + SyncAggregate: &altair.SyncAggregate{SyncCommitteeBits: bitfield.NewBitvector512()}, + SignedExecutionPayloadBid: &gloas.SignedExecutionPayloadBid{Message: &gloas.ExecutionPayloadBid{}}, + ParentExecutionRequests: &gloas.ExecutionRequests{}, + }, + } +} + +// TestJSONFixedLengthGuards verifies that the hand-written JSON unmarshalers +// reject a hex value whose decoded length does not match the fixed-size array it +// is copied into. hex.DecodeString accepts any even-length input, so without a +// length check a short value silently zero-pads the array's tail and a long one +// is silently truncated, with UnmarshalJSON still returning nil — handing the +// caller a corrupted root, hash, address or signature and surfacing no error. +func TestJSONFixedLengthGuards(t *testing.T) { + bidJSON := mustMarshal(t, &gloas.ExecutionPayloadBid{}) + envelopeJSON := mustMarshal(t, validExecutionPayloadEnvelope()) + blockJSON := mustMarshal(t, validBeaconBlock()) + stateJSON := mustMarshal(t, validBeaconState()) + dataJSON := mustMarshal(t, &gloas.PayloadAttestationData{}) + prefsJSON := mustMarshal(t, &gloas.ProposerPreferences{}) + + tests := []struct { + name string + target json.Unmarshaler + input string + err string + }{ + { + name: "PayloadAttestationDataBeaconBlockRootShort", + target: &gloas.PayloadAttestationData{}, + input: `{"beacon_block_root":"` + hexOfLen(31) + `","slot":"1","payload_present":true,"blob_data_available":true}`, + err: "incorrect length for beacon block root", + }, + { + name: "PayloadAttestationDataBeaconBlockRootLong", + target: &gloas.PayloadAttestationData{}, + input: `{"beacon_block_root":"` + hexOfLen(33) + `","slot":"1","payload_present":true,"blob_data_available":true}`, + err: "incorrect length for beacon block root", + }, + { + name: "PayloadAttestationMessageSignatureShort", + target: &gloas.PayloadAttestationMessage{}, + input: `{"validator_index":"1","data":` + dataJSON + `,"signature":"` + hexOfLen(95) + `"}`, + err: "incorrect length for signature", + }, + { + name: "PayloadAttestationMessageSignatureLong", + target: &gloas.PayloadAttestationMessage{}, + input: `{"validator_index":"1","data":` + dataJSON + `,"signature":"` + hexOfLen(97) + `"}`, + err: "incorrect length for signature", + }, + { + name: "ProposerPreferencesFeeRecipientShort", + target: &gloas.ProposerPreferences{}, + input: withHexField(t, prefsJSON, "fee_recipient", hexOfLen(19)), + err: "incorrect length for fee recipient", + }, + { + name: "ProposerPreferencesFeeRecipientLong", + target: &gloas.ProposerPreferences{}, + input: withHexField(t, prefsJSON, "fee_recipient", hexOfLen(21)), + err: "incorrect length for fee recipient", + }, + { + name: "PayloadAttestationSignatureShort", + target: &gloas.PayloadAttestation{}, + input: `{"aggregation_bits":"` + hexOfLen(64) + `","data":` + dataJSON + + `,"signature":"` + hexOfLen(95) + `"}`, + err: "incorrect length for signature", + }, + { + name: "IndexedPayloadAttestationSignatureShort", + target: &gloas.IndexedPayloadAttestation{}, + input: `{"attesting_indices":["1"],"data":` + dataJSON + `,"signature":"` + hexOfLen(95) + `"}`, + err: "incorrect length for signature", + }, + { + name: "SignedProposerPreferencesSignatureShort", + target: &gloas.SignedProposerPreferences{}, + input: `{"message":` + prefsJSON + `,"signature":"` + hexOfLen(95) + `"}`, + err: "incorrect length for signature", + }, + { + name: "SignedExecutionPayloadBidSignatureShort", + target: &gloas.SignedExecutionPayloadBid{}, + input: `{"message":` + bidJSON + `,"signature":"` + hexOfLen(95) + `"}`, + err: "incorrect length for signature", + }, + { + name: "SignedExecutionPayloadEnvelopeSignatureShort", + target: &gloas.SignedExecutionPayloadEnvelope{}, + input: `{"message":` + envelopeJSON + `,"signature":"` + hexOfLen(95) + `"}`, + err: "incorrect length for signature", + }, + { + name: "SignedBeaconBlockSignatureShort", + target: &gloas.SignedBeaconBlock{}, + input: `{"message":` + blockJSON + `,"signature":"` + hexOfLen(95) + `"}`, + err: "incorrect length for signature", + }, + { + name: "ExecutionPayloadEnvelopeBeaconBlockRootShort", + target: &gloas.ExecutionPayloadEnvelope{}, + input: withHexField(t, envelopeJSON, "beacon_block_root", hexOfLen(31)), + err: "incorrect length for beacon block root", + }, + { + name: "ExecutionPayloadEnvelopeParentBeaconBlockRootShort", + target: &gloas.ExecutionPayloadEnvelope{}, + input: withHexField(t, envelopeJSON, "parent_beacon_block_root", hexOfLen(31)), + err: "incorrect length for parent beacon block root", + }, + { + name: "ExecutionPayloadBidParentBlockHashShort", + target: &gloas.ExecutionPayloadBid{}, + input: withHexField(t, bidJSON, "parent_block_hash", hexOfLen(31)), + err: "incorrect length for parent block hash", + }, + { + name: "ExecutionPayloadBidParentBlockRootShort", + target: &gloas.ExecutionPayloadBid{}, + input: withHexField(t, bidJSON, "parent_block_root", hexOfLen(31)), + err: "incorrect length for parent block root", + }, + { + name: "ExecutionPayloadBidBlockHashShort", + target: &gloas.ExecutionPayloadBid{}, + input: withHexField(t, bidJSON, "block_hash", hexOfLen(31)), + err: "incorrect length for block hash", + }, + { + name: "ExecutionPayloadBidPrevRandaoShort", + target: &gloas.ExecutionPayloadBid{}, + input: withHexField(t, bidJSON, "prev_randao", hexOfLen(31)), + err: "incorrect length for prev randao", + }, + { + name: "ExecutionPayloadBidFeeRecipientShort", + target: &gloas.ExecutionPayloadBid{}, + input: withHexField(t, bidJSON, "fee_recipient", hexOfLen(19)), + err: "incorrect length for fee recipient", + }, + { + name: "ExecutionPayloadBidExecutionRequestsRootShort", + target: &gloas.ExecutionPayloadBid{}, + input: withHexField(t, bidJSON, "execution_requests_root", hexOfLen(31)), + err: "incorrect length for execution requests root", + }, + { + // randao_reveal is the body's first check, so no other field is needed. + name: "BeaconBlockBodyRANDAORevealShort", + target: &gloas.BeaconBlockBody{}, + input: `{"randao_reveal":"` + hexOfLen(95) + `"}`, + err: "incorrect length for randao reveal", + }, + { + name: "BeaconBlockBodyGraffitiShort", + target: &gloas.BeaconBlockBody{}, + input: `{"randao_reveal":"` + hexOfLen(96) + `","graffiti":"` + hexOfLen(31) + `"}`, + err: "incorrect length for graffiti", + }, + { + name: "BeaconStateLatestBlockHashShort", + target: &gloas.BeaconState{}, + input: withHexField(t, stateJSON, "latest_block_hash", hexOfLen(31)), + err: "incorrect length for latest block hash", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := json.Unmarshal([]byte(test.input), test.target) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + } + }) + } +} + +// TestJSONFixedLengthRoundTrip is the counterpart to TestJSONFixedLengthGuards: +// it verifies the guards reject only genuinely wrong-length values. A +// correctly-sized document must still unmarshal and re-marshal unchanged, which +// would fail if a guard used the wrong length constant or a one-sided +// comparison. +// +// The BeaconState row does double duty: its fixture populates all 46 fields rather +// than only those preceding a guard, so it also catches any field whose marshal and +// unmarshal disagree, length-guarded or not — which is how it caught gloas.Builder +// dropping its version. +func TestJSONFixedLengthRoundTrip(t *testing.T) { + tests := []struct { + name string + value any + target json.Unmarshaler + }{ + { + name: "PayloadAttestationData", + value: &gloas.PayloadAttestationData{}, + target: &gloas.PayloadAttestationData{}, + }, + { + name: "PayloadAttestationMessage", + value: &gloas.PayloadAttestationMessage{Data: &gloas.PayloadAttestationData{}}, + target: &gloas.PayloadAttestationMessage{}, + }, + { + name: "ProposerPreferences", + value: &gloas.ProposerPreferences{}, + target: &gloas.ProposerPreferences{}, + }, + { + name: "SignedProposerPreferences", + value: &gloas.SignedProposerPreferences{Message: &gloas.ProposerPreferences{}}, + target: &gloas.SignedProposerPreferences{}, + }, + { + name: "ExecutionPayloadBid", + value: &gloas.ExecutionPayloadBid{}, + target: &gloas.ExecutionPayloadBid{}, + }, + { + name: "SignedExecutionPayloadBid", + value: &gloas.SignedExecutionPayloadBid{Message: &gloas.ExecutionPayloadBid{}}, + target: &gloas.SignedExecutionPayloadBid{}, + }, + { + name: "ExecutionPayloadEnvelope", + value: validExecutionPayloadEnvelope(), + target: &gloas.ExecutionPayloadEnvelope{}, + }, + { + name: "SignedExecutionPayloadEnvelope", + value: &gloas.SignedExecutionPayloadEnvelope{Message: validExecutionPayloadEnvelope()}, + target: &gloas.SignedExecutionPayloadEnvelope{}, + }, + { + // Also exercises the body's randao_reveal and graffiti guards. + name: "SignedBeaconBlock", + value: &gloas.SignedBeaconBlock{Message: validBeaconBlock()}, + target: &gloas.SignedBeaconBlock{}, + }, + { + // All 46 fields populated, per the note above. A field wrong the same + // way in both directions still passes here; TestBeaconStateJSONWireShapes + // is what catches those. + name: "BeaconState", + value: populatedBeaconState(), + target: &gloas.BeaconState{}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + input := mustMarshal(t, test.value) + require.NoError(t, json.Unmarshal([]byte(input), test.target)) + require.Equal(t, input, mustMarshal(t, test.target)) + }) + } +} diff --git a/spec/gloas/payloadattestation.go b/spec/gloas/payloadattestation.go new file mode 100644 index 00000000..a7024de2 --- /dev/null +++ b/spec/gloas/payloadattestation.go @@ -0,0 +1,113 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + bitfield "github.com/OffchainLabs/go-bitfield" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// PayloadAttestation represents a payload attestation. +type PayloadAttestation struct { + // AggregationBits is Bitvector[PTC_SIZE], so its width follows the preset: 64 + // bytes at mainnet, 2 on the minimal preset. Four of the bitvector's own methods + // are hard-coded to the mainnet width and must not be used: at any other preset + // BitAt returns false for every index of a correctly-sized value, SetBitAt + // silently discards the write, Len reports 512 regardless, and Shift panics + // outright on a value shorter than eight bytes. Use AttestedAt, SetAttestedAt and + // PTCSize instead. BitIndices, Count and Bytes are length-tolerant up to the + // mainnet width; above it they silently ignore the excess and so disagree with + // PTCSize, which a node sending an over-wide value can provoke. + AggregationBits bitfield.Bitvector512 `dynssz-size:"PTC_SIZE/8" ssz-index:"0" ssz-size:"64"` + Data *PayloadAttestationData `ssz-index:"1"` + Signature phase0.BLSSignature `ssz-index:"2" ssz-size:"96"` +} + +// PTCSize reports how many payload timeliness committee positions the aggregation +// bits cover. Use this rather than AggregationBits.Len(); see AggregationBits. +// +// This is the width of the value as decoded, not a width verified against the chain's +// own PTC_SIZE, so for an attestation decoded from a beacon node it is that node's +// claim rather than an established fact. A caller that needs the distinction must +// check it against the spec itself. +func (p *PayloadAttestation) PTCSize() uint64 { + return uint64(len(p.AggregationBits)) * 8 +} + +// checkPTCIndex errors if the given position falls outside the committee the +// aggregation bits cover. Both accessors share it so the bound and its error can +// never disagree between the read and the write direction. +func (p *PayloadAttestation) checkPTCIndex(ptcIndex uint64) error { + if size := p.PTCSize(); ptcIndex >= size { + return fmt.Errorf("payload timeliness committee index %d out of range for committee of %d", ptcIndex, size) + } + + return nil +} + +// AttestedAt reports whether the payload timeliness committee member at the given +// position attested. The position is an index into the committee returned by +// get_ptc() for the attestation's slot, not a validator index. +// +// Use this rather than AggregationBits.BitAt(), which is mainnet-only; see +// AggregationBits. An index outside the committee is an error here rather than a +// false, so a failed read cannot be mistaken for an unset bit. +func (p *PayloadAttestation) AttestedAt(ptcIndex uint64) (bool, error) { + if err := p.checkPTCIndex(ptcIndex); err != nil { + return false, err + } + + return p.AggregationBits[ptcIndex/8]&(byte(1)<<(ptcIndex%8)) != 0, nil +} + +// SetAttestedAt records participation for the payload timeliness committee member at +// the given position, which is an index into the committee returned by get_ptc() for +// the attestation's slot, not a validator index. +// +// Use this rather than AggregationBits.SetBitAt(), which is mainnet-only; see +// AggregationBits. bitfield has no length-tolerant setter to delegate to, so the bit +// is set here directly, and an index outside the committee is an error rather than a +// dropped write. +// +// The attested bool mirrors the SetBitAt signature this replaces, so a caller passing a +// computed boolean keeps a single call rather than branching over two method names. +// +//nolint:revive // the bool mirrors the signature this replaces +func (p *PayloadAttestation) SetAttestedAt(ptcIndex uint64, attested bool) error { + if err := p.checkPTCIndex(ptcIndex); err != nil { + return err + } + + bit := byte(1) << (ptcIndex % 8) + if attested { + p.AggregationBits[ptcIndex/8] |= bit + } else { + p.AggregationBits[ptcIndex/8] &^= bit + } + + return nil +} + +// String returns a string version of the structure. +func (p *PayloadAttestation) String() string { + data, err := yaml.Marshal(p) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/payloadattestation_json.go b/spec/gloas/payloadattestation_json.go new file mode 100644 index 00000000..5771737f --- /dev/null +++ b/spec/gloas/payloadattestation_json.go @@ -0,0 +1,76 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// payloadAttestationJSON is the spec representation of the struct. +type payloadAttestationJSON struct { + AggregationBits string `json:"aggregation_bits"` + Data *PayloadAttestationData `json:"data"` + Signature string `json:"signature"` +} + +// MarshalJSON implements json.Marshaler. +func (p *PayloadAttestation) MarshalJSON() ([]byte, error) { + return json.Marshal(&payloadAttestationJSON{ + AggregationBits: fmt.Sprintf("%#x", p.AggregationBits), + Data: p.Data, + Signature: fmt.Sprintf("%#x", p.Signature), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (p *PayloadAttestation) UnmarshalJSON(input []byte) error { + var data payloadAttestationJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if data.AggregationBits == "" { + return errors.New("aggregation bits missing") + } + bits, err := hex.DecodeString(strings.TrimPrefix(data.AggregationBits, "0x")) + if err != nil { + return errors.Wrap(err, "invalid aggregation bits") + } + p.AggregationBits = bits + + if data.Data == nil { + return errors.New("data missing") + } + p.Data = data.Data + + if data.Signature == "" { + return errors.New("signature missing") + } + signature, err := hex.DecodeString(strings.TrimPrefix(data.Signature, "0x")) + if err != nil { + return errors.Wrap(err, "invalid signature") + } + if len(signature) != phase0.SignatureLength { + return errors.New("incorrect length for signature") + } + copy(p.Signature[:], signature) + + return nil +} diff --git a/spec/gloas/payloadattestation_ssz.go b/spec/gloas/payloadattestation_ssz.go new file mode 100644 index 00000000..5431ea20 --- /dev/null +++ b/spec/gloas/payloadattestation_ssz.go @@ -0,0 +1,130 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 6e7bc3ef07452a47ae01e1a88fbfe02bd3f61e8392b5fa4ab997c9e81569bfa6 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[PayloadAttestation](`ssz-static:"true"`) + +// MarshalSSZ marshals the *PayloadAttestation to SSZ-encoded bytes. +func (t *PayloadAttestation) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *PayloadAttestation to SSZ-encoded bytes, appending to the provided buffer. +func (t *PayloadAttestation) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(PayloadAttestation) + } + { // Static Field #0 'AggregationBits' + vlen := len(t.AggregationBits) + if vlen > 64 { + return nil, sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 64), "AggregationBits") + } + dst = append(dst, t.AggregationBits[:vlen]...) + if vlen < 64 { + dst = sszutils.AppendZeroPadding(dst, (64-vlen)*1) + } + } + { // Static Field #1 'Data' + t := t.Data + if t == nil { + t = new(PayloadAttestationData) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Data") + } + } + { // Static Field #2 'Signature' + dst = append(dst, t.Signature[:96]...) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *PayloadAttestation from SSZ-encoded bytes. +func (t *PayloadAttestation) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 202 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 202) + } + if buflen > 202 { + return sszutils.ErrTrailingDataFn(buflen - 202) + } + { // Field #0 'AggregationBits' (static) + buf := buf[0:64] + t.AggregationBits = sszutils.ExpandSlice(t.AggregationBits, 64) + copy(t.AggregationBits[:], buf) + } + { // Field #1 'Data' (static) + buf := buf[64:106] + if t.Data == nil { + t.Data = new(PayloadAttestationData) + } + if err = t.Data.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Data") + } + } + { // Field #2 'Signature' (static) + buf := buf[106:202] + copy(t.Signature[:], buf) + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *PayloadAttestation. +func (t *PayloadAttestation) SizeSSZ() (size int) { + return 202 +} + +// HashTreeRoot computes the SSZ hash tree root of the *PayloadAttestation. +func (t *PayloadAttestation) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *PayloadAttestation using the given hash walker. +func (t *PayloadAttestation) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(PayloadAttestation) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'AggregationBits' + vlen := len(t.AggregationBits) + if vlen > 64 { + return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, 64), "AggregationBits") + } + val := t.AggregationBits[:] + if vlen < 64 { + val = sszutils.AppendZeroPadding(val, (64-vlen)*1) + } + hh.PutBytes(val[:64]) + } + { // Field #1 'Data' + t := t.Data + if t == nil { + t = new(PayloadAttestationData) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Data") + } + } + { // Field #2 'Signature' + hh.PutBytes(t.Signature[:96]) + } + hh.MerkleizeProgressiveWithActiveFields(idx, []byte{0x07}) + return nil +} diff --git a/spec/gloas/payloadattestation_test.go b/spec/gloas/payloadattestation_test.go new file mode 100644 index 00000000..7fa616d0 --- /dev/null +++ b/spec/gloas/payloadattestation_test.go @@ -0,0 +1,269 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas_test + +import ( + "fmt" + "math" + "reflect" + "slices" + "testing" + + bitfield "github.com/OffchainLabs/go-bitfield" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/stretchr/testify/require" +) + +// The aggregation bits of a payload attestation are Bitvector[PTC_SIZE], so their +// width is preset-dependent: 512 bits (64 bytes) at mainnet, 16 bits (2 bytes) on +// the minimal preset the validation devnet runs. Both widths below are valid, +// correctly-decoded values. +const ( + minimalPTCBytes = 2 + mainnetPTCBytes = 64 +) + +// TestPayloadAttestationSignatureSSZSize pins the Bytes96 width that the +// consensus spec assigns to both payload-attestation signatures. +func TestPayloadAttestationSignatureSSZSize(t *testing.T) { + tests := []struct { + name string + typ reflect.Type + }{ + { + name: "PayloadAttestation", + typ: reflect.TypeFor[gloas.PayloadAttestation](), + }, + { + name: "IndexedPayloadAttestation", + typ: reflect.TypeFor[gloas.IndexedPayloadAttestation](), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + field, found := test.typ.FieldByName("Signature") + require.True(t, found) + require.Equal(t, "96", field.Tag.Get("ssz-size")) + }) + } +} + +// TestPayloadAttestationAttestedAtPresets reads every position of a correctly-sized +// bitvector at both the mainnet and minimal widths, including the highest valid +// index, which is where a mainnet-fixed width check bites. +// +// The fixtures are hand-written bytes, never built with SetAttestedAt: deriving them +// from the code under test would make the assertion circular. BitIndices and Count +// are the length-tolerant members of bitfield's own API, so they serve as an +// independent oracle that the byte and bit ordering here matches go-bitfield's. +func TestPayloadAttestationAttestedAtPresets(t *testing.T) { + mainnetBits := make(bitfield.Bitvector512, mainnetPTCBytes) + mainnetBits[0] = 0x09 // positions 0 and 3 + mainnetBits[63] = 0x80 // position 511, the highest valid at mainnet + + tests := []struct { + name string + bits bitfield.Bitvector512 + size uint64 + // attesting lists, ascending, the positions the fixture bytes set. + attesting []int + }{ + { + name: "MinimalPreset", + bits: bitfield.Bitvector512{0x09, 0x80}, // positions 0, 3 and 15 + size: minimalPTCBytes * 8, + attesting: []int{0, 3, 15}, + }, + { + name: "MainnetPreset", + bits: mainnetBits, + size: mainnetPTCBytes * 8, + attesting: []int{0, 3, 511}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + attestation := &gloas.PayloadAttestation{AggregationBits: test.bits} + + for position := range test.size { + attested, err := attestation.AttestedAt(position) + require.NoError(t, err) + require.Equal(t, slices.Contains(test.attesting, int(position)), attested, + "position %d", position) + } + + // Out of range is an error rather than a false: the whole defect being + // fixed here is that a failed read is indistinguishable from an unset bit. + _, err := attestation.AttestedAt(test.size) + require.ErrorContains(t, err, "out of range") + + require.Equal(t, test.attesting, test.bits.BitIndices()) + require.Equal(t, uint64(len(test.attesting)), test.bits.Count()) + }) + } +} + +// TestPayloadAttestationPTCSize covers the committee width, which bitfield's own +// Len() reports as the mainnet 512 whatever the bitvector actually holds. +func TestPayloadAttestationPTCSize(t *testing.T) { + tests := []struct { + name string + bits bitfield.Bitvector512 + expected uint64 + }{ + { + name: "MinimalPreset", + bits: make(bitfield.Bitvector512, minimalPTCBytes), + expected: 16, + }, + { + name: "MainnetPreset", + bits: make(bitfield.Bitvector512, mainnetPTCBytes), + expected: 512, + }, + { + // An absent bitvector covers no committee at all. Reporting 512 is what + // makes a zero-valued field look like a full mainnet committee. + name: "Absent", + expected: 0, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + attestation := &gloas.PayloadAttestation{AggregationBits: test.bits} + require.Equal(t, test.expected, attestation.PTCSize()) + }) + } + + // The premise this whole file exists for, pinned deliberately once: bitfield's own + // Len() is the mainnet constant at every width, including a nil bitvector covering + // no committee at all. Should this ever fail, go-bitfield has grown preset-aware + // accessors and these accessors are worth revisiting. + require.Equal(t, uint64(512), bitfield.Bitvector512(nil).Len()) + require.Equal(t, uint64(512), bitfield.Bitvector512{0x00, 0x00}.Len()) +} + +// TestPayloadAttestationSetAttestedAt covers the write direction at both presets. +// bitfield offers no length-tolerant setter at all, so without this accessor +// building a minimal-preset aggregate is not expressible against this type. +func TestPayloadAttestationSetAttestedAt(t *testing.T) { + tests := []struct { + name string + bytes int + size uint64 + }{ + {name: "MinimalPreset", bytes: minimalPTCBytes, size: minimalPTCBytes * 8}, + {name: "MainnetPreset", bytes: mainnetPTCBytes, size: mainnetPTCBytes * 8}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + attestation := &gloas.PayloadAttestation{ + AggregationBits: make(bitfield.Bitvector512, test.bytes), + } + + // Aggregate two members, one of them the highest valid position. + require.NoError(t, attestation.SetAttestedAt(0, true)) + require.NoError(t, attestation.SetAttestedAt(test.size-1, true)) + + // The length-tolerant family is what every other reader of these bits + // uses, so its agreement is what proves the write landed in the right + // place rather than merely in a place this file's reader agrees with. + require.Equal(t, []int{0, int(test.size - 1)}, attestation.AggregationBits.BitIndices()) + require.Equal(t, uint64(2), attestation.AggregationBits.Count()) + + // Hand-computed bytes: position 0 is bit 0 of the first byte, and the + // highest position is bit 7 of the last. + require.Equal(t, byte(0x01), attestation.AggregationBits[0]) + require.Equal(t, byte(0x80), attestation.AggregationBits[test.bytes-1]) + + attested, err := attestation.AttestedAt(test.size - 1) + require.NoError(t, err) + require.True(t, attested) + + // Clearing must clear, and must leave its neighbours alone. + require.NoError(t, attestation.SetAttestedAt(test.size-1, false)) + attested, err = attestation.AttestedAt(test.size - 1) + require.NoError(t, err) + require.False(t, attested) + require.Equal(t, uint64(1), attestation.AggregationBits.Count()) + + // Out of range must error rather than no-op: a silently dropped write + // is how SetBitAt loses an aggregate. + require.ErrorContains(t, attestation.SetAttestedAt(test.size, true), "out of range") + require.Equal(t, uint64(1), attestation.AggregationBits.Count()) + }) + } + + // The exact message, pinned once. + attestation := &gloas.PayloadAttestation{ + AggregationBits: make(bitfield.Bitvector512, minimalPTCBytes), + } + require.EqualError(t, attestation.SetAttestedAt(16, true), + "payload timeliness committee index 16 out of range for committee of 16") +} + +// TestPayloadAttestationZeroValueAccessors covers the zero-valued container, whose +// bitvector is nil. Indexing one without a bound check panics, which is a failure +// mode this package has recorded before for a hand-written accessor. +func TestPayloadAttestationZeroValueAccessors(t *testing.T) { + attestation := &gloas.PayloadAttestation{} + + require.Zero(t, attestation.PTCSize()) + + _, err := attestation.AttestedAt(0) + require.EqualError(t, err, "payload timeliness committee index 0 out of range for committee of 0") + + require.EqualError(t, attestation.SetAttestedAt(0, true), + "payload timeliness committee index 0 out of range for committee of 0") +} + +// TestPayloadAttestationArbitraryWidths pins the property the accessors' safety rests +// on: the width of AggregationBits is whatever a beacon node sent, since nothing on +// the JSON path checks it against the chain's PTC_SIZE. So every width must be +// handled without panicking -- widths no preset produces, and one far larger than +// mainnet, included. +func TestPayloadAttestationArbitraryWidths(t *testing.T) { + for _, width := range []int{0, 1, 2, 3, 7, 63, 64, 65, 1000} { + t.Run(fmt.Sprintf("%dBytes", width), func(t *testing.T) { + attestation := &gloas.PayloadAttestation{ + AggregationBits: make(bitfield.Bitvector512, width), + } + size := uint64(width) * 8 + require.Equal(t, size, attestation.PTCSize()) + + // Every position the reported size admits round-trips, and the first + // position beyond it is refused in both directions. + for position := range size { + require.NoError(t, attestation.SetAttestedAt(position, true)) + attested, err := attestation.AttestedAt(position) + require.NoError(t, err) + require.True(t, attested) + } + require.Equal(t, size, attestation.PTCSize()) + + _, err := attestation.AttestedAt(size) + require.ErrorContains(t, err, "out of range") + require.ErrorContains(t, attestation.SetAttestedAt(size, true), "out of range") + + // The largest index representable cannot be made to index anything. + _, err = attestation.AttestedAt(math.MaxUint64) + require.ErrorContains(t, err, "out of range") + require.ErrorContains(t, attestation.SetAttestedAt(math.MaxUint64, true), "out of range") + }) + } +} diff --git a/spec/gloas/payloadattestation_yaml.go b/spec/gloas/payloadattestation_yaml.go new file mode 100644 index 00000000..fafd7aed --- /dev/null +++ b/spec/gloas/payloadattestation_yaml.go @@ -0,0 +1,51 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (p *PayloadAttestation) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&payloadAttestationJSON{ + AggregationBits: fmt.Sprintf("%#x", p.AggregationBits), + Data: p.Data, + Signature: fmt.Sprintf("%#x", p.Signature), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (p *PayloadAttestation) UnmarshalYAML(input []byte) error { + var data payloadAttestationJSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return p.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/payloadattestationdata.go b/spec/gloas/payloadattestationdata.go new file mode 100644 index 00000000..e9199ace --- /dev/null +++ b/spec/gloas/payloadattestationdata.go @@ -0,0 +1,39 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// PayloadAttestationData represents payload attestation data. +type PayloadAttestationData struct { + BeaconBlockRoot phase0.Root `ssz-size:"32"` + Slot phase0.Slot + PayloadPresent bool + BlobDataAvailable bool +} + +// String returns a string version of the structure. +func (p *PayloadAttestationData) String() string { + data, err := yaml.Marshal(p) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/payloadattestationdata_json.go b/spec/gloas/payloadattestationdata_json.go new file mode 100644 index 00000000..b9d480bd --- /dev/null +++ b/spec/gloas/payloadattestationdata_json.go @@ -0,0 +1,75 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// payloadAttestationDataJSON is the spec representation of the struct. +type payloadAttestationDataJSON struct { + BeaconBlockRoot string `json:"beacon_block_root"` + Slot string `json:"slot"` + PayloadPresent bool `json:"payload_present"` + BlobDataAvailable bool `json:"blob_data_available"` +} + +// MarshalJSON implements json.Marshaler. +func (p *PayloadAttestationData) MarshalJSON() ([]byte, error) { + return json.Marshal(&payloadAttestationDataJSON{ + BeaconBlockRoot: fmt.Sprintf("%#x", p.BeaconBlockRoot), + Slot: fmt.Sprintf("%d", p.Slot), + PayloadPresent: p.PayloadPresent, + BlobDataAvailable: p.BlobDataAvailable, + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (p *PayloadAttestationData) UnmarshalJSON(input []byte) error { + var data payloadAttestationDataJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if data.BeaconBlockRoot == "" { + return errors.New("beacon block root missing") + } + root, err := hex.DecodeString(strings.TrimPrefix(data.BeaconBlockRoot, "0x")) + if err != nil { + return errors.Wrap(err, "invalid beacon block root") + } + if len(root) != phase0.RootLength { + return errors.New("incorrect length for beacon block root") + } + copy(p.BeaconBlockRoot[:], root) + + slot, err := strconv.ParseUint(data.Slot, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid slot") + } + p.Slot = phase0.Slot(slot) + + p.PayloadPresent = data.PayloadPresent + + p.BlobDataAvailable = data.BlobDataAvailable + + return nil +} diff --git a/spec/gloas/payloadattestationdata_ssz.go b/spec/gloas/payloadattestationdata_ssz.go new file mode 100644 index 00000000..0ce14d82 --- /dev/null +++ b/spec/gloas/payloadattestationdata_ssz.go @@ -0,0 +1,116 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: bc7c6dc18dab78bc139b97fbd8777cc5cc5aa36fd9e267e054cad05789205c6d +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[PayloadAttestationData](`ssz-static:"true"`) + +// MarshalSSZ marshals the *PayloadAttestationData to SSZ-encoded bytes. +func (t *PayloadAttestationData) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *PayloadAttestationData to SSZ-encoded bytes, appending to the provided buffer. +func (t *PayloadAttestationData) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(PayloadAttestationData) + } + { // Static Field #0 'BeaconBlockRoot' + dst = append(dst, t.BeaconBlockRoot[:32]...) + } + { // Static Field #1 'Slot' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.Slot)) + } + { // Static Field #2 'PayloadPresent' + dst = sszutils.MarshalBool(dst, t.PayloadPresent) + } + { // Static Field #3 'BlobDataAvailable' + dst = sszutils.MarshalBool(dst, t.BlobDataAvailable) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *PayloadAttestationData from SSZ-encoded bytes. +func (t *PayloadAttestationData) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 42 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 42) + } + if buflen > 42 { + return sszutils.ErrTrailingDataFn(buflen - 42) + } + { // Field #0 'BeaconBlockRoot' (static) + buf := buf[0:32] + copy(t.BeaconBlockRoot[:], buf) + } + { // Field #1 'Slot' (static) + buf := buf[32:40] + t.Slot = phase0.Slot(binary.LittleEndian.Uint64(buf)) + } + { // Field #2 'PayloadPresent' (static) + buf := buf[40:41] + if buf[0] != 1 && buf[0] != 0 { + return sszutils.ErrInvalidBoolValueFn() + } + t.PayloadPresent = buf[0] == 1 + } + { // Field #3 'BlobDataAvailable' (static) + buf := buf[41:42] + if buf[0] != 1 && buf[0] != 0 { + return sszutils.ErrInvalidBoolValueFn() + } + t.BlobDataAvailable = buf[0] == 1 + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *PayloadAttestationData. +func (t *PayloadAttestationData) SizeSSZ() (size int) { + return 42 +} + +// HashTreeRoot computes the SSZ hash tree root of the *PayloadAttestationData. +func (t *PayloadAttestationData) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *PayloadAttestationData using the given hash walker. +func (t *PayloadAttestationData) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(PayloadAttestationData) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'BeaconBlockRoot' + hh.PutBytes(t.BeaconBlockRoot[:32]) + } + { // Field #1 'Slot' + hh.PutUint64(uint64(t.Slot)) + } + { // Field #2 'PayloadPresent' + hh.PutBool(t.PayloadPresent) + } + { // Field #3 'BlobDataAvailable' + hh.PutBool(t.BlobDataAvailable) + } + hh.Merkleize(idx) + return nil +} diff --git a/spec/gloas/payloadattestationdata_yaml.go b/spec/gloas/payloadattestationdata_yaml.go new file mode 100644 index 00000000..25250aa9 --- /dev/null +++ b/spec/gloas/payloadattestationdata_yaml.go @@ -0,0 +1,52 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (p *PayloadAttestationData) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&payloadAttestationDataJSON{ + BeaconBlockRoot: fmt.Sprintf("%#x", p.BeaconBlockRoot), + Slot: fmt.Sprintf("%d", p.Slot), + PayloadPresent: p.PayloadPresent, + BlobDataAvailable: p.BlobDataAvailable, + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (p *PayloadAttestationData) UnmarshalYAML(input []byte) error { + var data payloadAttestationDataJSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return p.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/payloadattestationmessage.go b/spec/gloas/payloadattestationmessage.go new file mode 100644 index 00000000..a584d8dc --- /dev/null +++ b/spec/gloas/payloadattestationmessage.go @@ -0,0 +1,38 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// PayloadAttestationMessage represents a payload attestation message. +type PayloadAttestationMessage struct { + ValidatorIndex phase0.ValidatorIndex + Data *PayloadAttestationData + Signature phase0.BLSSignature `ssz-size:"96"` +} + +// String returns a string version of the structure. +func (p *PayloadAttestationMessage) String() string { + data, err := yaml.Marshal(p) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/payloadattestationmessage_json.go b/spec/gloas/payloadattestationmessage_json.go new file mode 100644 index 00000000..9d1dc669 --- /dev/null +++ b/spec/gloas/payloadattestationmessage_json.go @@ -0,0 +1,77 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// payloadAttestationMessageJSON is the spec representation of the struct. +type payloadAttestationMessageJSON struct { + ValidatorIndex string `json:"validator_index"` + Data *PayloadAttestationData `json:"data"` + Signature string `json:"signature"` +} + +// MarshalJSON implements json.Marshaler. +func (p *PayloadAttestationMessage) MarshalJSON() ([]byte, error) { + return json.Marshal(&payloadAttestationMessageJSON{ + ValidatorIndex: fmt.Sprintf("%d", p.ValidatorIndex), + Data: p.Data, + Signature: fmt.Sprintf("%#x", p.Signature), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (p *PayloadAttestationMessage) UnmarshalJSON(input []byte) error { + var data payloadAttestationMessageJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if data.ValidatorIndex == "" { + return errors.New("validator index missing") + } + validatorIndex, err := strconv.ParseUint(data.ValidatorIndex, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid validator index") + } + p.ValidatorIndex = phase0.ValidatorIndex(validatorIndex) + + if data.Data == nil { + return errors.New("data missing") + } + p.Data = data.Data + + if data.Signature == "" { + return errors.New("signature missing") + } + signature, err := hex.DecodeString(strings.TrimPrefix(data.Signature, "0x")) + if err != nil { + return errors.Wrap(err, "invalid signature") + } + if len(signature) != phase0.SignatureLength { + return errors.New("incorrect length for signature") + } + copy(p.Signature[:], signature) + + return nil +} diff --git a/spec/gloas/payloadattestationmessage_ssz.go b/spec/gloas/payloadattestationmessage_ssz.go new file mode 100644 index 00000000..80977b46 --- /dev/null +++ b/spec/gloas/payloadattestationmessage_ssz.go @@ -0,0 +1,117 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 3908ebeeeffed33153f7c3f68780c43415eba9226b0a17b9aa73ddbe3e0a9892 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[PayloadAttestationMessage](`ssz-static:"true"`) + +// MarshalSSZ marshals the *PayloadAttestationMessage to SSZ-encoded bytes. +func (t *PayloadAttestationMessage) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *PayloadAttestationMessage to SSZ-encoded bytes, appending to the provided buffer. +func (t *PayloadAttestationMessage) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(PayloadAttestationMessage) + } + { // Static Field #0 'ValidatorIndex' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.ValidatorIndex)) + } + { // Static Field #1 'Data' + t := t.Data + if t == nil { + t = new(PayloadAttestationData) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Data") + } + } + { // Static Field #2 'Signature' + dst = append(dst, t.Signature[:96]...) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *PayloadAttestationMessage from SSZ-encoded bytes. +func (t *PayloadAttestationMessage) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 146 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 146) + } + if buflen > 146 { + return sszutils.ErrTrailingDataFn(buflen - 146) + } + { // Field #0 'ValidatorIndex' (static) + buf := buf[0:8] + t.ValidatorIndex = phase0.ValidatorIndex(binary.LittleEndian.Uint64(buf)) + } + { // Field #1 'Data' (static) + buf := buf[8:50] + if t.Data == nil { + t.Data = new(PayloadAttestationData) + } + if err = t.Data.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Data") + } + } + { // Field #2 'Signature' (static) + buf := buf[50:146] + copy(t.Signature[:], buf) + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *PayloadAttestationMessage. +func (t *PayloadAttestationMessage) SizeSSZ() (size int) { + return 146 +} + +// HashTreeRoot computes the SSZ hash tree root of the *PayloadAttestationMessage. +func (t *PayloadAttestationMessage) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *PayloadAttestationMessage using the given hash walker. +func (t *PayloadAttestationMessage) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(PayloadAttestationMessage) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'ValidatorIndex' + hh.PutUint64(uint64(t.ValidatorIndex)) + } + { // Field #1 'Data' + t := t.Data + if t == nil { + t = new(PayloadAttestationData) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Data") + } + } + { // Field #2 'Signature' + hh.PutBytes(t.Signature[:96]) + } + hh.Merkleize(idx) + return nil +} diff --git a/spec/gloas/payloadattestationmessage_yaml.go b/spec/gloas/payloadattestationmessage_yaml.go new file mode 100644 index 00000000..88dfe178 --- /dev/null +++ b/spec/gloas/payloadattestationmessage_yaml.go @@ -0,0 +1,51 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (p *PayloadAttestationMessage) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&payloadAttestationMessageJSON{ + ValidatorIndex: fmt.Sprintf("%d", p.ValidatorIndex), + Data: p.Data, + Signature: fmt.Sprintf("%#x", p.Signature), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (p *PayloadAttestationMessage) UnmarshalYAML(input []byte) error { + var data payloadAttestationMessageJSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return p.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/proposerpreferences.go b/spec/gloas/proposerpreferences.go new file mode 100644 index 00000000..2a0ea0d9 --- /dev/null +++ b/spec/gloas/proposerpreferences.go @@ -0,0 +1,41 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// ProposerPreferences represents a proposer's preferences for execution payload construction. +type ProposerPreferences struct { + DependentRoot phase0.Root `ssz-size:"32"` + ProposalSlot phase0.Slot + ValidatorIndex phase0.ValidatorIndex + FeeRecipient bellatrix.ExecutionAddress `ssz-size:"20"` + TargetGasLimit uint64 +} + +// String returns a string version of the structure. +func (p *ProposerPreferences) String() string { + data, err := yaml.Marshal(p) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/proposerpreferences_json.go b/spec/gloas/proposerpreferences_json.go new file mode 100644 index 00000000..03e3e045 --- /dev/null +++ b/spec/gloas/proposerpreferences_json.go @@ -0,0 +1,112 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// proposerPreferencesJSON is the spec representation of the struct. +type proposerPreferencesJSON struct { + DependentRoot string `json:"dependent_root"` + ProposalSlot string `json:"proposal_slot"` + ValidatorIndex string `json:"validator_index"` + FeeRecipient string `json:"fee_recipient"` + TargetGasLimit string `json:"target_gas_limit"` +} + +// MarshalJSON implements json.Marshaler. +func (p *ProposerPreferences) MarshalJSON() ([]byte, error) { + return json.Marshal(&proposerPreferencesJSON{ + DependentRoot: fmt.Sprintf("%#x", p.DependentRoot), + ProposalSlot: fmt.Sprintf("%d", p.ProposalSlot), + ValidatorIndex: fmt.Sprintf("%d", p.ValidatorIndex), + FeeRecipient: fmt.Sprintf("%#x", p.FeeRecipient), + TargetGasLimit: fmt.Sprintf("%d", p.TargetGasLimit), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (p *ProposerPreferences) UnmarshalJSON(input []byte) error { + var data proposerPreferencesJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + // Dependent root. + if data.DependentRoot == "" { + return errors.New("dependent root missing") + } + dependentRoot, err := hex.DecodeString(strings.TrimPrefix(data.DependentRoot, "0x")) + if err != nil { + return errors.Wrap(err, "invalid dependent root") + } + if len(dependentRoot) != phase0.RootLength { + return errors.New("incorrect length for dependent root") + } + copy(p.DependentRoot[:], dependentRoot) + + // Proposal slot. + if data.ProposalSlot == "" { + return errors.New("proposal slot missing") + } + proposalSlot, err := strconv.ParseUint(data.ProposalSlot, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid proposal slot") + } + p.ProposalSlot = phase0.Slot(proposalSlot) + + // Validator index. + if data.ValidatorIndex == "" { + return errors.New("validator index missing") + } + validatorIndex, err := strconv.ParseUint(data.ValidatorIndex, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid validator index") + } + p.ValidatorIndex = phase0.ValidatorIndex(validatorIndex) + + // Fee recipient. + if data.FeeRecipient == "" { + return errors.New("fee recipient missing") + } + feeRecipient, err := hex.DecodeString(strings.TrimPrefix(data.FeeRecipient, "0x")) + if err != nil { + return errors.Wrap(err, "invalid fee recipient") + } + if len(feeRecipient) != bellatrix.FeeRecipientLength { + return errors.New("incorrect length for fee recipient") + } + copy(p.FeeRecipient[:], feeRecipient) + + // Target gas limit. + if data.TargetGasLimit == "" { + return errors.New("target gas limit missing") + } + targetGasLimit, err := strconv.ParseUint(data.TargetGasLimit, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid target gas limit") + } + p.TargetGasLimit = targetGasLimit + + return nil +} diff --git a/spec/gloas/proposerpreferences_ssz.go b/spec/gloas/proposerpreferences_ssz.go new file mode 100644 index 00000000..bb18f789 --- /dev/null +++ b/spec/gloas/proposerpreferences_ssz.go @@ -0,0 +1,120 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: b008b6f3176e10b2ec64ed7943073b8be0db0d370ff48dbf3a42ba68b9a889d9 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + "github.com/attestantio/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[ProposerPreferences](`ssz-static:"true"`) + +// MarshalSSZ marshals the *ProposerPreferences to SSZ-encoded bytes. +func (t *ProposerPreferences) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *ProposerPreferences to SSZ-encoded bytes, appending to the provided buffer. +func (t *ProposerPreferences) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(ProposerPreferences) + } + { // Static Field #0 'DependentRoot' + dst = append(dst, t.DependentRoot[:32]...) + } + { // Static Field #1 'ProposalSlot' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.ProposalSlot)) + } + { // Static Field #2 'ValidatorIndex' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.ValidatorIndex)) + } + { // Static Field #3 'FeeRecipient' + dst = append(dst, t.FeeRecipient[:20]...) + } + { // Static Field #4 'TargetGasLimit' + dst = binary.LittleEndian.AppendUint64(dst, t.TargetGasLimit) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *ProposerPreferences from SSZ-encoded bytes. +func (t *ProposerPreferences) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 76 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 76) + } + if buflen > 76 { + return sszutils.ErrTrailingDataFn(buflen - 76) + } + { // Field #0 'DependentRoot' (static) + buf := buf[0:32] + copy(t.DependentRoot[:], buf) + } + { // Field #1 'ProposalSlot' (static) + buf := buf[32:40] + t.ProposalSlot = phase0.Slot(binary.LittleEndian.Uint64(buf)) + } + { // Field #2 'ValidatorIndex' (static) + buf := buf[40:48] + t.ValidatorIndex = phase0.ValidatorIndex(binary.LittleEndian.Uint64(buf)) + } + { // Field #3 'FeeRecipient' (static) + buf := buf[48:68] + copy(t.FeeRecipient[:], buf) + } + { // Field #4 'TargetGasLimit' (static) + buf := buf[68:76] + t.TargetGasLimit = binary.LittleEndian.Uint64(buf) + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *ProposerPreferences. +func (t *ProposerPreferences) SizeSSZ() (size int) { + return 76 +} + +// HashTreeRoot computes the SSZ hash tree root of the *ProposerPreferences. +func (t *ProposerPreferences) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *ProposerPreferences using the given hash walker. +func (t *ProposerPreferences) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(ProposerPreferences) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'DependentRoot' + hh.PutBytes(t.DependentRoot[:32]) + } + { // Field #1 'ProposalSlot' + hh.PutUint64(uint64(t.ProposalSlot)) + } + { // Field #2 'ValidatorIndex' + hh.PutUint64(uint64(t.ValidatorIndex)) + } + { // Field #3 'FeeRecipient' + hh.PutBytes(t.FeeRecipient[:20]) + } + { // Field #4 'TargetGasLimit' + hh.PutUint64(t.TargetGasLimit) + } + hh.Merkleize(idx) + return nil +} diff --git a/spec/gloas/proposerpreferences_yaml.go b/spec/gloas/proposerpreferences_yaml.go new file mode 100644 index 00000000..775f588a --- /dev/null +++ b/spec/gloas/proposerpreferences_yaml.go @@ -0,0 +1,53 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (p *ProposerPreferences) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&proposerPreferencesJSON{ + DependentRoot: fmt.Sprintf("%#x", p.DependentRoot), + ProposalSlot: fmt.Sprintf("%d", p.ProposalSlot), + ValidatorIndex: fmt.Sprintf("%d", p.ValidatorIndex), + FeeRecipient: fmt.Sprintf("%#x", p.FeeRecipient), + TargetGasLimit: fmt.Sprintf("%d", p.TargetGasLimit), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (p *ProposerPreferences) UnmarshalYAML(input []byte) error { + var data proposerPreferencesJSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return p.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/signedaggregateandproof.go b/spec/gloas/signedaggregateandproof.go new file mode 100644 index 00000000..52256805 --- /dev/null +++ b/spec/gloas/signedaggregateandproof.go @@ -0,0 +1,120 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// SignedAggregateAndProof provides information about a signed aggregate and proof. +type SignedAggregateAndProof struct { + Message *AggregateAndProof + Signature phase0.BLSSignature `ssz-size:"96"` +} + +// signedAggregateAndProofJSON is a raw representation of the struct. +type signedAggregateAndProofJSON struct { + Message *AggregateAndProof `json:"message"` + Signature string `json:"signature"` +} + +// signedAggregateAndProofYAML is a raw representation of the struct. +type signedAggregateAndProofYAML struct { + Message *AggregateAndProof `yaml:"message"` + Signature string `yaml:"signature"` +} + +// MarshalJSON implements json.Marshaler. +func (s *SignedAggregateAndProof) MarshalJSON() ([]byte, error) { + return json.Marshal(&signedAggregateAndProofJSON{ + Message: s.Message, + Signature: fmt.Sprintf("%#x", s.Signature), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (s *SignedAggregateAndProof) UnmarshalJSON(input []byte) error { + var signedAggregateAndProofJSON signedAggregateAndProofJSON + if err := json.Unmarshal(input, &signedAggregateAndProofJSON); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + return s.unpack(&signedAggregateAndProofJSON) +} + +// MarshalYAML implements yaml.Marshaler. +func (s *SignedAggregateAndProof) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&signedAggregateAndProofYAML{ + Message: s.Message, + Signature: fmt.Sprintf("%#x", s.Signature), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (s *SignedAggregateAndProof) UnmarshalYAML(input []byte) error { + // We unmarshal to the JSON struct to save on duplicate code. + var signedAggregateAndProofJSON signedAggregateAndProofJSON + if err := yaml.Unmarshal(input, &signedAggregateAndProofJSON); err != nil { + return err + } + + return s.unpack(&signedAggregateAndProofJSON) +} + +// String returns a string version of the structure. +func (s *SignedAggregateAndProof) String() string { + data, err := yaml.Marshal(s) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} + +func (s *SignedAggregateAndProof) unpack(signedAggregateAndProofJSON *signedAggregateAndProofJSON) error { + if signedAggregateAndProofJSON.Message == nil { + return errors.New("message missing") + } + + s.Message = signedAggregateAndProofJSON.Message + if signedAggregateAndProofJSON.Signature == "" { + return errors.New("signature missing") + } + + signature, err := hex.DecodeString(strings.TrimPrefix(signedAggregateAndProofJSON.Signature, "0x")) + if err != nil { + return errors.Wrap(err, "invalid value for signature") + } + + if len(signature) != phase0.SignatureLength { + return errors.New("incorrect length for signature") + } + + copy(s.Signature[:], signature) + + return nil +} diff --git a/spec/gloas/signedaggregateandproof_ssz.go b/spec/gloas/signedaggregateandproof_ssz.go new file mode 100644 index 00000000..fb690652 --- /dev/null +++ b/spec/gloas/signedaggregateandproof_ssz.go @@ -0,0 +1,121 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 650130ca32152912881bfce4fe3cffa22cfb8d2970293fecedcd10be365c5c44 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[SignedAggregateAndProof](`ssz-static:"false"`) + +// MarshalSSZ marshals the *SignedAggregateAndProof to SSZ-encoded bytes. +func (t *SignedAggregateAndProof) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *SignedAggregateAndProof to SSZ-encoded bytes, appending to the provided buffer. +func (t *SignedAggregateAndProof) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(SignedAggregateAndProof) + } + dstlen := len(dst) + // Offset Field #0 'Message' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #1 'Signature' + dst = append(dst, t.Signature[:96]...) + } + { // Dynamic Field #0 'Message' + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(len(dst)-dstlen)) + t := t.Message + if t == nil { + t = new(AggregateAndProof) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Message") + } + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *SignedAggregateAndProof from SSZ-encoded bytes. +func (t *SignedAggregateAndProof) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 100 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 100) + } + // Field #0 'Message' (offset) + offset0 := int(binary.LittleEndian.Uint32(buf[0:4])) + if offset0 != 100 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 100), "Message:o") + } + { // Field #1 'Signature' (static) + buf := buf[4:100] + copy(t.Signature[:], buf) + } + { // Field #0 'Message' (dynamic) + buf := buf[offset0:] + if t.Message == nil { + t.Message = new(AggregateAndProof) + } + if err = t.Message.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Message") + } + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *SignedAggregateAndProof. +func (t *SignedAggregateAndProof) SizeSSZ() (size int) { + if t == nil { + t = new(SignedAggregateAndProof) + } + // Field #0 'Message' offset (4 bytes) + // Field #1 'Signature' static (96 bytes) + size += 100 + { // Dynamic field #0 'Message' + size += t.Message.SizeSSZ() + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *SignedAggregateAndProof. +func (t *SignedAggregateAndProof) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *SignedAggregateAndProof using the given hash walker. +func (t *SignedAggregateAndProof) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(SignedAggregateAndProof) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Message' + t := t.Message + if t == nil { + t = new(AggregateAndProof) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Message") + } + } + { // Field #1 'Signature' + hh.PutBytes(t.Signature[:96]) + } + hh.Merkleize(idx) + return nil +} diff --git a/spec/gloas/signedbeaconblock.go b/spec/gloas/signedbeaconblock.go new file mode 100644 index 00000000..0da2d78f --- /dev/null +++ b/spec/gloas/signedbeaconblock.go @@ -0,0 +1,37 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// SignedBeaconBlock is a signed beacon block. +type SignedBeaconBlock struct { + Message *BeaconBlock + Signature phase0.BLSSignature `ssz-size:"96"` +} + +// String returns a string version of the structure. +func (s *SignedBeaconBlock) String() string { + data, err := yaml.Marshal(s) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/signedbeaconblock_json.go b/spec/gloas/signedbeaconblock_json.go new file mode 100644 index 00000000..671ca0ed --- /dev/null +++ b/spec/gloas/signedbeaconblock_json.go @@ -0,0 +1,68 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +type signedBeaconBlockJSON struct { + Message *BeaconBlock `json:"message"` + Signature string `json:"signature"` +} + +// MarshalJSON implements json.Marshaler. +func (s *SignedBeaconBlock) MarshalJSON() ([]byte, error) { + if s == nil { + return nil, nil + } + + return json.Marshal(&signedBeaconBlockJSON{ + Message: s.Message, + Signature: fmt.Sprintf("%#x", s.Signature), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (s *SignedBeaconBlock) UnmarshalJSON(input []byte) error { + var data signedBeaconBlockJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if data.Message == nil { + return errors.New("message missing") + } + s.Message = data.Message + + if data.Signature == "" { + return errors.New("signature missing") + } + signature, err := hex.DecodeString(strings.TrimPrefix(data.Signature, "0x")) + if err != nil { + return errors.Wrap(err, "invalid signature") + } + if len(signature) != phase0.SignatureLength { + return errors.New("incorrect length for signature") + } + copy(s.Signature[:], signature) + + return nil +} diff --git a/spec/gloas/signedbeaconblock_ssz.go b/spec/gloas/signedbeaconblock_ssz.go new file mode 100644 index 00000000..d5640b1f --- /dev/null +++ b/spec/gloas/signedbeaconblock_ssz.go @@ -0,0 +1,121 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 3bbb944c1b2c8d58c376c6e6dcf5bfc04b945efe9a44443f3d606df9d7aa9cb5 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[SignedBeaconBlock](`ssz-static:"false"`) + +// MarshalSSZ marshals the *SignedBeaconBlock to SSZ-encoded bytes. +func (t *SignedBeaconBlock) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *SignedBeaconBlock to SSZ-encoded bytes, appending to the provided buffer. +func (t *SignedBeaconBlock) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(SignedBeaconBlock) + } + dstlen := len(dst) + // Offset Field #0 'Message' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #1 'Signature' + dst = append(dst, t.Signature[:96]...) + } + { // Dynamic Field #0 'Message' + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(len(dst)-dstlen)) + t := t.Message + if t == nil { + t = new(BeaconBlock) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Message") + } + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *SignedBeaconBlock from SSZ-encoded bytes. +func (t *SignedBeaconBlock) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 100 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 100) + } + // Field #0 'Message' (offset) + offset0 := int(binary.LittleEndian.Uint32(buf[0:4])) + if offset0 != 100 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 100), "Message:o") + } + { // Field #1 'Signature' (static) + buf := buf[4:100] + copy(t.Signature[:], buf) + } + { // Field #0 'Message' (dynamic) + buf := buf[offset0:] + if t.Message == nil { + t.Message = new(BeaconBlock) + } + if err = t.Message.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Message") + } + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *SignedBeaconBlock. +func (t *SignedBeaconBlock) SizeSSZ() (size int) { + if t == nil { + t = new(SignedBeaconBlock) + } + // Field #0 'Message' offset (4 bytes) + // Field #1 'Signature' static (96 bytes) + size += 100 + { // Dynamic field #0 'Message' + size += t.Message.SizeSSZ() + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *SignedBeaconBlock. +func (t *SignedBeaconBlock) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *SignedBeaconBlock using the given hash walker. +func (t *SignedBeaconBlock) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(SignedBeaconBlock) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Message' + t := t.Message + if t == nil { + t = new(BeaconBlock) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Message") + } + } + { // Field #1 'Signature' + hh.PutBytes(t.Signature[:96]) + } + hh.Merkleize(idx) + return nil +} diff --git a/spec/gloas/signedbeaconblock_yaml.go b/spec/gloas/signedbeaconblock_yaml.go new file mode 100644 index 00000000..555699ef --- /dev/null +++ b/spec/gloas/signedbeaconblock_yaml.go @@ -0,0 +1,55 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (s *SignedBeaconBlock) MarshalYAML() ([]byte, error) { + if s == nil { + return nil, nil + } + + data, err := json.Marshal(s) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal JSON") + } + + yamlBytes, err := yaml.JSONToYAML(data) + if err != nil { + return nil, errors.Wrap(err, "failed to convert JSON to YAML") + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (s *SignedBeaconBlock) UnmarshalYAML(input []byte) error { + var data signedBeaconBlockJSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return s.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/signedbuilderrequestauth_ssz.go b/spec/gloas/signedbuilderrequestauth_ssz.go new file mode 100644 index 00000000..f8990072 --- /dev/null +++ b/spec/gloas/signedbuilderrequestauth_ssz.go @@ -0,0 +1,121 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 827ec7325f5b3571c5e03bed09ba9d91f2b2ce28481aa08198b7751cb4d03af4 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[SignedBuilderRequestAuth](`ssz-static:"false"`) + +// MarshalSSZ marshals the *SignedBuilderRequestAuth to SSZ-encoded bytes. +func (t *SignedBuilderRequestAuth) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *SignedBuilderRequestAuth to SSZ-encoded bytes, appending to the provided buffer. +func (t *SignedBuilderRequestAuth) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(SignedBuilderRequestAuth) + } + dstlen := len(dst) + // Offset Field #0 'Message' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #1 'Signature' + dst = append(dst, t.Signature[:96]...) + } + { // Dynamic Field #0 'Message' + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(len(dst)-dstlen)) + t := t.Message + if t == nil { + t = new(BuilderRequestAuth) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Message") + } + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *SignedBuilderRequestAuth from SSZ-encoded bytes. +func (t *SignedBuilderRequestAuth) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 100 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 100) + } + // Field #0 'Message' (offset) + offset0 := int(binary.LittleEndian.Uint32(buf[0:4])) + if offset0 != 100 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 100), "Message:o") + } + { // Field #1 'Signature' (static) + buf := buf[4:100] + copy(t.Signature[:], buf) + } + { // Field #0 'Message' (dynamic) + buf := buf[offset0:] + if t.Message == nil { + t.Message = new(BuilderRequestAuth) + } + if err = t.Message.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Message") + } + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *SignedBuilderRequestAuth. +func (t *SignedBuilderRequestAuth) SizeSSZ() (size int) { + if t == nil { + t = new(SignedBuilderRequestAuth) + } + // Field #0 'Message' offset (4 bytes) + // Field #1 'Signature' static (96 bytes) + size += 100 + { // Dynamic field #0 'Message' + size += t.Message.SizeSSZ() + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *SignedBuilderRequestAuth. +func (t *SignedBuilderRequestAuth) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *SignedBuilderRequestAuth using the given hash walker. +func (t *SignedBuilderRequestAuth) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(SignedBuilderRequestAuth) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Message' + t := t.Message + if t == nil { + t = new(BuilderRequestAuth) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Message") + } + } + { // Field #1 'Signature' + hh.PutBytes(t.Signature[:96]) + } + hh.MerkleizeProgressiveWithActiveFields(idx, []byte{0x03}) + return nil +} diff --git a/spec/gloas/signedexecutionpayloadbid.go b/spec/gloas/signedexecutionpayloadbid.go new file mode 100644 index 00000000..a047b8ed --- /dev/null +++ b/spec/gloas/signedexecutionpayloadbid.go @@ -0,0 +1,37 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// SignedExecutionPayloadBid represents a signed execution payload bid. +type SignedExecutionPayloadBid struct { + Message *ExecutionPayloadBid + Signature phase0.BLSSignature `ssz-size:"96"` +} + +// String returns a string version of the structure. +func (s *SignedExecutionPayloadBid) String() string { + data, err := yaml.Marshal(s) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/signedexecutionpayloadbid_json.go b/spec/gloas/signedexecutionpayloadbid_json.go new file mode 100644 index 00000000..60ac1804 --- /dev/null +++ b/spec/gloas/signedexecutionpayloadbid_json.go @@ -0,0 +1,65 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// signedExecutionPayloadBidJSON is the spec representation of the struct. +type signedExecutionPayloadBidJSON struct { + Message *ExecutionPayloadBid `json:"message"` + Signature string `json:"signature"` +} + +// MarshalJSON implements json.Marshaler. +func (s *SignedExecutionPayloadBid) MarshalJSON() ([]byte, error) { + return json.Marshal(&signedExecutionPayloadBidJSON{ + Message: s.Message, + Signature: fmt.Sprintf("%#x", s.Signature), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (s *SignedExecutionPayloadBid) UnmarshalJSON(input []byte) error { + var data signedExecutionPayloadBidJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if data.Message == nil { + return errors.New("message missing") + } + s.Message = data.Message + + if data.Signature == "" { + return errors.New("signature missing") + } + signature, err := hex.DecodeString(strings.TrimPrefix(data.Signature, "0x")) + if err != nil { + return errors.Wrap(err, "invalid signature") + } + if len(signature) != phase0.SignatureLength { + return errors.New("incorrect length for signature") + } + copy(s.Signature[:], signature) + + return nil +} diff --git a/spec/gloas/signedexecutionpayloadbid_ssz.go b/spec/gloas/signedexecutionpayloadbid_ssz.go new file mode 100644 index 00000000..cc995534 --- /dev/null +++ b/spec/gloas/signedexecutionpayloadbid_ssz.go @@ -0,0 +1,121 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 9f0e256740a86de8e5f70563414d7a63dd090cf46ea2b0b94f5df4b7d197e530 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[SignedExecutionPayloadBid](`ssz-static:"false"`) + +// MarshalSSZ marshals the *SignedExecutionPayloadBid to SSZ-encoded bytes. +func (t *SignedExecutionPayloadBid) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *SignedExecutionPayloadBid to SSZ-encoded bytes, appending to the provided buffer. +func (t *SignedExecutionPayloadBid) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(SignedExecutionPayloadBid) + } + dstlen := len(dst) + // Offset Field #0 'Message' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #1 'Signature' + dst = append(dst, t.Signature[:96]...) + } + { // Dynamic Field #0 'Message' + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(len(dst)-dstlen)) + t := t.Message + if t == nil { + t = new(ExecutionPayloadBid) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Message") + } + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *SignedExecutionPayloadBid from SSZ-encoded bytes. +func (t *SignedExecutionPayloadBid) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 100 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 100) + } + // Field #0 'Message' (offset) + offset0 := int(binary.LittleEndian.Uint32(buf[0:4])) + if offset0 != 100 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 100), "Message:o") + } + { // Field #1 'Signature' (static) + buf := buf[4:100] + copy(t.Signature[:], buf) + } + { // Field #0 'Message' (dynamic) + buf := buf[offset0:] + if t.Message == nil { + t.Message = new(ExecutionPayloadBid) + } + if err = t.Message.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Message") + } + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *SignedExecutionPayloadBid. +func (t *SignedExecutionPayloadBid) SizeSSZ() (size int) { + if t == nil { + t = new(SignedExecutionPayloadBid) + } + // Field #0 'Message' offset (4 bytes) + // Field #1 'Signature' static (96 bytes) + size += 100 + { // Dynamic field #0 'Message' + size += t.Message.SizeSSZ() + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *SignedExecutionPayloadBid. +func (t *SignedExecutionPayloadBid) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *SignedExecutionPayloadBid using the given hash walker. +func (t *SignedExecutionPayloadBid) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(SignedExecutionPayloadBid) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Message' + t := t.Message + if t == nil { + t = new(ExecutionPayloadBid) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Message") + } + } + { // Field #1 'Signature' + hh.PutBytes(t.Signature[:96]) + } + hh.Merkleize(idx) + return nil +} diff --git a/spec/gloas/signedexecutionpayloadbid_yaml.go b/spec/gloas/signedexecutionpayloadbid_yaml.go new file mode 100644 index 00000000..e0d5a7bb --- /dev/null +++ b/spec/gloas/signedexecutionpayloadbid_yaml.go @@ -0,0 +1,50 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (s *SignedExecutionPayloadBid) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&signedExecutionPayloadBidJSON{ + Message: s.Message, + Signature: fmt.Sprintf("%#x", s.Signature), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (s *SignedExecutionPayloadBid) UnmarshalYAML(input []byte) error { + var data signedExecutionPayloadBidJSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return s.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/signedexecutionpayloadenvelope.go b/spec/gloas/signedexecutionpayloadenvelope.go new file mode 100644 index 00000000..70eb7ebd --- /dev/null +++ b/spec/gloas/signedexecutionpayloadenvelope.go @@ -0,0 +1,37 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// SignedExecutionPayloadEnvelope represents a signed execution payload envelope. +type SignedExecutionPayloadEnvelope struct { + Message *ExecutionPayloadEnvelope + Signature phase0.BLSSignature `ssz-size:"96"` +} + +// String returns a string version of the structure. +func (s *SignedExecutionPayloadEnvelope) String() string { + data, err := yaml.Marshal(s) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/signedexecutionpayloadenvelope_json.go b/spec/gloas/signedexecutionpayloadenvelope_json.go new file mode 100644 index 00000000..73af3365 --- /dev/null +++ b/spec/gloas/signedexecutionpayloadenvelope_json.go @@ -0,0 +1,65 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// signedExecutionPayloadEnvelopeJSON is the spec representation of the struct. +type signedExecutionPayloadEnvelopeJSON struct { + Message *ExecutionPayloadEnvelope `json:"message"` + Signature string `json:"signature"` +} + +// MarshalJSON implements json.Marshaler. +func (s *SignedExecutionPayloadEnvelope) MarshalJSON() ([]byte, error) { + return json.Marshal(&signedExecutionPayloadEnvelopeJSON{ + Message: s.Message, + Signature: fmt.Sprintf("%#x", s.Signature), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (s *SignedExecutionPayloadEnvelope) UnmarshalJSON(input []byte) error { + var data signedExecutionPayloadEnvelopeJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if data.Message == nil { + return errors.New("message missing") + } + s.Message = data.Message + + if data.Signature == "" { + return errors.New("signature missing") + } + signature, err := hex.DecodeString(strings.TrimPrefix(data.Signature, "0x")) + if err != nil { + return errors.Wrap(err, "invalid signature") + } + if len(signature) != phase0.SignatureLength { + return errors.New("incorrect length for signature") + } + copy(s.Signature[:], signature) + + return nil +} diff --git a/spec/gloas/signedexecutionpayloadenvelope_ssz.go b/spec/gloas/signedexecutionpayloadenvelope_ssz.go new file mode 100644 index 00000000..3959a0ba --- /dev/null +++ b/spec/gloas/signedexecutionpayloadenvelope_ssz.go @@ -0,0 +1,121 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 0e8052523366a012e897e4234d9226f6d5981e66a1961f9c4822cb103dc154f7 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + "encoding/binary" + + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[SignedExecutionPayloadEnvelope](`ssz-static:"false"`) + +// MarshalSSZ marshals the *SignedExecutionPayloadEnvelope to SSZ-encoded bytes. +func (t *SignedExecutionPayloadEnvelope) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *SignedExecutionPayloadEnvelope to SSZ-encoded bytes, appending to the provided buffer. +func (t *SignedExecutionPayloadEnvelope) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(SignedExecutionPayloadEnvelope) + } + dstlen := len(dst) + // Offset Field #0 'Message' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #1 'Signature' + dst = append(dst, t.Signature[:96]...) + } + { // Dynamic Field #0 'Message' + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(len(dst)-dstlen)) + t := t.Message + if t == nil { + t = new(ExecutionPayloadEnvelope) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Message") + } + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *SignedExecutionPayloadEnvelope from SSZ-encoded bytes. +func (t *SignedExecutionPayloadEnvelope) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 100 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 100) + } + // Field #0 'Message' (offset) + offset0 := int(binary.LittleEndian.Uint32(buf[0:4])) + if offset0 != 100 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 100), "Message:o") + } + { // Field #1 'Signature' (static) + buf := buf[4:100] + copy(t.Signature[:], buf) + } + { // Field #0 'Message' (dynamic) + buf := buf[offset0:] + if t.Message == nil { + t.Message = new(ExecutionPayloadEnvelope) + } + if err = t.Message.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Message") + } + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *SignedExecutionPayloadEnvelope. +func (t *SignedExecutionPayloadEnvelope) SizeSSZ() (size int) { + if t == nil { + t = new(SignedExecutionPayloadEnvelope) + } + // Field #0 'Message' offset (4 bytes) + // Field #1 'Signature' static (96 bytes) + size += 100 + { // Dynamic field #0 'Message' + size += t.Message.SizeSSZ() + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *SignedExecutionPayloadEnvelope. +func (t *SignedExecutionPayloadEnvelope) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *SignedExecutionPayloadEnvelope using the given hash walker. +func (t *SignedExecutionPayloadEnvelope) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(SignedExecutionPayloadEnvelope) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Message' + t := t.Message + if t == nil { + t = new(ExecutionPayloadEnvelope) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Message") + } + } + { // Field #1 'Signature' + hh.PutBytes(t.Signature[:96]) + } + hh.Merkleize(idx) + return nil +} diff --git a/spec/gloas/signedexecutionpayloadenvelope_yaml.go b/spec/gloas/signedexecutionpayloadenvelope_yaml.go new file mode 100644 index 00000000..c843bb08 --- /dev/null +++ b/spec/gloas/signedexecutionpayloadenvelope_yaml.go @@ -0,0 +1,50 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (s *SignedExecutionPayloadEnvelope) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&signedExecutionPayloadEnvelopeJSON{ + Message: s.Message, + Signature: fmt.Sprintf("%#x", s.Signature), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (s *SignedExecutionPayloadEnvelope) UnmarshalYAML(input []byte) error { + var data signedExecutionPayloadEnvelopeJSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return s.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/signedproposerpreferences.go b/spec/gloas/signedproposerpreferences.go new file mode 100644 index 00000000..a89646d4 --- /dev/null +++ b/spec/gloas/signedproposerpreferences.go @@ -0,0 +1,37 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "fmt" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// SignedProposerPreferences represents a signed proposer preferences message. +type SignedProposerPreferences struct { + Message *ProposerPreferences + Signature phase0.BLSSignature `ssz-size:"96"` +} + +// String returns a string version of the structure. +func (s *SignedProposerPreferences) String() string { + data, err := yaml.Marshal(s) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/spec/gloas/signedproposerpreferences_json.go b/spec/gloas/signedproposerpreferences_json.go new file mode 100644 index 00000000..adbf8223 --- /dev/null +++ b/spec/gloas/signedproposerpreferences_json.go @@ -0,0 +1,65 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// signedProposerPreferencesJSON is the spec representation of the struct. +type signedProposerPreferencesJSON struct { + Message *ProposerPreferences `json:"message"` + Signature string `json:"signature"` +} + +// MarshalJSON implements json.Marshaler. +func (s *SignedProposerPreferences) MarshalJSON() ([]byte, error) { + return json.Marshal(&signedProposerPreferencesJSON{ + Message: s.Message, + Signature: fmt.Sprintf("%#x", s.Signature), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (s *SignedProposerPreferences) UnmarshalJSON(input []byte) error { + var data signedProposerPreferencesJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if data.Message == nil { + return errors.New("message missing") + } + s.Message = data.Message + + if data.Signature == "" { + return errors.New("signature missing") + } + signature, err := hex.DecodeString(strings.TrimPrefix(data.Signature, "0x")) + if err != nil { + return errors.Wrap(err, "invalid signature") + } + if len(signature) != phase0.SignatureLength { + return errors.New("incorrect length for signature") + } + copy(s.Signature[:], signature) + + return nil +} diff --git a/spec/gloas/signedproposerpreferences_ssz.go b/spec/gloas/signedproposerpreferences_ssz.go new file mode 100644 index 00000000..36444558 --- /dev/null +++ b/spec/gloas/signedproposerpreferences_ssz.go @@ -0,0 +1,104 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: ab6225206ea43f8e7c2b2020c769383723d0205c6b8babc792d4f71096b7f2b3 +// Version: v1.3.2 (https://github.com/pk910/dynamic-ssz) +package gloas + +import ( + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +var _ = sszutils.Annotate[SignedProposerPreferences](`ssz-static:"true"`) + +// MarshalSSZ marshals the *SignedProposerPreferences to SSZ-encoded bytes. +func (t *SignedProposerPreferences) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *SignedProposerPreferences to SSZ-encoded bytes, appending to the provided buffer. +func (t *SignedProposerPreferences) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(SignedProposerPreferences) + } + { // Static Field #0 'Message' + t := t.Message + if t == nil { + t = new(ProposerPreferences) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Message") + } + } + { // Static Field #1 'Signature' + dst = append(dst, t.Signature[:96]...) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *SignedProposerPreferences from SSZ-encoded bytes. +func (t *SignedProposerPreferences) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 172 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 172) + } + if buflen > 172 { + return sszutils.ErrTrailingDataFn(buflen - 172) + } + { // Field #0 'Message' (static) + buf := buf[0:76] + if t.Message == nil { + t.Message = new(ProposerPreferences) + } + if err = t.Message.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Message") + } + } + { // Field #1 'Signature' (static) + buf := buf[76:172] + copy(t.Signature[:], buf) + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *SignedProposerPreferences. +func (t *SignedProposerPreferences) SizeSSZ() (size int) { + return 172 +} + +// HashTreeRoot computes the SSZ hash tree root of the *SignedProposerPreferences. +func (t *SignedProposerPreferences) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *SignedProposerPreferences using the given hash walker. +func (t *SignedProposerPreferences) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(SignedProposerPreferences) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Message' + t := t.Message + if t == nil { + t = new(ProposerPreferences) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Message") + } + } + { // Field #1 'Signature' + hh.PutBytes(t.Signature[:96]) + } + hh.Merkleize(idx) + return nil +} diff --git a/spec/gloas/signedproposerpreferences_yaml.go b/spec/gloas/signedproposerpreferences_yaml.go new file mode 100644 index 00000000..dbd1848a --- /dev/null +++ b/spec/gloas/signedproposerpreferences_yaml.go @@ -0,0 +1,50 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (s *SignedProposerPreferences) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&signedProposerPreferencesJSON{ + Message: s.Message, + Signature: fmt.Sprintf("%#x", s.Signature), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (s *SignedProposerPreferences) UnmarshalYAML(input []byte) error { + var data signedProposerPreferencesJSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return s.UnmarshalJSON(marshaled) +} diff --git a/spec/gloas/sszannotations_test.go b/spec/gloas/sszannotations_test.go new file mode 100644 index 00000000..5ccf72b1 --- /dev/null +++ b/spec/gloas/sszannotations_test.go @@ -0,0 +1,120 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas_test + +import ( + "reflect" + "testing" + + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/stretchr/testify/require" +) + +// TestGloasSSZFieldAnnotations pins SSZ annotations whose values are not +// otherwise apparent from the surrounding field declaration. +func TestGloasSSZFieldAnnotations(t *testing.T) { + tests := []struct { + name string + typ reflect.Type + field string + tag string + expected string + }{ + { + name: "HistoricalRootsDynamicLimit", + typ: reflect.TypeFor[gloas.BeaconState](), + field: "HistoricalRoots", + tag: "dynssz-max", + expected: "HISTORICAL_ROOTS_LIMIT", + }, + { + name: "HistoricalSummariesDynamicLimit", + typ: reflect.TypeFor[gloas.BeaconState](), + field: "HistoricalSummaries", + tag: "dynssz-max", + expected: "HISTORICAL_ROOTS_LIMIT", + }, + { + name: "RANDAORevealSize", + typ: reflect.TypeFor[gloas.BeaconBlockBody](), + field: "RANDAOReveal", + tag: "ssz-size", + expected: "96", + }, + { + name: "GraffitiSize", + typ: reflect.TypeFor[gloas.BeaconBlockBody](), + field: "Graffiti", + tag: "ssz-size", + expected: "32", + }, + { + name: "ParentBlockHashSize", + typ: reflect.TypeFor[gloas.ExecutionPayloadBid](), + field: "ParentBlockHash", + tag: "ssz-size", + expected: "32", + }, + { + name: "ParentBlockRootSize", + typ: reflect.TypeFor[gloas.ExecutionPayloadBid](), + field: "ParentBlockRoot", + tag: "ssz-size", + expected: "32", + }, + { + name: "BlockHashSize", + typ: reflect.TypeFor[gloas.ExecutionPayloadBid](), + field: "BlockHash", + tag: "ssz-size", + expected: "32", + }, + { + name: "PrevRandaoSize", + typ: reflect.TypeFor[gloas.ExecutionPayloadBid](), + field: "PrevRandao", + tag: "ssz-size", + expected: "32", + }, + { + name: "FeeRecipientSize", + typ: reflect.TypeFor[gloas.ExecutionPayloadBid](), + field: "FeeRecipient", + tag: "ssz-size", + expected: "20", + }, + { + name: "BeaconBlockRootSize", + typ: reflect.TypeFor[gloas.ExecutionPayloadEnvelope](), + field: "BeaconBlockRoot", + tag: "ssz-size", + expected: "32", + }, + { + name: "ParentBeaconBlockRootSize", + typ: reflect.TypeFor[gloas.ExecutionPayloadEnvelope](), + field: "ParentBeaconBlockRoot", + tag: "ssz-size", + expected: "32", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + field, found := test.typ.FieldByName(test.field) + require.True(t, found) + require.Equal(t, test.expected, field.Tag.Get(test.tag)) + }) + } +} diff --git a/spec/gloas/sszroot_test.go b/spec/gloas/sszroot_test.go new file mode 100644 index 00000000..d8289e44 --- /dev/null +++ b/spec/gloas/sszroot_test.go @@ -0,0 +1,276 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas_test + +import ( + "encoding/hex" + "testing" + + "github.com/OffchainLabs/go-bitfield" + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/holiman/uint256" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/stretchr/testify/require" +) + +// The expected roots in this file were independently calculated with the +// Ethereum consensus-specs v1.7.0-alpha.12 PySpec at commit +// 84454a9d57f4f49f74c78e9f375347baf30474f2. That implementation uses +// eth-remerkleable rather than this repository's generated dynamic-ssz codec. +// This independent coverage complements, but does not replace, official +// v1.7.0-alpha.12 consensus-spec vector validation. +// +// Each fixture below mirrors the named PySpec container's non-zero fields. The +// literals deliberately make a generated-code regression fail even if the same +// regression affects both this package's MarshalSSZ and HashTreeRoot methods. +type sszRootable interface { + MarshalSSZ() ([]byte, error) + UnmarshalSSZ([]byte) error + HashTreeRoot() ([32]byte, error) +} + +func TestInheritedContainerSSZRoots(t *testing.T) { + tests := []struct { + name string + value sszRootable + newValue func() sszRootable + root string + }{ + { + name: "Attestation", + value: testAttestation(), + newValue: func() sszRootable { return &gloas.Attestation{} }, + root: "7f14183aef36f97fd2e90104f3afb1e8eeef426d9c2613fae882131db3bb820b", + }, + { + name: "IndexedAttestation", + value: testIndexedAttestation(1), + newValue: func() sszRootable { return &gloas.IndexedAttestation{} }, + root: "683e085a809f52ba767d8e50033c25684a71708fe063224ad8ef293ecc8b55b6", + }, + { + name: "ExecutionPayload", + value: testExecutionPayload(), + newValue: func() sszRootable { return &gloas.ExecutionPayload{} }, + root: "f1a113a895e90bce01951250d9858cf9d71ace6c4355f92dd409be321975d8bc", + }, + { + name: "ExecutionRequests", + value: testExecutionRequests(), + newValue: func() sszRootable { return &gloas.ExecutionRequests{} }, + root: "b43a532aa4dbf7e7b6b5cfa4b6cc1c54941be8e5f4eda56d965a534a8daec3b4", + }, + { + name: "BeaconBlockBody", + value: testBeaconBlockBody(), + newValue: func() sszRootable { return &gloas.BeaconBlockBody{} }, + root: "85ce69a9ad9f3abfca93126bd85b98a005e0405112b70f0bfb3e245affcf3598", + }, + { + name: "BeaconState", + value: testBeaconState(), + newValue: func() sszRootable { return &gloas.BeaconState{} }, + root: "4e2b7050ea9041ac17e2308892b23f24a895302f73ae662c12641fdfb3b38431", + }, + { + name: "SignedAggregateAndProof", + value: testSignedAggregateAndProof(), + newValue: func() sszRootable { return &gloas.SignedAggregateAndProof{} }, + root: "71473a74b9b3273a783edc7ebdeb412ccdbfc034dcd860dd7f77b4d506996f38", + }, + { + name: "AttesterSlashing", + value: testAttesterSlashing(), + newValue: func() sszRootable { return &gloas.AttesterSlashing{} }, + root: "cbb503af73492f1ab7dbbd53ae3aa83f781e99a12015eb8b072871be5630c60c", + }, + { + name: "SignedBeaconBlock", + value: testSignedBeaconBlock(), + newValue: func() sszRootable { return &gloas.SignedBeaconBlock{} }, + root: "99fff2530b1b3e26ab697f29dde7ae9f8ee0bbfdd07255861be1b33f5864a79c", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + encoded, err := test.value.MarshalSSZ() + require.NoError(t, err) + + decoded := test.newValue() + require.NoError(t, decoded.UnmarshalSSZ(encoded)) + + roundTripped, err := decoded.MarshalSSZ() + require.NoError(t, err) + require.Equal(t, encoded, roundTripped) + + root, err := decoded.HashTreeRoot() + require.NoError(t, err) + require.Equal(t, rootFromHex(t, test.root), root) + }) + } +} + +// TestAttestationSSZCustomPreset exercises the dynamic path used by callers +// with custom spec support. The minimal preset has four committees per slot, +// so its committee bitvector is one byte rather than mainnet's eight. +func TestAttestationSSZCustomPreset(t *testing.T) { + attestation := &gloas.Attestation{ + AggregationBits: bitfield.Bitlist{0x19}, + CommitteeBits: bitfield.Bitvector64{0}, + } + dynamicSSZ := dynssz.NewDynSsz(map[string]any{ + "MAX_COMMITTEES_PER_SLOT": uint64(4), + }) + + encoded, err := dynamicSSZ.MarshalSSZ(attestation) + require.NoError(t, err) + // Independently encoded by the v1.7.0-alpha.12 minimal PySpec. + require.Len(t, encoded, 230) + + var decoded gloas.Attestation + require.NoError(t, dynamicSSZ.UnmarshalSSZ(&decoded, encoded)) + require.Equal(t, []byte{0x19}, []byte(decoded.AggregationBits)) + require.Equal(t, []byte{0}, []byte(decoded.CommitteeBits)) + + root, err := dynamicSSZ.HashTreeRoot(&decoded) + require.NoError(t, err) + require.Equal(t, rootFromHex(t, "2c107e8672069142b8bfa7924cdb90be0fd9f7ebb61c6157f8f40fc608df5bf5"), root) +} + +// TestExecutionPayloadEnvelopeHashTreeRootMinimalPreset verifies generated +// hashing against dynamic SSZ with the canonical minimal preset value relevant +// to this envelope. MAX_EXTRA_DATA_BYTES is 32 for both mainnet and minimal; +// its payload and execution request lists are progressive, and +// BlobKZGCommitments is not an envelope field. +func TestExecutionPayloadEnvelopeHashTreeRootMinimalPreset(t *testing.T) { + envelope := &gloas.ExecutionPayloadEnvelope{ + Payload: testExecutionPayload(), + ExecutionRequests: testExecutionRequests(), + BuilderIndex: 3, + } + envelope.Payload.ExtraData = []byte{1} + + generatedRoot, err := envelope.HashTreeRoot() + require.NoError(t, err) + + dynamicRoot, err := dynssz.NewDynSsz(map[string]any{ + "MAX_EXTRA_DATA_BYTES": uint64(32), + }).HashTreeRoot(envelope) + require.NoError(t, err) + require.Equal(t, generatedRoot, dynamicRoot) +} + +func rootFromHex(t *testing.T, input string) [32]byte { + t.Helper() + + decoded, err := hex.DecodeString(input) + require.NoError(t, err) + require.Len(t, decoded, 32) + + var root [32]byte + copy(root[:], decoded) + + return root +} + +func testAttestation() *gloas.Attestation { + aggregationBits := bitfield.NewBitlist(8) + aggregationBits.SetBitAt(0, true) + aggregationBits.SetBitAt(3, true) + + return &gloas.Attestation{AggregationBits: aggregationBits} +} + +func testIndexedAttestation(index uint64) *gloas.IndexedAttestation { + return &gloas.IndexedAttestation{AttestingIndices: []uint64{index}} +} + +func testExecutionPayload() *gloas.ExecutionPayload { + return &gloas.ExecutionPayload{ + BlockNumber: 1, + BaseFeePerGas: uint256.NewInt(1), + SlotNumber: 2, + } +} + +func testExecutionRequests() *gloas.ExecutionRequests { + return &gloas.ExecutionRequests{ + BuilderExits: []*gloas.BuilderExitRequest{{ + SourceAddress: executionAddress(1), + Pubkey: publicKey(2), + }}, + } +} + +func testBeaconBlockBody() *gloas.BeaconBlockBody { + return &gloas.BeaconBlockBody{ + AttesterSlashings: []*gloas.AttesterSlashing{testAttesterSlashing()}, + Attestations: []*gloas.Attestation{testAttestation()}, + } +} + +func testBeaconState() *gloas.BeaconState { + return &gloas.BeaconState{ + GenesisTime: 1, + Builders: []*gloas.Builder{{ + PublicKey: publicKey(3), + Version: 1, + }}, + NextWithdrawalBuilderIndex: 2, + } +} + +func testSignedAggregateAndProof() *gloas.SignedAggregateAndProof { + return &gloas.SignedAggregateAndProof{ + Message: &gloas.AggregateAndProof{ + AggregatorIndex: 7, + Aggregate: testAttestation(), + }, + } +} + +func testAttesterSlashing() *gloas.AttesterSlashing { + return &gloas.AttesterSlashing{ + Attestation1: testIndexedAttestation(1), + Attestation2: testIndexedAttestation(2), + } +} + +func testSignedBeaconBlock() *gloas.SignedBeaconBlock { + return &gloas.SignedBeaconBlock{ + Message: &gloas.BeaconBlock{ + Slot: 3, + Body: testBeaconBlockBody(), + }, + } +} + +func executionAddress(value byte) (address bellatrix.ExecutionAddress) { + for i := range address { + address[i] = value + } + + return address +} + +func publicKey(value byte) (pubkey phase0.BLSPubKey) { + for i := range pubkey { + pubkey[i] = value + } + + return pubkey +} diff --git a/spec/version_compatibility_test.go b/spec/version_compatibility_test.go new file mode 100644 index 00000000..446381ef --- /dev/null +++ b/spec/version_compatibility_test.go @@ -0,0 +1,44 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spec_test + +import ( + "reflect" + "testing" + + "github.com/attestantio/go-eth2-client/spec" + "github.com/stretchr/testify/require" +) + +func TestVersionTypePackagePath(t *testing.T) { + tests := []struct { + name string + type_ any + }{ + { + name: "BuilderVersion", + type_: spec.BuilderVersion(0), + }, + { + name: "DataVersion", + type_: spec.DataVersion(0), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, "github.com/attestantio/go-eth2-client/spec", reflect.TypeOf(test.type_).PkgPath()) + }) + } +} diff --git a/spec/versionedaggregateandproof.go b/spec/versionedaggregateandproof.go index 9e6449bb..93b43e4a 100644 --- a/spec/versionedaggregateandproof.go +++ b/spec/versionedaggregateandproof.go @@ -17,6 +17,7 @@ import ( "errors" "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" ) @@ -30,6 +31,10 @@ type VersionedAggregateAndProof struct { Deneb *phase0.AggregateAndProof Electra *electra.AggregateAndProof Fulu *electra.AggregateAndProof + // Gloas has its own container because gloas.Attestation merkleizes its aggregation bits as a + // progressive bitlist, giving a different hash tree root (and so a different signing root) + // from the electra container. + Gloas *gloas.AggregateAndProof } // AggregatorIndex returns the aggregator index of the aggregate. @@ -77,6 +82,12 @@ func (v *VersionedAggregateAndProof) AggregatorIndex() (phase0.ValidatorIndex, e } return v.Fulu.AggregatorIndex, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no gloas aggregate and proof") + } + + return v.Gloas.AggregatorIndex, nil default: return 0, errors.New("unknown version for aggregate and proof") } @@ -127,6 +138,12 @@ func (v *VersionedAggregateAndProof) HashTreeRoot() ([32]byte, error) { } return v.Fulu.HashTreeRoot() + case DataVersionGloas: + if v.Gloas == nil { + return [32]byte{}, errors.New("no gloas aggregate and proof") + } + + return v.Gloas.HashTreeRoot() default: return [32]byte{}, errors.New("unknown version") } @@ -135,7 +152,7 @@ func (v *VersionedAggregateAndProof) HashTreeRoot() ([32]byte, error) { // IsEmpty returns true if there is no aggregate and proof. func (v *VersionedAggregateAndProof) IsEmpty() bool { return v.Phase0 == nil && v.Altair == nil && v.Bellatrix == nil && - v.Capella == nil && v.Deneb == nil && v.Electra == nil && v.Fulu == nil + v.Capella == nil && v.Deneb == nil && v.Electra == nil && v.Fulu == nil && v.Gloas == nil } // String returns a string version of the structure. @@ -183,6 +200,12 @@ func (v *VersionedAggregateAndProof) String() string { } return v.Fulu.String() + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() default: return "unknown version" } @@ -233,6 +256,12 @@ func (v *VersionedAggregateAndProof) SelectionProof() (phase0.BLSSignature, erro } return v.Fulu.SelectionProof, nil + case DataVersionGloas: + if v.Gloas == nil { + return phase0.BLSSignature{}, errors.New("no gloas aggregate and proof") + } + + return v.Gloas.SelectionProof, nil default: return phase0.BLSSignature{}, errors.New("unknown version") } diff --git a/spec/versionedaggregateandproof_test.go b/spec/versionedaggregateandproof_test.go new file mode 100644 index 00000000..d8aadc23 --- /dev/null +++ b/spec/versionedaggregateandproof_test.go @@ -0,0 +1,165 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spec_test + +import ( + "fmt" + "testing" + + "github.com/OffchainLabs/go-bitfield" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// TestVersionedAggregateAndProofHashTreeRootGloas asserts that the Gloas arm's hash tree root is +// computed via the progressive-bitlist merkleization, by showing it differs from the root the +// electra container produces for the same logical aggregate. +// +// Under Gloas (consensus-spec v1.7.0-alpha.12, specs/gloas/beacon-chain.md) Attestation is a +// ProgressiveContainer whose aggregation_bits are a ProgressiveBitlist; electra's are a regular +// Bitlist, so the two merkleize to different hash tree roots. +func TestVersionedAggregateAndProofHashTreeRootGloas(t *testing.T) { + electraAggregate, gloasAggregate := equivalentAggregates() + + gloasRoot, err := (&spec.VersionedAggregateAndProof{ + Version: spec.DataVersionGloas, + Gloas: gloasAggregate, + }).HashTreeRoot() + require.NoError(t, err) + + electraRoot, err := (&spec.VersionedAggregateAndProof{ + Version: spec.DataVersionElectra, + Electra: electraAggregate, + }).HashTreeRoot() + require.NoError(t, err) + + require.NotEqual(t, electraRoot, gloasRoot, + "the Gloas root must differ from electra's: aggregation bits merkleize progressively") + + // Pin the root itself, so that a change to the progressive merkleization is caught rather + // than merely producing a different-but-still-not-electra value. This value is generated from + // this repository's own codec, so it guards against drift; it is not independent verification + // against the spec, which needs the consensus-spec ssz_static vectors. + require.Equal(t, "0xc565330dfc3b575b20935a7f217a64119b90f86c0e987b023bbf6d94648334ce", + fmt.Sprintf("%#x", gloasRoot), + "the Gloas aggregate root changed: check go generate ./spec/gloas and the progressive merkleization") +} + +// TestVersionedAggregateAndProofGloasAccessors covers every accessor's DataVersionGloas arm on the +// unsigned wrapper, asserting each reads the gloas container and reports a missing arm rather than +// panicking on a nil pointer. +// +// This wrapper has no callers inside this library (it exists for downstream signers, which is why +// its mistyped Gloas arm went unnoticed), so these are the only tests exercising its Gloas path. +func TestVersionedAggregateAndProofGloasAccessors(t *testing.T) { + _, aggregate := equivalentAggregates() + populated := &spec.VersionedAggregateAndProof{ + Version: spec.DataVersionGloas, + Gloas: aggregate, + } + empty := &spec.VersionedAggregateAndProof{Version: spec.DataVersionGloas} + + t.Run("AggregatorIndex", func(t *testing.T) { + got, err := populated.AggregatorIndex() + require.NoError(t, err) + require.Equal(t, phase0.ValidatorIndex(7), got) + + _, err = empty.AggregatorIndex() + require.EqualError(t, err, "no gloas aggregate and proof") + }) + + t.Run("SelectionProof", func(t *testing.T) { + got, err := populated.SelectionProof() + require.NoError(t, err) + require.Equal(t, phase0.BLSSignature{0xbb}, got) + + _, err = empty.SelectionProof() + require.EqualError(t, err, "no gloas aggregate and proof") + }) + + t.Run("HashTreeRoot", func(t *testing.T) { + _, err := empty.HashTreeRoot() + require.EqualError(t, err, "no gloas aggregate and proof") + }) + + t.Run("String", func(t *testing.T) { + require.Contains(t, populated.String(), "aggregator_index") + require.Empty(t, empty.String()) + }) + + t.Run("IsEmpty", func(t *testing.T) { + require.False(t, populated.IsEmpty()) + require.True(t, empty.IsEmpty()) + }) +} + +// equivalentAggregates returns an electra and a gloas aggregate and proof carrying identical +// logical content, so that any difference in their hash tree roots is attributable to +// merkleization alone. +// +// It, along with the aggregationBits helper below, is declared here and shared with +// versionedsignedaggregateandproof_test.go. +func equivalentAggregates() (*electra.AggregateAndProof, *gloas.AggregateAndProof) { + data := &phase0.AttestationData{ + Slot: 42, + Index: 1, + BeaconBlockRoot: phase0.Root{0x01}, + Source: &phase0.Checkpoint{Epoch: 1, Root: phase0.Root{0x02}}, + Target: &phase0.Checkpoint{Epoch: 2, Root: phase0.Root{0x03}}, + } + + signature := phase0.BLSSignature{0xaa} + selectionProof := phase0.BLSSignature{0xbb} + + return &electra.AggregateAndProof{ + AggregatorIndex: 7, + Aggregate: &electra.Attestation{ + AggregationBits: aggregationBits(), + Data: data, + Signature: signature, + CommitteeBits: committeeBits(), + }, + SelectionProof: selectionProof, + }, &gloas.AggregateAndProof{ + AggregatorIndex: 7, + Aggregate: &gloas.Attestation{ + AggregationBits: aggregationBits(), + Data: data, + Signature: signature, + CommitteeBits: committeeBits(), + }, + SelectionProof: selectionProof, + } +} + +// aggregationBits returns a bitlist with two bits set, short enough that the progressive and +// regular merkleizations of it are both exercised on a single chunk. +func aggregationBits() bitfield.Bitlist { + bits := bitfield.NewBitlist(8) + bits.SetBitAt(0, true) + bits.SetBitAt(3, true) + + return bits +} + +// committeeBits returns a committee bitvector with a single committee selected. +func committeeBits() bitfield.Bitvector64 { + bits := bitfield.NewBitvector64() + bits.SetBitAt(0, true) + + return bits +} diff --git a/spec/versionedattestation.go b/spec/versionedattestation.go index 9a143245..f367119f 100644 --- a/spec/versionedattestation.go +++ b/spec/versionedattestation.go @@ -1,4 +1,4 @@ -// Copyright © 2024 Attestant Limited. +// Copyright © 2024 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -19,6 +19,7 @@ import ( "github.com/OffchainLabs/go-bitfield" "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" ) @@ -33,12 +34,13 @@ type VersionedAttestation struct { Deneb *phase0.Attestation Electra *electra.Attestation Fulu *electra.Attestation + Gloas *gloas.Attestation } // IsEmpty returns true if there is no block. func (v *VersionedAttestation) IsEmpty() bool { return v.Phase0 == nil && v.Altair == nil && v.Bellatrix == nil && - v.Capella == nil && v.Deneb == nil && v.Electra == nil && v.Fulu == nil + v.Capella == nil && v.Deneb == nil && v.Electra == nil && v.Fulu == nil && v.Gloas == nil } // AggregationBits returns the aggregation bits of the attestation. @@ -86,6 +88,12 @@ func (v *VersionedAttestation) AggregationBits() (bitfield.Bitlist, error) { } return v.Fulu.AggregationBits, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no Gloas attestation") + } + + return v.Gloas.AggregationBits, nil default: return nil, errors.New("unknown version") } @@ -136,6 +144,12 @@ func (v *VersionedAttestation) Data() (*phase0.AttestationData, error) { } return v.Fulu.Data, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no Gloas attestation") + } + + return v.Gloas.Data, nil default: return nil, fmt.Errorf("unknown version: %d", v.Version) } @@ -158,6 +172,12 @@ func (v *VersionedAttestation) CommitteeBits() (bitfield.Bitvector64, error) { } return v.Fulu.CommitteeBits, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no Gloas attestation") + } + + return v.Gloas.CommitteeBits, nil default: return nil, errors.New("unknown version") } @@ -208,6 +228,12 @@ func (v *VersionedAttestation) CommitteeIndex() (phase0.CommitteeIndex, error) { } return v.Fulu.CommitteeIndex() + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no Gloas attestation") + } + + return v.Gloas.CommitteeIndex() default: return 0, errors.New("unknown version") } @@ -257,6 +283,12 @@ func (v *VersionedAttestation) HashTreeRoot() ([32]byte, error) { } return v.Fulu.HashTreeRoot() + case DataVersionGloas: + if v.Gloas == nil { + return [32]byte{}, errors.New("no Gloas attestation") + } + + return v.Gloas.HashTreeRoot() default: return [32]byte{}, errors.New("unknown version") } @@ -307,6 +339,12 @@ func (v *VersionedAttestation) Signature() (phase0.BLSSignature, error) { } return v.Fulu.Signature, nil + case DataVersionGloas: + if v.Gloas == nil { + return phase0.BLSSignature{}, errors.New("no Gloas attestation") + } + + return v.Gloas.Signature, nil default: return phase0.BLSSignature{}, errors.New("unknown version") } @@ -357,6 +395,12 @@ func (v *VersionedAttestation) String() string { } return v.Fulu.String() + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() default: return "unknown version" } diff --git a/spec/versionedattesterslashing.go b/spec/versionedattesterslashing.go index 8bf880f8..e9613f1c 100644 --- a/spec/versionedattesterslashing.go +++ b/spec/versionedattesterslashing.go @@ -1,4 +1,4 @@ -// Copyright © 2024 Attestant Limited. +// Copyright © 2024 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -17,6 +17,7 @@ import ( "errors" "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" ) @@ -30,12 +31,13 @@ type VersionedAttesterSlashing struct { Deneb *phase0.AttesterSlashing Electra *electra.AttesterSlashing Fulu *electra.AttesterSlashing + Gloas *gloas.AttesterSlashing } // IsEmpty returns true if there is no block. func (v *VersionedAttesterSlashing) IsEmpty() bool { return v.Phase0 == nil && v.Altair == nil && v.Bellatrix == nil && - v.Capella == nil && v.Deneb == nil && v.Electra == nil && v.Fulu == nil + v.Capella == nil && v.Deneb == nil && v.Electra == nil && v.Fulu == nil && v.Gloas == nil } // Attestation1 returns the first indexed attestation. @@ -117,6 +119,17 @@ func (v *VersionedAttesterSlashing) Attestation1() (*VersionedIndexedAttestation Fulu: v.Fulu.Attestation1, } + return &versionedIndexedAttestation, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no Gloas indexed attestation") + } + + versionedIndexedAttestation := VersionedIndexedAttestation{ + Version: DataVersionGloas, + Gloas: v.Gloas.Attestation1, + } + return &versionedIndexedAttestation, nil default: return nil, errors.New("unknown version") @@ -202,6 +215,17 @@ func (v *VersionedAttesterSlashing) Attestation2() (*VersionedIndexedAttestation Fulu: v.Fulu.Attestation2, } + return &versionedIndexedAttestation, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no Gloas indexed attestation") + } + + versionedIndexedAttestation := VersionedIndexedAttestation{ + Version: DataVersionGloas, + Gloas: v.Gloas.Attestation2, + } + return &versionedIndexedAttestation, nil default: return nil, errors.New("unknown version") @@ -253,6 +277,12 @@ func (v *VersionedAttesterSlashing) String() string { } return v.Fulu.String() + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() default: return "unknown version" } diff --git a/spec/versionedbeaconblock.go b/spec/versionedbeaconblock.go index 9daa7149..e0a579a6 100644 --- a/spec/versionedbeaconblock.go +++ b/spec/versionedbeaconblock.go @@ -1,4 +1,4 @@ -// Copyright © 2021 - 2024 Attestant Limited. +// Copyright © 2021 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -21,6 +21,7 @@ import ( "github.com/attestantio/go-eth2-client/spec/capella" "github.com/attestantio/go-eth2-client/spec/deneb" "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" ) @@ -34,12 +35,13 @@ type VersionedBeaconBlock struct { Deneb *deneb.BeaconBlock Electra *electra.BeaconBlock Fulu *electra.BeaconBlock + Gloas *gloas.BeaconBlock } // IsEmpty returns true if there is no block. func (v *VersionedBeaconBlock) IsEmpty() bool { return v.Phase0 == nil && v.Altair == nil && v.Bellatrix == nil && v.Capella == nil && - v.Deneb == nil && v.Electra == nil && v.Fulu == nil + v.Deneb == nil && v.Electra == nil && v.Fulu == nil && v.Gloas == nil } // Slot returns the slot of the beacon block. @@ -87,6 +89,12 @@ func (v *VersionedBeaconBlock) Slot() (phase0.Slot, error) { } return v.Fulu.Slot, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no gloas block") + } + + return v.Gloas.Slot, nil default: return 0, errors.New("unknown version") } @@ -165,6 +173,15 @@ func (v *VersionedBeaconBlock) RandaoReveal() (phase0.BLSSignature, error) { } return v.Fulu.Body.RANDAOReveal, nil + case DataVersionGloas: + if v.Gloas == nil { + return phase0.BLSSignature{}, errors.New("no gloas block") + } + if v.Gloas.Body == nil { + return phase0.BLSSignature{}, errors.New("no gloas block body") + } + + return v.Gloas.Body.RANDAOReveal, nil default: return phase0.BLSSignature{}, errors.New("unknown version") } @@ -243,6 +260,15 @@ func (v *VersionedBeaconBlock) Graffiti() ([32]byte, error) { } return v.Fulu.Body.Graffiti, nil + case DataVersionGloas: + if v.Gloas == nil { + return [32]byte{}, errors.New("no gloas block") + } + if v.Gloas.Body == nil { + return [32]byte{}, errors.New("no gloas block body") + } + + return v.Gloas.Body.Graffiti, nil default: return [32]byte{}, errors.New("unknown version") } @@ -293,6 +319,12 @@ func (v *VersionedBeaconBlock) ProposerIndex() (phase0.ValidatorIndex, error) { } return v.Fulu.ProposerIndex, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no gloas block") + } + + return v.Gloas.ProposerIndex, nil default: return 0, errors.New("unknown version") } @@ -343,6 +375,12 @@ func (v *VersionedBeaconBlock) Root() (phase0.Root, error) { } return v.Fulu.HashTreeRoot() + case DataVersionGloas: + if v.Gloas == nil { + return phase0.Root{}, errors.New("no gloas block") + } + + return v.Gloas.HashTreeRoot() default: return phase0.Root{}, errors.New("unknown version") } @@ -421,6 +459,15 @@ func (v *VersionedBeaconBlock) BodyRoot() (phase0.Root, error) { } return v.Fulu.Body.HashTreeRoot() + case DataVersionGloas: + if v.Gloas == nil { + return phase0.Root{}, errors.New("no gloas block") + } + if v.Gloas.Body == nil { + return phase0.Root{}, errors.New("no gloas block body") + } + + return v.Gloas.Body.HashTreeRoot() default: return phase0.Root{}, errors.New("unknown version") } @@ -471,6 +518,12 @@ func (v *VersionedBeaconBlock) ParentRoot() (phase0.Root, error) { } return v.Fulu.ParentRoot, nil + case DataVersionGloas: + if v.Gloas == nil { + return phase0.Root{}, errors.New("no gloas block") + } + + return v.Gloas.ParentRoot, nil default: return phase0.Root{}, errors.New("unknown version") } @@ -521,12 +574,20 @@ func (v *VersionedBeaconBlock) StateRoot() (phase0.Root, error) { } return v.Fulu.StateRoot, nil + case DataVersionGloas: + if v.Gloas == nil { + return phase0.Root{}, errors.New("no gloas block") + } + + return v.Gloas.StateRoot, nil default: return phase0.Root{}, errors.New("unknown version") } } // Attestations returns the attestations of the beacon block. +// +//nolint:gocyclo func (v *VersionedBeaconBlock) Attestations() ([]VersionedAttestation, error) { switch v.Version { case DataVersionPhase0: @@ -626,6 +687,20 @@ func (v *VersionedBeaconBlock) Attestations() ([]VersionedAttestation, error) { } } + return versionedAttestations, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Body == nil { + return nil, errors.New("no gloas block") + } + + versionedAttestations := make([]VersionedAttestation, len(v.Gloas.Body.Attestations)) + for i, attestation := range v.Gloas.Body.Attestations { + versionedAttestations[i] = VersionedAttestation{ + Version: DataVersionGloas, + Gloas: attestation, + } + } + return versionedAttestations, nil default: return nil, errors.New("unknown version") @@ -633,6 +708,8 @@ func (v *VersionedBeaconBlock) Attestations() ([]VersionedAttestation, error) { } // AttesterSlashings returns the attester slashings of the beacon block. +// +//nolint:gocyclo func (v *VersionedBeaconBlock) AttesterSlashings() ([]VersionedAttesterSlashing, error) { switch v.Version { case DataVersionPhase0: @@ -732,6 +809,20 @@ func (v *VersionedBeaconBlock) AttesterSlashings() ([]VersionedAttesterSlashing, } } + return versionedAttesterSlashings, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Body == nil { + return nil, errors.New("no gloas block") + } + + versionedAttesterSlashings := make([]VersionedAttesterSlashing, len(v.Gloas.Body.AttesterSlashings)) + for i, attesterSlashing := range v.Gloas.Body.AttesterSlashings { + versionedAttesterSlashings[i] = VersionedAttesterSlashing{ + Version: DataVersionGloas, + Gloas: attesterSlashing, + } + } + return versionedAttesterSlashings, nil default: return nil, errors.New("unknown version") @@ -783,6 +874,43 @@ func (v *VersionedBeaconBlock) ProposerSlashings() ([]*phase0.ProposerSlashing, } return v.Fulu.Body.ProposerSlashings, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Body == nil { + return nil, errors.New("no gloas block") + } + + return v.Gloas.Body.ProposerSlashings, nil + default: + return nil, errors.New("unknown version") + } +} + +// SignedExecutionPayloadBid returns the execution payload bid of the beacon block. +func (v *VersionedBeaconBlock) SignedExecutionPayloadBid() (*VersionedSignedExecutionPayloadBid, error) { + switch v.Version { + case DataVersionPhase0: + return nil, errors.New("no signed execution payload bid in phase0") + case DataVersionAltair: + return nil, errors.New("no signed execution payload bid in altair") + case DataVersionBellatrix: + return nil, errors.New("no signed execution payload bid in bellatrix") + case DataVersionCapella: + return nil, errors.New("no signed execution payload bid in capella") + case DataVersionDeneb: + return nil, errors.New("no signed execution payload bid in deneb") + case DataVersionElectra: + return nil, errors.New("no signed execution payload bid in electra") + case DataVersionFulu: + return nil, errors.New("no signed execution payload bid in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Body == nil { + return nil, errors.New("no gloas block") + } + + return &VersionedSignedExecutionPayloadBid{ + Version: DataVersionGloas, + Gloas: v.Gloas.Body.SignedExecutionPayloadBid, + }, nil default: return nil, errors.New("unknown version") } @@ -829,6 +957,8 @@ func (v *VersionedBeaconBlock) ExecutionPayload() (*VersionedExecutionPayload, e } versionedExecutionPayload.Fulu = v.Fulu.Body.ExecutionPayload + case DataVersionGloas: + return nil, errors.New("no execution payload in gloas") default: return nil, errors.New("unknown version") } @@ -881,6 +1011,12 @@ func (v *VersionedBeaconBlock) String() string { } return v.Fulu.String() + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() default: return "unknown version" } diff --git a/spec/versionedbeaconblockbody.go b/spec/versionedbeaconblockbody.go index 40af4ab6..1ebf1f8b 100644 --- a/spec/versionedbeaconblockbody.go +++ b/spec/versionedbeaconblockbody.go @@ -1,4 +1,4 @@ -// Copyright © 2021 - 2024 Attestant Limited. +// Copyright © 2021 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -19,6 +19,7 @@ import ( "github.com/attestantio/go-eth2-client/spec/capella" "github.com/attestantio/go-eth2-client/spec/deneb" "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" ) @@ -32,6 +33,7 @@ type VersionedBeaconBlockBody struct { Deneb *deneb.BeaconBlockBody Electra *electra.BeaconBlockBody Fulu *electra.BeaconBlockBody + Gloas *gloas.BeaconBlockBody } // String returns a string version of the structure. @@ -79,6 +81,12 @@ func (v *VersionedBeaconBlockBody) String() string { } return v.Fulu.String() + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() default: return "unknown version" } diff --git a/spec/versionedbeaconstate.go b/spec/versionedbeaconstate.go index 8b5398a0..aeee50e1 100644 --- a/spec/versionedbeaconstate.go +++ b/spec/versionedbeaconstate.go @@ -22,6 +22,7 @@ import ( "github.com/attestantio/go-eth2-client/spec/deneb" "github.com/attestantio/go-eth2-client/spec/electra" "github.com/attestantio/go-eth2-client/spec/fulu" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" proofutil "github.com/attestantio/go-eth2-client/util/proof" dynssz "github.com/pk910/dynamic-ssz" @@ -38,12 +39,13 @@ type VersionedBeaconState struct { Deneb *deneb.BeaconState Electra *electra.BeaconState Fulu *fulu.BeaconState + Gloas *gloas.BeaconState } // IsEmpty returns true if there is no block. func (v *VersionedBeaconState) IsEmpty() bool { return v.Phase0 == nil && v.Altair == nil && v.Bellatrix == nil && - v.Capella == nil && v.Deneb == nil && v.Electra == nil && v.Fulu == nil + v.Capella == nil && v.Deneb == nil && v.Electra == nil && v.Fulu == nil && v.Gloas == nil } // Slot returns the slot of the state. @@ -91,6 +93,12 @@ func (v *VersionedBeaconState) Slot() (phase0.Slot, error) { } return v.Fulu.Slot, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no Gloas state") + } + + return v.Gloas.Slot, nil default: return 0, errors.New("unknown version") } @@ -125,6 +133,12 @@ func (v *VersionedBeaconState) NextWithdrawalValidatorIndex() (phase0.ValidatorI } return v.Fulu.NextWithdrawalValidatorIndex, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no Gloas state") + } + + return v.Gloas.NextWithdrawalValidatorIndex, nil default: return 0, errors.New("unknown version") } @@ -175,6 +189,12 @@ func (v *VersionedBeaconState) Validators() ([]*phase0.Validator, error) { } return v.Fulu.Validators, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no Gloas state") + } + + return v.Gloas.Validators, nil default: return nil, errors.New("unknown version") } @@ -225,6 +245,12 @@ func (v *VersionedBeaconState) ValidatorBalances() ([]phase0.Gwei, error) { } return v.Fulu.Balances, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no Gloas state") + } + + return v.Gloas.Balances, nil default: return nil, errors.New("unknown version") } @@ -247,6 +273,12 @@ func (v *VersionedBeaconState) DepositRequestsStartIndex() (uint64, error) { } return v.Fulu.DepositRequestsStartIndex, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no Gloas state") + } + + return v.Gloas.DepositRequestsStartIndex, nil default: return 0, errors.New("unknown version") } @@ -269,6 +301,12 @@ func (v *VersionedBeaconState) DepositBalanceToConsume() (phase0.Gwei, error) { } return v.Fulu.DepositBalanceToConsume, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no Gloas state") + } + + return v.Gloas.DepositBalanceToConsume, nil default: return 0, errors.New("unknown version") } @@ -291,6 +329,12 @@ func (v *VersionedBeaconState) ExitBalanceToConsume() (phase0.Gwei, error) { } return v.Fulu.ExitBalanceToConsume, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no Gloas state") + } + + return v.Gloas.ExitBalanceToConsume, nil default: return 0, errors.New("unknown version") } @@ -313,6 +357,12 @@ func (v *VersionedBeaconState) EarliestExitEpoch() (phase0.Epoch, error) { } return v.Fulu.EarliestExitEpoch, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no Gloas state") + } + + return v.Gloas.EarliestExitEpoch, nil default: return 0, errors.New("unknown version") } @@ -335,6 +385,12 @@ func (v *VersionedBeaconState) ConsolidationBalanceToConsume() (phase0.Gwei, err } return v.Fulu.ConsolidationBalanceToConsume, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no Gloas state") + } + + return v.Gloas.ConsolidationBalanceToConsume, nil default: return 0, errors.New("unknown version") } @@ -357,6 +413,12 @@ func (v *VersionedBeaconState) EarliestConsolidationEpoch() (phase0.Epoch, error } return v.Fulu.EarliestConsolidationEpoch, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no Gloas state") + } + + return v.Gloas.EarliestConsolidationEpoch, nil default: return 0, errors.New("unknown version") } @@ -379,6 +441,12 @@ func (v *VersionedBeaconState) PendingDeposits() ([]*electra.PendingDeposit, err } return v.Fulu.PendingDeposits, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no Gloas state") + } + + return v.Gloas.PendingDeposits, nil default: return nil, errors.New("unknown version") } @@ -401,6 +469,12 @@ func (v *VersionedBeaconState) PendingPartialWithdrawals() ([]*electra.PendingPa } return v.Fulu.PendingPartialWithdrawals, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no Gloas state") + } + + return v.Gloas.PendingPartialWithdrawals, nil default: return nil, errors.New("unknown version") } @@ -423,6 +497,46 @@ func (v *VersionedBeaconState) PendingConsolidations() ([]*electra.PendingConsol } return v.Fulu.PendingConsolidations, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no Gloas state") + } + + return v.Gloas.PendingConsolidations, nil + default: + return nil, errors.New("unknown version") + } +} + +// Builders returns the builders of the state. +func (v *VersionedBeaconState) Builders() ([]*gloas.Builder, error) { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, + DataVersionDeneb, DataVersionElectra, DataVersionFulu: + return nil, errors.New("state does not provide builders") + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no Gloas state") + } + + return v.Gloas.Builders, nil + default: + return nil, errors.New("unknown version") + } +} + +// ExecutionPayloadAvailability returns the execution payload availability of the state. +func (v *VersionedBeaconState) ExecutionPayloadAvailability() ([]uint8, error) { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, + DataVersionDeneb, DataVersionElectra, DataVersionFulu: + return nil, errors.New("state does not provide execution payload availability") + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no Gloas state") + } + + return v.Gloas.ExecutionPayloadAvailability, nil default: return nil, errors.New("unknown version") } @@ -515,6 +629,12 @@ func (v *VersionedBeaconState) GetTree() (*treeproof.Node, error) { } return dynssz.GetGlobalDynSsz().GetTree(v.Fulu) + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no Gloas state") + } + + return dynssz.GetGlobalDynSsz().GetTree(v.Gloas) default: return nil, errors.New("unknown version") } @@ -565,6 +685,12 @@ func (v *VersionedBeaconState) HashTreeRoot() (phase0.Hash32, error) { } return v.Fulu.HashTreeRoot() + case DataVersionGloas: + if v.Gloas == nil { + return phase0.Hash32{}, errors.New("no Gloas state") + } + + return v.Gloas.HashTreeRoot() default: return phase0.Hash32{}, errors.New("unknown version") } @@ -617,6 +743,12 @@ func (v *VersionedBeaconState) FieldIndex(name string) (int, error) { } return proofutil.FieldIndex(v.Fulu, name) + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no Gloas state") + } + + return proofutil.FieldIndex(v.Gloas, name) default: return 0, errors.New("unknown version") } @@ -670,6 +802,12 @@ func (v *VersionedBeaconState) FieldGeneralizedIndex(name string) (int, error) { } return proofutil.FieldGeneralizedIndex(v.Fulu, name) + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no Gloas state") + } + + return proofutil.FieldGeneralizedIndex(v.Gloas, name) default: return 0, errors.New("unknown version") } @@ -829,6 +967,12 @@ func (v *VersionedBeaconState) String() string { } return v.Fulu.String() + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() default: return "unknown version" } diff --git a/spec/versionedexecutionpayload.go b/spec/versionedexecutionpayload.go index 3af836dd..d628eecd 100644 --- a/spec/versionedexecutionpayload.go +++ b/spec/versionedexecutionpayload.go @@ -1,4 +1,4 @@ -// Copyright © 2025 Attestant Limited. +// Copyright © 2025 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -19,6 +19,7 @@ import ( "github.com/attestantio/go-eth2-client/spec/bellatrix" "github.com/attestantio/go-eth2-client/spec/capella" "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/holiman/uint256" ) @@ -31,12 +32,13 @@ type VersionedExecutionPayload struct { Deneb *deneb.ExecutionPayload Electra *deneb.ExecutionPayload Fulu *deneb.ExecutionPayload + Gloas *gloas.ExecutionPayload } // IsEmpty returns true if there is no block. func (v *VersionedExecutionPayload) IsEmpty() bool { return v.Version < DataVersionBellatrix || (v.Bellatrix == nil && - v.Capella == nil && v.Deneb == nil && v.Electra == nil && v.Fulu == nil) + v.Capella == nil && v.Deneb == nil && v.Electra == nil && v.Fulu == nil && v.Gloas == nil) } // ParentHash returns the parent hash of the execution payload. @@ -76,6 +78,12 @@ func (v *VersionedExecutionPayload) ParentHash() (phase0.Hash32, error) { } return v.Fulu.ParentHash, nil + case DataVersionGloas: + if v.Gloas == nil { + return phase0.Hash32{}, errors.New("no gloas execution payload") + } + + return v.Gloas.ParentHash, nil default: return phase0.Hash32{}, errors.New("unknown version") } @@ -118,6 +126,12 @@ func (v *VersionedExecutionPayload) FeeRecipient() (bellatrix.ExecutionAddress, } return v.Fulu.FeeRecipient, nil + case DataVersionGloas: + if v.Gloas == nil { + return bellatrix.ExecutionAddress{}, errors.New("no gloas execution payload") + } + + return v.Gloas.FeeRecipient, nil default: return bellatrix.ExecutionAddress{}, errors.New("unknown version") } @@ -160,6 +174,12 @@ func (v *VersionedExecutionPayload) StateRoot() (phase0.Root, error) { } return v.Fulu.StateRoot, nil + case DataVersionGloas: + if v.Gloas == nil { + return phase0.Root{}, errors.New("no gloas execution payload") + } + + return v.Gloas.StateRoot, nil default: return phase0.Root{}, errors.New("unknown version") } @@ -202,6 +222,12 @@ func (v *VersionedExecutionPayload) ReceiptsRoot() (phase0.Root, error) { } return v.Fulu.ReceiptsRoot, nil + case DataVersionGloas: + if v.Gloas == nil { + return phase0.Root{}, errors.New("no gloas execution payload") + } + + return v.Gloas.ReceiptsRoot, nil default: return phase0.Root{}, errors.New("unknown version") } @@ -244,6 +270,12 @@ func (v *VersionedExecutionPayload) LogsBloom() ([256]byte, error) { } return v.Fulu.LogsBloom, nil + case DataVersionGloas: + if v.Gloas == nil { + return [256]byte{}, errors.New("no gloas execution payload") + } + + return v.Gloas.LogsBloom, nil default: return [256]byte{}, errors.New("unknown version") } @@ -286,6 +318,12 @@ func (v *VersionedExecutionPayload) PrevRandao() ([32]byte, error) { } return v.Fulu.PrevRandao, nil + case DataVersionGloas: + if v.Gloas == nil { + return [32]byte{}, errors.New("no gloas execution payload") + } + + return v.Gloas.PrevRandao, nil default: return [32]byte{}, errors.New("unknown version") } @@ -328,6 +366,12 @@ func (v *VersionedExecutionPayload) BlockNumber() (uint64, error) { } return v.Fulu.BlockNumber, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no gloas execution payload") + } + + return v.Gloas.BlockNumber, nil default: return 0, errors.New("unknown version") } @@ -370,6 +414,12 @@ func (v *VersionedExecutionPayload) GasLimit() (uint64, error) { } return v.Fulu.GasLimit, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no gloas execution payload") + } + + return v.Gloas.GasLimit, nil default: return 0, errors.New("unknown version") } @@ -412,6 +462,12 @@ func (v *VersionedExecutionPayload) GasUsed() (uint64, error) { } return v.Fulu.GasUsed, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no gloas execution payload") + } + + return v.Gloas.GasUsed, nil default: return 0, errors.New("unknown version") } @@ -454,6 +510,12 @@ func (v *VersionedExecutionPayload) Timestamp() (uint64, error) { } return v.Fulu.Timestamp, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no gloas execution payload") + } + + return v.Gloas.Timestamp, nil default: return 0, errors.New("unknown version") } @@ -496,6 +558,12 @@ func (v *VersionedExecutionPayload) ExtraData() ([]byte, error) { } return v.Fulu.ExtraData, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no gloas execution payload") + } + + return v.Gloas.ExtraData, nil default: return nil, errors.New("unknown version") } @@ -538,6 +606,12 @@ func (v *VersionedExecutionPayload) BaseFeePerGas() (*uint256.Int, error) { } return v.Fulu.BaseFeePerGas, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no gloas execution payload") + } + + return v.Gloas.BaseFeePerGas, nil default: return nil, errors.New("unknown version") } @@ -580,6 +654,12 @@ func (v *VersionedExecutionPayload) BlockHash() (phase0.Hash32, error) { } return v.Fulu.BlockHash, nil + case DataVersionGloas: + if v.Gloas == nil { + return phase0.Hash32{}, errors.New("no gloas execution payload") + } + + return v.Gloas.BlockHash, nil default: return phase0.Hash32{}, errors.New("unknown version") } @@ -622,6 +702,12 @@ func (v *VersionedExecutionPayload) Transactions() ([]bellatrix.Transaction, err } return v.Fulu.Transactions, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no gloas execution payload") + } + + return v.Gloas.Transactions, nil default: return nil, errors.New("unknown version") } @@ -660,6 +746,12 @@ func (v *VersionedExecutionPayload) Withdrawals() ([]*capella.Withdrawal, error) } return v.Fulu.Withdrawals, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no gloas execution payload") + } + + return v.Gloas.Withdrawals, nil default: return nil, errors.New("unknown version") } @@ -694,6 +786,12 @@ func (v *VersionedExecutionPayload) BlobGasUsed() (uint64, error) { } return v.Fulu.BlobGasUsed, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no gloas execution payload") + } + + return v.Gloas.BlobGasUsed, nil default: return 0, errors.New("unknown version") } @@ -728,6 +826,12 @@ func (v *VersionedExecutionPayload) ExcessBlobGas() (uint64, error) { } return v.Fulu.ExcessBlobGas, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no gloas execution payload") + } + + return v.Gloas.ExcessBlobGas, nil default: return 0, errors.New("unknown version") } @@ -770,6 +874,12 @@ func (v *VersionedExecutionPayload) String() string { } return v.Fulu.String() + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() default: return "unknown version" } diff --git a/spec/versionedexecutionpayloadenvelope.go b/spec/versionedexecutionpayloadenvelope.go new file mode 100644 index 00000000..bc8cdc9e --- /dev/null +++ b/spec/versionedexecutionpayloadenvelope.go @@ -0,0 +1,194 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spec + +import ( + "errors" + + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// VersionedExecutionPayloadEnvelope contains a versioned execution payload +// envelope (EIP-7732). The envelope is a gloas-onwards container; this +// wrapper exists so callers can treat it uniformly with other versioned types. +type VersionedExecutionPayloadEnvelope struct { + Version DataVersion + Gloas *gloas.ExecutionPayloadEnvelope +} + +// IsEmpty returns true if no fork-specific envelope is populated. +func (v *VersionedExecutionPayloadEnvelope) IsEmpty() bool { + return v.Gloas == nil +} + +// String returns a string version of the structure. +func (v *VersionedExecutionPayloadEnvelope) String() string { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, + DataVersionDeneb, DataVersionElectra, DataVersionFulu: + return "" + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() + default: + return "unknown version" + } +} + +// Payload returns the execution payload of the envelope. +func (v *VersionedExecutionPayloadEnvelope) Payload() (*gloas.ExecutionPayload, error) { + switch v.Version { + case DataVersionPhase0: + return nil, errors.New("no execution payload envelope in phase0") + case DataVersionAltair: + return nil, errors.New("no execution payload envelope in altair") + case DataVersionBellatrix: + return nil, errors.New("no execution payload envelope in bellatrix") + case DataVersionCapella: + return nil, errors.New("no execution payload envelope in capella") + case DataVersionDeneb: + return nil, errors.New("no execution payload envelope in deneb") + case DataVersionElectra: + return nil, errors.New("no execution payload envelope in electra") + case DataVersionFulu: + return nil, errors.New("no execution payload envelope in fulu") + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no gloas execution payload envelope") + } + + return v.Gloas.Payload, nil + default: + return nil, errors.New("unknown version") + } +} + +// ExecutionRequests returns the execution requests of the envelope. +func (v *VersionedExecutionPayloadEnvelope) ExecutionRequests() (*VersionedExecutionRequests, error) { + switch v.Version { + case DataVersionPhase0: + return nil, errors.New("no execution payload envelope in phase0") + case DataVersionAltair: + return nil, errors.New("no execution payload envelope in altair") + case DataVersionBellatrix: + return nil, errors.New("no execution payload envelope in bellatrix") + case DataVersionCapella: + return nil, errors.New("no execution payload envelope in capella") + case DataVersionDeneb: + return nil, errors.New("no execution payload envelope in deneb") + case DataVersionElectra: + return nil, errors.New("no execution payload envelope in electra") + case DataVersionFulu: + return nil, errors.New("no execution payload envelope in fulu") + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no gloas execution payload envelope") + } + + return &VersionedExecutionRequests{ + Version: DataVersionGloas, + Gloas: v.Gloas.ExecutionRequests, + }, nil + default: + return nil, errors.New("unknown version") + } +} + +// BuilderIndex returns the builder index of the envelope. +func (v *VersionedExecutionPayloadEnvelope) BuilderIndex() (gloas.BuilderIndex, error) { + switch v.Version { + case DataVersionPhase0: + return 0, errors.New("no execution payload envelope in phase0") + case DataVersionAltair: + return 0, errors.New("no execution payload envelope in altair") + case DataVersionBellatrix: + return 0, errors.New("no execution payload envelope in bellatrix") + case DataVersionCapella: + return 0, errors.New("no execution payload envelope in capella") + case DataVersionDeneb: + return 0, errors.New("no execution payload envelope in deneb") + case DataVersionElectra: + return 0, errors.New("no execution payload envelope in electra") + case DataVersionFulu: + return 0, errors.New("no execution payload envelope in fulu") + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no gloas execution payload envelope") + } + + return v.Gloas.BuilderIndex, nil + default: + return 0, errors.New("unknown version") + } +} + +// BeaconBlockRoot returns the beacon block root of the envelope. +func (v *VersionedExecutionPayloadEnvelope) BeaconBlockRoot() (phase0.Root, error) { + switch v.Version { + case DataVersionPhase0: + return phase0.Root{}, errors.New("no execution payload envelope in phase0") + case DataVersionAltair: + return phase0.Root{}, errors.New("no execution payload envelope in altair") + case DataVersionBellatrix: + return phase0.Root{}, errors.New("no execution payload envelope in bellatrix") + case DataVersionCapella: + return phase0.Root{}, errors.New("no execution payload envelope in capella") + case DataVersionDeneb: + return phase0.Root{}, errors.New("no execution payload envelope in deneb") + case DataVersionElectra: + return phase0.Root{}, errors.New("no execution payload envelope in electra") + case DataVersionFulu: + return phase0.Root{}, errors.New("no execution payload envelope in fulu") + case DataVersionGloas: + if v.Gloas == nil { + return phase0.Root{}, errors.New("no gloas execution payload envelope") + } + + return v.Gloas.BeaconBlockRoot, nil + default: + return phase0.Root{}, errors.New("unknown version") + } +} + +// ParentBeaconBlockRoot returns the parent beacon block root of the envelope. +func (v *VersionedExecutionPayloadEnvelope) ParentBeaconBlockRoot() (phase0.Root, error) { + switch v.Version { + case DataVersionPhase0: + return phase0.Root{}, errors.New("no execution payload envelope in phase0") + case DataVersionAltair: + return phase0.Root{}, errors.New("no execution payload envelope in altair") + case DataVersionBellatrix: + return phase0.Root{}, errors.New("no execution payload envelope in bellatrix") + case DataVersionCapella: + return phase0.Root{}, errors.New("no execution payload envelope in capella") + case DataVersionDeneb: + return phase0.Root{}, errors.New("no execution payload envelope in deneb") + case DataVersionElectra: + return phase0.Root{}, errors.New("no execution payload envelope in electra") + case DataVersionFulu: + return phase0.Root{}, errors.New("no execution payload envelope in fulu") + case DataVersionGloas: + if v.Gloas == nil { + return phase0.Root{}, errors.New("no gloas execution payload envelope") + } + + return v.Gloas.ParentBeaconBlockRoot, nil + default: + return phase0.Root{}, errors.New("unknown version") + } +} diff --git a/spec/versionedexecutionpayloadenvelope_test.go b/spec/versionedexecutionpayloadenvelope_test.go new file mode 100644 index 00000000..31f0d8aa --- /dev/null +++ b/spec/versionedexecutionpayloadenvelope_test.go @@ -0,0 +1,141 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spec_test + +import ( + "testing" + + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" +) + +func TestVersionedExecutionPayloadEnvelopeAccessors(t *testing.T) { + payload := &gloas.ExecutionPayload{BaseFeePerGas: uint256.NewInt(1)} + executionRequests := &gloas.ExecutionRequests{} + beaconBlockRoot := phase0.Root{0x01} + parentBeaconBlockRoot := phase0.Root{0x02} + populated := &spec.VersionedExecutionPayloadEnvelope{ + Version: spec.DataVersionGloas, + Gloas: &gloas.ExecutionPayloadEnvelope{ + Payload: payload, + ExecutionRequests: executionRequests, + BuilderIndex: 3, + BeaconBlockRoot: beaconBlockRoot, + ParentBeaconBlockRoot: parentBeaconBlockRoot, + }, + } + empty := &spec.VersionedExecutionPayloadEnvelope{Version: spec.DataVersionGloas} + tests := []struct { + name string + accessor func(*spec.VersionedExecutionPayloadEnvelope) (any, error) + want any + }{ + { + name: "Payload", + accessor: func(v *spec.VersionedExecutionPayloadEnvelope) (any, error) { + return v.Payload() + }, + want: payload, + }, + { + name: "ExecutionRequests", + accessor: func(v *spec.VersionedExecutionPayloadEnvelope) (any, error) { + return v.ExecutionRequests() + }, + want: &spec.VersionedExecutionRequests{ + Version: spec.DataVersionGloas, + Gloas: executionRequests, + }, + }, + { + name: "BuilderIndex", + accessor: func(v *spec.VersionedExecutionPayloadEnvelope) (any, error) { + return v.BuilderIndex() + }, + want: gloas.BuilderIndex(3), + }, + { + name: "BeaconBlockRoot", + accessor: func(v *spec.VersionedExecutionPayloadEnvelope) (any, error) { + return v.BeaconBlockRoot() + }, + want: beaconBlockRoot, + }, + { + name: "ParentBeaconBlockRoot", + accessor: func(v *spec.VersionedExecutionPayloadEnvelope) (any, error) { + return v.ParentBeaconBlockRoot() + }, + want: parentBeaconBlockRoot, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := test.accessor(populated) + require.NoError(t, err) + require.Equal(t, test.want, got) + + _, err = test.accessor(empty) + require.EqualError(t, err, "no gloas execution payload envelope") + }) + } +} + +func TestVersionedExecutionPayloadEnvelopeState(t *testing.T) { + tests := []struct { + name string + envelope *spec.VersionedExecutionPayloadEnvelope + empty bool + expected string + }{ + { + name: "Gloas", + envelope: &spec.VersionedExecutionPayloadEnvelope{ + Version: spec.DataVersionGloas, + Gloas: &gloas.ExecutionPayloadEnvelope{ + Payload: &gloas.ExecutionPayload{BaseFeePerGas: uint256.NewInt(1)}, + }, + }, + expected: "payload:", + }, + { + name: "GloasNil", + envelope: &spec.VersionedExecutionPayloadEnvelope{ + Version: spec.DataVersionGloas, + }, + empty: true, + }, + { + name: "UnknownVersion", + envelope: &spec.VersionedExecutionPayloadEnvelope{}, + empty: true, + expected: "unknown version", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.empty, test.envelope.IsEmpty()) + if test.expected == "" { + require.Empty(t, test.envelope.String()) + } else { + require.Contains(t, test.envelope.String(), test.expected) + } + }) + } +} diff --git a/spec/versionedexecutionrequests.go b/spec/versionedexecutionrequests.go new file mode 100644 index 00000000..6b93f822 --- /dev/null +++ b/spec/versionedexecutionrequests.go @@ -0,0 +1,182 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spec + +import ( + "errors" + + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" +) + +// VersionedExecutionRequests contains versioned execution layer triggered +// requests. The container is introduced in Electra; builder requests are added +// in Gloas (EIP-8282). +type VersionedExecutionRequests struct { + Version DataVersion + Electra *electra.ExecutionRequests + Fulu *electra.ExecutionRequests + Gloas *gloas.ExecutionRequests +} + +// IsEmpty returns true if no fork-specific execution requests are populated. +func (v *VersionedExecutionRequests) IsEmpty() bool { + return v.Electra == nil && v.Fulu == nil && v.Gloas == nil +} + +// Deposits returns the deposit requests. +func (v *VersionedExecutionRequests) Deposits() ([]*electra.DepositRequest, error) { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, DataVersionDeneb: + return nil, errors.New("execution requests are not present before electra") + case DataVersionElectra: + if v.Electra == nil { + return nil, errors.New("no electra execution requests") + } + + return v.Electra.Deposits, nil + case DataVersionFulu: + if v.Fulu == nil { + return nil, errors.New("no fulu execution requests") + } + + return v.Fulu.Deposits, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no gloas execution requests") + } + + return v.Gloas.Deposits, nil + default: + return nil, errors.New("unknown version") + } +} + +// Withdrawals returns the withdrawal requests. +func (v *VersionedExecutionRequests) Withdrawals() ([]*electra.WithdrawalRequest, error) { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, DataVersionDeneb: + return nil, errors.New("execution requests are not present before electra") + case DataVersionElectra: + if v.Electra == nil { + return nil, errors.New("no electra execution requests") + } + + return v.Electra.Withdrawals, nil + case DataVersionFulu: + if v.Fulu == nil { + return nil, errors.New("no fulu execution requests") + } + + return v.Fulu.Withdrawals, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no gloas execution requests") + } + + return v.Gloas.Withdrawals, nil + default: + return nil, errors.New("unknown version") + } +} + +// Consolidations returns the consolidation requests. +func (v *VersionedExecutionRequests) Consolidations() ([]*electra.ConsolidationRequest, error) { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, DataVersionDeneb: + return nil, errors.New("execution requests are not present before electra") + case DataVersionElectra: + if v.Electra == nil { + return nil, errors.New("no electra execution requests") + } + + return v.Electra.Consolidations, nil + case DataVersionFulu: + if v.Fulu == nil { + return nil, errors.New("no fulu execution requests") + } + + return v.Fulu.Consolidations, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no gloas execution requests") + } + + return v.Gloas.Consolidations, nil + default: + return nil, errors.New("unknown version") + } +} + +// BuilderDeposits returns the builder deposit requests (EIP-8282, gloas onwards). +func (v *VersionedExecutionRequests) BuilderDeposits() ([]*gloas.BuilderDepositRequest, error) { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, DataVersionDeneb, + DataVersionElectra, DataVersionFulu: + return nil, errors.New("builder deposit requests are not present before gloas") + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no gloas execution requests") + } + + return v.Gloas.BuilderDeposits, nil + default: + return nil, errors.New("unknown version") + } +} + +// BuilderExits returns the builder exit requests (EIP-8282, gloas onwards). +func (v *VersionedExecutionRequests) BuilderExits() ([]*gloas.BuilderExitRequest, error) { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, DataVersionDeneb, + DataVersionElectra, DataVersionFulu: + return nil, errors.New("builder exit requests are not present before gloas") + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no gloas execution requests") + } + + return v.Gloas.BuilderExits, nil + default: + return nil, errors.New("unknown version") + } +} + +// String returns a string version of the structure. +func (v *VersionedExecutionRequests) String() string { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, DataVersionDeneb: + return "" + case DataVersionElectra: + if v.Electra == nil { + return "" + } + + return v.Electra.String() + case DataVersionFulu: + if v.Fulu == nil { + return "" + } + + return v.Fulu.String() + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() + default: + return "unknown version" + } +} diff --git a/spec/versionedexecutionrequests_test.go b/spec/versionedexecutionrequests_test.go new file mode 100644 index 00000000..af9e4011 --- /dev/null +++ b/spec/versionedexecutionrequests_test.go @@ -0,0 +1,136 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spec_test + +import ( + "testing" + + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/stretchr/testify/require" +) + +func TestVersionedExecutionRequestsAccessors(t *testing.T) { + deposits := []*electra.DepositRequest{{Index: 1}} + withdrawals := []*electra.WithdrawalRequest{{}} + consolidations := []*electra.ConsolidationRequest{{}} + builderDeposits := []*gloas.BuilderDepositRequest{{}} + builderExits := []*gloas.BuilderExitRequest{{}} + populated := &spec.VersionedExecutionRequests{ + Version: spec.DataVersionGloas, + Gloas: &gloas.ExecutionRequests{ + Deposits: deposits, + Withdrawals: withdrawals, + Consolidations: consolidations, + BuilderDeposits: builderDeposits, + BuilderExits: builderExits, + }, + } + empty := &spec.VersionedExecutionRequests{Version: spec.DataVersionGloas} + tests := []struct { + name string + accessor func(*spec.VersionedExecutionRequests) (any, error) + want any + }{ + { + name: "Deposits", + accessor: func(v *spec.VersionedExecutionRequests) (any, error) { + return v.Deposits() + }, + want: deposits, + }, + { + name: "Withdrawals", + accessor: func(v *spec.VersionedExecutionRequests) (any, error) { + return v.Withdrawals() + }, + want: withdrawals, + }, + { + name: "Consolidations", + accessor: func(v *spec.VersionedExecutionRequests) (any, error) { + return v.Consolidations() + }, + want: consolidations, + }, + { + name: "BuilderDeposits", + accessor: func(v *spec.VersionedExecutionRequests) (any, error) { + return v.BuilderDeposits() + }, + want: builderDeposits, + }, + { + name: "BuilderExits", + accessor: func(v *spec.VersionedExecutionRequests) (any, error) { + return v.BuilderExits() + }, + want: builderExits, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := test.accessor(populated) + require.NoError(t, err) + require.Equal(t, test.want, got) + + _, err = test.accessor(empty) + require.EqualError(t, err, "no gloas execution requests") + }) + } +} + +func TestVersionedExecutionRequestsState(t *testing.T) { + tests := []struct { + name string + requests *spec.VersionedExecutionRequests + empty bool + expected string + }{ + { + name: "Gloas", + requests: &spec.VersionedExecutionRequests{ + Version: spec.DataVersionGloas, + Gloas: &gloas.ExecutionRequests{}, + }, + expected: "deposits:", + }, + { + name: "GloasNil", + requests: &spec.VersionedExecutionRequests{ + Version: spec.DataVersionGloas, + }, + empty: true, + }, + { + name: "UnknownVersion", + requests: &spec.VersionedExecutionRequests{}, + empty: true, + expected: "unknown version", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.empty, test.requests.IsEmpty()) + if test.expected == "" { + require.Empty(t, test.requests.String()) + } else { + require.Contains(t, test.requests.String(), test.expected) + } + }) + } +} diff --git a/spec/versionedindexedattestation.go b/spec/versionedindexedattestation.go index 1bce2196..06fa31d2 100644 --- a/spec/versionedindexedattestation.go +++ b/spec/versionedindexedattestation.go @@ -1,4 +1,4 @@ -// Copyright © 2024 Attestant Limited. +// Copyright © 2024 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -17,6 +17,7 @@ import ( "errors" "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" ) @@ -30,12 +31,13 @@ type VersionedIndexedAttestation struct { Deneb *phase0.IndexedAttestation Electra *electra.IndexedAttestation Fulu *electra.IndexedAttestation + Gloas *gloas.IndexedAttestation } // IsEmpty returns true if there is no block. func (v *VersionedIndexedAttestation) IsEmpty() bool { return v.Phase0 == nil && v.Altair == nil && v.Bellatrix == nil && - v.Capella == nil && v.Deneb == nil && v.Electra == nil && v.Fulu == nil + v.Capella == nil && v.Deneb == nil && v.Electra == nil && v.Fulu == nil && v.Gloas == nil } // AttestingIndices returns the attesting indices of the indexed attestation. @@ -83,6 +85,12 @@ func (v *VersionedIndexedAttestation) AttestingIndices() ([]uint64, error) { } return v.Fulu.AttestingIndices, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no Gloas indexed attestation") + } + + return v.Gloas.AttestingIndices, nil default: return nil, errors.New("unknown version") } @@ -133,6 +141,12 @@ func (v *VersionedIndexedAttestation) Data() (*phase0.AttestationData, error) { } return v.Fulu.Data, nil + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no Gloas indexed attestation") + } + + return v.Gloas.Data, nil default: return nil, errors.New("unknown version") } @@ -183,6 +197,12 @@ func (v *VersionedIndexedAttestation) Signature() (phase0.BLSSignature, error) { } return v.Fulu.Signature, nil + case DataVersionGloas: + if v.Gloas == nil { + return phase0.BLSSignature{}, errors.New("no Gloas indexed attestation") + } + + return v.Gloas.Signature, nil default: return phase0.BLSSignature{}, errors.New("unknown version") } @@ -233,6 +253,12 @@ func (v *VersionedIndexedAttestation) String() string { } return v.Fulu.String() + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() default: return "unknown version" } diff --git a/spec/versionedpayloadattestation.go b/spec/versionedpayloadattestation.go new file mode 100644 index 00000000..bf40144c --- /dev/null +++ b/spec/versionedpayloadattestation.go @@ -0,0 +1,114 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spec + +import ( + "errors" + "fmt" + + bitfield "github.com/OffchainLabs/go-bitfield" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// VersionedPayloadAttestation contains a versioned payload attestation +// (EIP-7732): the aggregate form held in the operations pool, in which a +// single signature covers the payload timeliness committee members named by +// the aggregation bits. It is a gloas-onwards container; the wrapper exists +// so callers can branch on Version uniformly with the other versioned types. +type VersionedPayloadAttestation struct { + Version DataVersion + Gloas *gloas.PayloadAttestation +} + +// IsEmpty returns true if no fork-specific payload attestation is populated. +func (v *VersionedPayloadAttestation) IsEmpty() bool { + return v.Gloas == nil +} + +// String returns a string version of the structure. +func (v *VersionedPayloadAttestation) String() string { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, + DataVersionDeneb, DataVersionElectra, DataVersionFulu: + return "" + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() + default: + return "unknown version" + } +} + +// Data returns the data being attested to. +func (v *VersionedPayloadAttestation) Data() (*gloas.PayloadAttestationData, error) { + attestation, err := v.attestation() + if err != nil { + return nil, err + } + + if attestation.Data == nil { + return nil, errors.New("no gloas payload attestation data") + } + + return attestation.Data, nil +} + +// AggregationBits returns the bits of the payload timeliness committee covered +// by the attestation's signature. +// +// The returned value's own BitAt, SetBitAt, Len and Shift methods are mainnet-only and +// must not be used; read and write participation through gloas.PayloadAttestation's +// AttestedAt, SetAttestedAt and PTCSize instead, which hold at any preset width. Note +// that width is the producing node's claim rather than a verified fact; see PTCSize. +func (v *VersionedPayloadAttestation) AggregationBits() (bitfield.Bitvector512, error) { + attestation, err := v.attestation() + if err != nil { + return nil, err + } + + return attestation.AggregationBits, nil +} + +// Signature returns the aggregate signature of the payload attestation. +func (v *VersionedPayloadAttestation) Signature() (phase0.BLSSignature, error) { + attestation, err := v.attestation() + if err != nil { + return phase0.BLSSignature{}, err + } + + return attestation.Signature, nil +} + +// attestation returns the fork-specific payload attestation, or an error +// explaining why there is none. Every accessor funnels through here so that +// the version and nil checks cannot drift between them. +func (v *VersionedPayloadAttestation) attestation() (*gloas.PayloadAttestation, error) { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, + DataVersionDeneb, DataVersionElectra, DataVersionFulu: + return nil, fmt.Errorf("no payload attestation in %s", v.Version) + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no gloas payload attestation") + } + + return v.Gloas, nil + default: + return nil, errors.New("unknown version") + } +} diff --git a/spec/versionedpayloadattestation_test.go b/spec/versionedpayloadattestation_test.go new file mode 100644 index 00000000..5c61cbbd --- /dev/null +++ b/spec/versionedpayloadattestation_test.go @@ -0,0 +1,108 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spec_test + +import ( + "testing" + + bitfield "github.com/OffchainLabs/go-bitfield" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +func TestVersionedPayloadAttestationAccessors(t *testing.T) { + bits := bitfield.NewBitvector512() + bits.SetBitAt(3, true) + + populated := &spec.VersionedPayloadAttestation{ + Version: spec.DataVersionGloas, + Gloas: &gloas.PayloadAttestation{ + AggregationBits: bits, + Data: testPayloadAttestationData(), + Signature: phase0.BLSSignature{0xaa}, + }, + } + nilArm := &spec.VersionedPayloadAttestation{Version: spec.DataVersionGloas} + preGloas := &spec.VersionedPayloadAttestation{Version: spec.DataVersionFulu} + unknown := &spec.VersionedPayloadAttestation{Version: spec.DataVersion(99)} + + t.Run("Data", func(t *testing.T) { + data, err := populated.Data() + require.NoError(t, err) + require.Equal(t, phase0.Slot(60300), data.Slot) + + _, err = nilArm.Data() + require.EqualError(t, err, "no gloas payload attestation") + + _, err = preGloas.Data() + require.EqualError(t, err, "no payload attestation in fulu") + + _, err = unknown.Data() + require.EqualError(t, err, "unknown version") + }) + + t.Run("AggregationBits", func(t *testing.T) { + aggregationBits, err := populated.AggregationBits() + require.NoError(t, err) + require.Equal(t, []int{3}, aggregationBits.BitIndices()) + + // Read participation through the container's accessor, not the bitvector's + // mainnet-only BitAt, which this mainnet-width fixture would hide. + attested, err := populated.Gloas.AttestedAt(3) + require.NoError(t, err) + require.True(t, attested) + + attested, err = populated.Gloas.AttestedAt(4) + require.NoError(t, err) + require.False(t, attested) + + _, err = nilArm.AggregationBits() + require.EqualError(t, err, "no gloas payload attestation") + }) + + t.Run("Signature", func(t *testing.T) { + sig, err := populated.Signature() + require.NoError(t, err) + require.Equal(t, phase0.BLSSignature{0xaa}, sig) + + _, err = nilArm.Signature() + require.EqualError(t, err, "no gloas payload attestation") + }) + + t.Run("IsEmpty", func(t *testing.T) { + require.False(t, populated.IsEmpty()) + require.True(t, nilArm.IsEmpty()) + }) + + t.Run("String", func(t *testing.T) { + require.Contains(t, populated.String(), "60300") + require.Empty(t, nilArm.String()) + require.Equal(t, "unknown version", unknown.String()) + }) + + // A populated attestation whose inner Data is nil must not hand back a + // usable-looking zero value: the aggregate carries signatures over that + // datum, so an empty one silently misrepresents what was signed. + t.Run("NilData", func(t *testing.T) { + partial := &spec.VersionedPayloadAttestation{ + Version: spec.DataVersionGloas, + Gloas: &gloas.PayloadAttestation{AggregationBits: bits}, + } + + _, err := partial.Data() + require.EqualError(t, err, "no gloas payload attestation data") + }) +} diff --git a/spec/versionedpayloadattestationdata.go b/spec/versionedpayloadattestationdata.go new file mode 100644 index 00000000..4d426f88 --- /dev/null +++ b/spec/versionedpayloadattestationdata.go @@ -0,0 +1,112 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spec + +import ( + "errors" + "fmt" + + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// VersionedPayloadAttestationData contains a versioned payload attestation +// data (EIP-7732). This is the datum a payload timeliness committee member +// signs, and is a gloas-onwards container; the wrapper exists so callers can +// branch on Version uniformly with the other versioned types. +type VersionedPayloadAttestationData struct { + Version DataVersion + Gloas *gloas.PayloadAttestationData +} + +// IsEmpty returns true if no fork-specific payload attestation data is populated. +func (v *VersionedPayloadAttestationData) IsEmpty() bool { + return v.Gloas == nil +} + +// String returns a string version of the structure. +func (v *VersionedPayloadAttestationData) String() string { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, + DataVersionDeneb, DataVersionElectra, DataVersionFulu: + return "" + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() + default: + return "unknown version" + } +} + +// Slot returns the slot of the payload attestation data. +func (v *VersionedPayloadAttestationData) Slot() (phase0.Slot, error) { + data, err := v.data() + if err != nil { + return 0, err + } + + return data.Slot, nil +} + +// BeaconBlockRoot returns the root of the beacon block being attested to. +func (v *VersionedPayloadAttestationData) BeaconBlockRoot() (phase0.Root, error) { + data, err := v.data() + if err != nil { + return phase0.Root{}, err + } + + return data.BeaconBlockRoot, nil +} + +// PayloadPresent returns true if the execution payload was revealed on time. +func (v *VersionedPayloadAttestationData) PayloadPresent() (bool, error) { + data, err := v.data() + if err != nil { + return false, err + } + + return data.PayloadPresent, nil +} + +// BlobDataAvailable returns true if the blob data for the payload was available. +func (v *VersionedPayloadAttestationData) BlobDataAvailable() (bool, error) { + data, err := v.data() + if err != nil { + return false, err + } + + return data.BlobDataAvailable, nil +} + +// data returns the fork-specific payload attestation data, or an error +// explaining why there is none. Every accessor funnels through here so that +// the version and nil checks cannot drift between them. +func (v *VersionedPayloadAttestationData) data() (*gloas.PayloadAttestationData, error) { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, + DataVersionDeneb, DataVersionElectra, DataVersionFulu: + return nil, fmt.Errorf("no payload attestation data in %s", v.Version) + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no gloas payload attestation data") + } + + return v.Gloas, nil + default: + return nil, errors.New("unknown version") + } +} diff --git a/spec/versionedpayloadattestationdata_test.go b/spec/versionedpayloadattestationdata_test.go new file mode 100644 index 00000000..30951c37 --- /dev/null +++ b/spec/versionedpayloadattestationdata_test.go @@ -0,0 +1,108 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spec_test + +import ( + "testing" + + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// testPayloadAttestationData is the datum a PTC validator signs: the block it +// is voting on, plus the two timeliness flags. +// +// The two flags are deliberately set apart. Both are bool and both mean +// "something about this payload is fine", so an accessor wired to the other +// one's field is invisible to any test built on a fixture that sets them +// alike — and getting it wrong makes a validator vote the opposite of what it +// observed. +func testPayloadAttestationData() *gloas.PayloadAttestationData { + return &gloas.PayloadAttestationData{ + BeaconBlockRoot: phase0.Root{0x01, 0x02, 0x03}, + Slot: 60300, + PayloadPresent: true, + BlobDataAvailable: false, + } +} + +func TestVersionedPayloadAttestationDataAccessors(t *testing.T) { + populated := &spec.VersionedPayloadAttestationData{ + Version: spec.DataVersionGloas, + Gloas: testPayloadAttestationData(), + } + nilArm := &spec.VersionedPayloadAttestationData{Version: spec.DataVersionGloas} + preGloas := &spec.VersionedPayloadAttestationData{Version: spec.DataVersionElectra} + unknown := &spec.VersionedPayloadAttestationData{Version: spec.DataVersion(99)} + + t.Run("Slot", func(t *testing.T) { + slot, err := populated.Slot() + require.NoError(t, err) + require.Equal(t, phase0.Slot(60300), slot) + + _, err = nilArm.Slot() + require.EqualError(t, err, "no gloas payload attestation data") + + _, err = preGloas.Slot() + require.EqualError(t, err, "no payload attestation data in electra") + + _, err = unknown.Slot() + require.EqualError(t, err, "unknown version") + }) + + t.Run("BeaconBlockRoot", func(t *testing.T) { + root, err := populated.BeaconBlockRoot() + require.NoError(t, err) + require.Equal(t, phase0.Root{0x01, 0x02, 0x03}, root) + + _, err = nilArm.BeaconBlockRoot() + require.EqualError(t, err, "no gloas payload attestation data") + + _, err = preGloas.BeaconBlockRoot() + require.EqualError(t, err, "no payload attestation data in electra") + }) + + t.Run("PayloadPresent", func(t *testing.T) { + present, err := populated.PayloadPresent() + require.NoError(t, err) + require.True(t, present) + + _, err = nilArm.PayloadPresent() + require.EqualError(t, err, "no gloas payload attestation data") + }) + + t.Run("BlobDataAvailable", func(t *testing.T) { + available, err := populated.BlobDataAvailable() + require.NoError(t, err) + require.False(t, available) + + _, err = nilArm.BlobDataAvailable() + require.EqualError(t, err, "no gloas payload attestation data") + }) + + t.Run("IsEmpty", func(t *testing.T) { + require.False(t, populated.IsEmpty()) + require.True(t, nilArm.IsEmpty()) + require.True(t, preGloas.IsEmpty()) + }) + + t.Run("String", func(t *testing.T) { + require.Contains(t, populated.String(), "60300") + require.Empty(t, nilArm.String()) + require.Empty(t, preGloas.String()) + require.Equal(t, "unknown version", unknown.String()) + }) +} diff --git a/spec/versionedpayloadattestationmessage.go b/spec/versionedpayloadattestationmessage.go new file mode 100644 index 00000000..3bd248f1 --- /dev/null +++ b/spec/versionedpayloadattestationmessage.go @@ -0,0 +1,107 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spec + +import ( + "errors" + "fmt" + + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// VersionedPayloadAttestationMessage contains a versioned payload attestation +// message (EIP-7732): the unaggregated form a single payload timeliness +// committee member broadcasts. It is a gloas-onwards container; the wrapper +// exists so that the Eth-Consensus-Version header required when submitting one +// is derived from the caller's data rather than hard-coded. +type VersionedPayloadAttestationMessage struct { + Version DataVersion + Gloas *gloas.PayloadAttestationMessage +} + +// IsEmpty returns true if no fork-specific message is populated. +func (v *VersionedPayloadAttestationMessage) IsEmpty() bool { + return v.Gloas == nil +} + +// String returns a string version of the structure. +func (v *VersionedPayloadAttestationMessage) String() string { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, + DataVersionDeneb, DataVersionElectra, DataVersionFulu: + return "" + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() + default: + return "unknown version" + } +} + +// ValidatorIndex returns the index of the attesting validator. +func (v *VersionedPayloadAttestationMessage) ValidatorIndex() (phase0.ValidatorIndex, error) { + message, err := v.message() + if err != nil { + return 0, err + } + + return message.ValidatorIndex, nil +} + +// Data returns the data being attested to. +func (v *VersionedPayloadAttestationMessage) Data() (*gloas.PayloadAttestationData, error) { + message, err := v.message() + if err != nil { + return nil, err + } + + if message.Data == nil { + return nil, errors.New("no gloas payload attestation data") + } + + return message.Data, nil +} + +// Signature returns the signature of the message. +func (v *VersionedPayloadAttestationMessage) Signature() (phase0.BLSSignature, error) { + message, err := v.message() + if err != nil { + return phase0.BLSSignature{}, err + } + + return message.Signature, nil +} + +// message returns the fork-specific message, or an error explaining why there +// is none. Every accessor funnels through here so that the version and nil +// checks cannot drift between them. +func (v *VersionedPayloadAttestationMessage) message() (*gloas.PayloadAttestationMessage, error) { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, + DataVersionDeneb, DataVersionElectra, DataVersionFulu: + return nil, fmt.Errorf("no payload attestation message in %s", v.Version) + case DataVersionGloas: + if v.Gloas == nil { + return nil, errors.New("no gloas payload attestation message") + } + + return v.Gloas, nil + default: + return nil, errors.New("unknown version") + } +} diff --git a/spec/versionedpayloadattestationmessage_test.go b/spec/versionedpayloadattestationmessage_test.go new file mode 100644 index 00000000..2256aa81 --- /dev/null +++ b/spec/versionedpayloadattestationmessage_test.go @@ -0,0 +1,93 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spec_test + +import ( + "testing" + + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +func TestVersionedPayloadAttestationMessageAccessors(t *testing.T) { + populated := &spec.VersionedPayloadAttestationMessage{ + Version: spec.DataVersionGloas, + Gloas: &gloas.PayloadAttestationMessage{ + ValidatorIndex: 8, + Data: testPayloadAttestationData(), + Signature: phase0.BLSSignature{0xbb}, + }, + } + nilArm := &spec.VersionedPayloadAttestationMessage{Version: spec.DataVersionGloas} + preGloas := &spec.VersionedPayloadAttestationMessage{Version: spec.DataVersionDeneb} + unknown := &spec.VersionedPayloadAttestationMessage{Version: spec.DataVersion(99)} + + t.Run("ValidatorIndex", func(t *testing.T) { + index, err := populated.ValidatorIndex() + require.NoError(t, err) + require.Equal(t, phase0.ValidatorIndex(8), index) + + _, err = nilArm.ValidatorIndex() + require.EqualError(t, err, "no gloas payload attestation message") + + _, err = preGloas.ValidatorIndex() + require.EqualError(t, err, "no payload attestation message in deneb") + + _, err = unknown.ValidatorIndex() + require.EqualError(t, err, "unknown version") + }) + + t.Run("Data", func(t *testing.T) { + data, err := populated.Data() + require.NoError(t, err) + require.Equal(t, phase0.Slot(60300), data.Slot) + + _, err = nilArm.Data() + require.EqualError(t, err, "no gloas payload attestation message") + }) + + t.Run("Signature", func(t *testing.T) { + sig, err := populated.Signature() + require.NoError(t, err) + require.Equal(t, phase0.BLSSignature{0xbb}, sig) + + _, err = nilArm.Signature() + require.EqualError(t, err, "no gloas payload attestation message") + }) + + t.Run("IsEmpty", func(t *testing.T) { + require.False(t, populated.IsEmpty()) + require.True(t, nilArm.IsEmpty()) + }) + + t.Run("String", func(t *testing.T) { + require.Contains(t, populated.String(), "60300") + require.Empty(t, nilArm.String()) + require.Equal(t, "unknown version", unknown.String()) + }) + + // The message is what gets submitted to the node; a nil Data would be + // serialised as a null datum rather than rejected. + t.Run("NilData", func(t *testing.T) { + partial := &spec.VersionedPayloadAttestationMessage{ + Version: spec.DataVersionGloas, + Gloas: &gloas.PayloadAttestationMessage{ValidatorIndex: 8}, + } + + _, err := partial.Data() + require.EqualError(t, err, "no gloas payload attestation data") + }) +} diff --git a/spec/versionedsignedaggregateandproof.go b/spec/versionedsignedaggregateandproof.go index 508f9932..f2804291 100644 --- a/spec/versionedsignedaggregateandproof.go +++ b/spec/versionedsignedaggregateandproof.go @@ -1,4 +1,4 @@ -// Copyright © 2025 Attestant Limited. +// Copyright © 2025 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -17,6 +17,7 @@ import ( "errors" "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" ) @@ -30,6 +31,10 @@ type VersionedSignedAggregateAndProof struct { Deneb *phase0.SignedAggregateAndProof Electra *electra.SignedAggregateAndProof Fulu *electra.SignedAggregateAndProof + // Gloas has its own container because gloas.Attestation merkleizes its aggregation bits as a + // progressive bitlist, giving a different hash tree root (and so a different signing root) + // from the electra container. + Gloas *gloas.SignedAggregateAndProof } // AggregatorIndex returns the aggregator index of the aggregate. @@ -77,6 +82,12 @@ func (v *VersionedSignedAggregateAndProof) AggregatorIndex() (phase0.ValidatorIn } return v.Fulu.Message.AggregatorIndex, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no gloas signed aggregate and proof") + } + + return v.Gloas.Message.AggregatorIndex, nil default: return 0, errors.New("unknown version for signed aggregate and proof") } @@ -85,7 +96,7 @@ func (v *VersionedSignedAggregateAndProof) AggregatorIndex() (phase0.ValidatorIn // IsEmpty returns true if there is no aggregate and proof. func (v *VersionedSignedAggregateAndProof) IsEmpty() bool { return v.Phase0 == nil && v.Altair == nil && v.Bellatrix == nil && v.Capella == nil && v.Deneb == nil && - v.Electra == nil && v.Fulu == nil + v.Electra == nil && v.Fulu == nil && v.Gloas == nil } // SelectionProof returns the selection proof of the signed aggregate. @@ -133,6 +144,12 @@ func (v *VersionedSignedAggregateAndProof) SelectionProof() (phase0.BLSSignature } return v.Fulu.Message.SelectionProof, nil + case DataVersionGloas: + if v.Gloas == nil { + return phase0.BLSSignature{}, errors.New("no gloas signed aggregate and proof") + } + + return v.Gloas.Message.SelectionProof, nil default: return phase0.BLSSignature{}, errors.New("unknown version") } @@ -183,6 +200,12 @@ func (v *VersionedSignedAggregateAndProof) Signature() (phase0.BLSSignature, err } return v.Fulu.Signature, nil + case DataVersionGloas: + if v.Gloas == nil { + return phase0.BLSSignature{}, errors.New("no gloas signed aggregate and proof") + } + + return v.Gloas.Signature, nil default: return phase0.BLSSignature{}, errors.New("unknown version") } @@ -233,6 +256,12 @@ func (v *VersionedSignedAggregateAndProof) Slot() (phase0.Slot, error) { } return v.Fulu.Message.Aggregate.Data.Slot, nil + case DataVersionGloas: + if v.Gloas == nil { + return 0, errors.New("no gloas signed aggregate and proof") + } + + return v.Gloas.Message.Aggregate.Data.Slot, nil default: return 0, errors.New("unknown version") } @@ -283,6 +312,12 @@ func (v *VersionedSignedAggregateAndProof) String() string { } return v.Fulu.String() + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() default: return "unknown version" } diff --git a/spec/versionedsignedaggregateandproof_test.go b/spec/versionedsignedaggregateandproof_test.go new file mode 100644 index 00000000..6bfe2983 --- /dev/null +++ b/spec/versionedsignedaggregateandproof_test.go @@ -0,0 +1,146 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spec_test + +import ( + "testing" + + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// TestVersionedSignedAggregateAndProofGloasFetchToSubmit walks a Gloas aggregate through the +// sequence a validator actually performs, asserting the types line up at every hop with no +// conversion: +// +// fetch: AggregateAttestation returns a VersionedAttestation whose Gloas arm is a +// *gloas.Attestation +// aggregate: that attestation is placed directly into a VersionedAggregateAndProof +// sign: HashTreeRoot on that wrapper yields the signing root +// submit: the signed result is wrapped in a VersionedSignedAggregateAndProof +// +// The hop that matters is placing the fetched *gloas.Attestation into the aggregate: while the +// Gloas arms were electra-typed this step did not compile, and the aggregate had to be rebuilt as +// an electra value, which is what made the signing root wrong. Each assignment below is therefore +// itself the assertion; the accessor checks confirm the wrappers resolve their Gloas arms. +func TestVersionedSignedAggregateAndProofGloasFetchToSubmit(t *testing.T) { + _, aggregate := equivalentAggregates() + + // Fetch: the shape AggregateAttestation returns for a Gloas node. + fetched := &spec.VersionedAttestation{ + Version: spec.DataVersionGloas, + Gloas: aggregate.Aggregate, + } + + // The fetched wrapper resolves its Gloas arm through the versioned accessors. + fetchedBits, err := fetched.AggregationBits() + require.NoError(t, err) + require.Equal(t, aggregationBits(), fetchedBits) + + // Aggregate: the fetched attestation is carried straight through, not converted. + unsigned := &spec.VersionedAggregateAndProof{ + Version: spec.DataVersionGloas, + Gloas: &gloas.AggregateAndProof{ + AggregatorIndex: 7, + Aggregate: fetched.Gloas, + SelectionProof: phase0.BLSSignature{0xbb}, + }, + } + + // Sign: the root the validator signs comes from the unsigned wrapper. + _, err = unsigned.HashTreeRoot() + require.NoError(t, err) + + // Submit: the signed aggregate keeps the same message. + signed := &spec.VersionedSignedAggregateAndProof{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedAggregateAndProof{ + Message: unsigned.Gloas, + Signature: phase0.BLSSignature{0xcc}, + }, + } + + // The signed wrapper reports the values that came off the wire, through its accessors. + aggregatorIndex, err := signed.AggregatorIndex() + require.NoError(t, err) + require.Equal(t, phase0.ValidatorIndex(7), aggregatorIndex) + + slot, err := signed.Slot() + require.NoError(t, err) + require.Equal(t, phase0.Slot(42), slot) +} + +// TestVersionedSignedAggregateAndProofGloasAccessors covers every accessor's DataVersionGloas arm, +// asserting each reads the gloas container rather than erroring or returning a zero value, and that +// each reports a missing arm rather than panicking on a nil pointer. +func TestVersionedSignedAggregateAndProofGloasAccessors(t *testing.T) { + _, aggregate := equivalentAggregates() + populated := &spec.VersionedSignedAggregateAndProof{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedAggregateAndProof{ + Message: aggregate, + Signature: phase0.BLSSignature{0xcc}, + }, + } + empty := &spec.VersionedSignedAggregateAndProof{Version: spec.DataVersionGloas} + + t.Run("AggregatorIndex", func(t *testing.T) { + got, err := populated.AggregatorIndex() + require.NoError(t, err) + require.Equal(t, phase0.ValidatorIndex(7), got) + + _, err = empty.AggregatorIndex() + require.EqualError(t, err, "no gloas signed aggregate and proof") + }) + + t.Run("SelectionProof", func(t *testing.T) { + got, err := populated.SelectionProof() + require.NoError(t, err) + require.Equal(t, phase0.BLSSignature{0xbb}, got) + + _, err = empty.SelectionProof() + require.EqualError(t, err, "no gloas signed aggregate and proof") + }) + + t.Run("Signature", func(t *testing.T) { + got, err := populated.Signature() + require.NoError(t, err) + require.Equal(t, phase0.BLSSignature{0xcc}, got) + + _, err = empty.Signature() + require.EqualError(t, err, "no gloas signed aggregate and proof") + }) + + t.Run("Slot", func(t *testing.T) { + got, err := populated.Slot() + require.NoError(t, err) + require.Equal(t, phase0.Slot(42), got) + + _, err = empty.Slot() + require.EqualError(t, err, "no gloas signed aggregate and proof") + }) + + t.Run("String", func(t *testing.T) { + // The gloas container's own String, reached through the arm, reports its fields. + require.Contains(t, populated.String(), "aggregator_index") + require.Empty(t, empty.String()) + }) + + t.Run("IsEmpty", func(t *testing.T) { + require.False(t, populated.IsEmpty()) + require.True(t, empty.IsEmpty()) + }) +} diff --git a/spec/versionedsignedbeaconblock.go b/spec/versionedsignedbeaconblock.go index e6bef3ae..d25e2e1b 100644 --- a/spec/versionedsignedbeaconblock.go +++ b/spec/versionedsignedbeaconblock.go @@ -1,4 +1,4 @@ -// Copyright © 2021 - 2024 Attestant Limited. +// Copyright © 2021 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -21,6 +21,7 @@ import ( "github.com/attestantio/go-eth2-client/spec/capella" "github.com/attestantio/go-eth2-client/spec/deneb" "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" ) @@ -34,6 +35,7 @@ type VersionedSignedBeaconBlock struct { Deneb *deneb.SignedBeaconBlock Electra *electra.SignedBeaconBlock Fulu *electra.SignedBeaconBlock + Gloas *gloas.SignedBeaconBlock } // Slot returns the slot of the signed beacon block. @@ -81,6 +83,12 @@ func (v *VersionedSignedBeaconBlock) Slot() (phase0.Slot, error) { } return v.Fulu.Message.Slot, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return 0, errors.New("no gloas block") + } + + return v.Gloas.Message.Slot, nil default: return 0, errors.New("unknown version") } @@ -131,12 +139,20 @@ func (v *VersionedSignedBeaconBlock) ProposerIndex() (phase0.ValidatorIndex, err } return v.Fulu.Message.ProposerIndex, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return 0, errors.New("no gloas block") + } + + return v.Gloas.Message.ProposerIndex, nil default: return 0, errors.New("unknown version") } } // ExecutionBlockHash returns the block hash of the beacon block. +// +//nolint:gocyclo func (v *VersionedSignedBeaconBlock) ExecutionBlockHash() (phase0.Hash32, error) { switch v.Version { case DataVersionBellatrix: @@ -178,6 +194,16 @@ func (v *VersionedSignedBeaconBlock) ExecutionBlockHash() (phase0.Hash32, error) } return v.Fulu.Message.Body.ExecutionPayload.BlockHash, nil + case DataVersionGloas: + if v.Gloas == nil || + v.Gloas.Message == nil || + v.Gloas.Message.Body == nil || + v.Gloas.Message.Body.SignedExecutionPayloadBid == nil || + v.Gloas.Message.Body.SignedExecutionPayloadBid.Message == nil { + return phase0.Hash32{}, errors.New("no gloas block") + } + + return v.Gloas.Message.Body.SignedExecutionPayloadBid.Message.BlockHash, nil default: return phase0.Hash32{}, errors.New("unknown version") } @@ -225,6 +251,8 @@ func (v *VersionedSignedBeaconBlock) ExecutionBlockNumber() (uint64, error) { } return v.Fulu.Message.Body.ExecutionPayload.BlockNumber, nil + case DataVersionGloas: + return 0, errors.New("no execution block number for gloas block") default: return 0, errors.New("unknown version") } @@ -276,12 +304,16 @@ func (v *VersionedSignedBeaconBlock) ExecutionTransactions() ([]bellatrix.Transa } return v.Fulu.Message.Body.ExecutionPayload.Transactions, nil + case DataVersionGloas: + return nil, errors.New("no execution block transactions for gloas block") default: return nil, errors.New("unknown version") } } // Graffiti returns the graffiti for the block. +// +//nolint:gocyclo func (v *VersionedSignedBeaconBlock) Graffiti() ([32]byte, error) { switch v.Version { case DataVersionPhase0: @@ -326,6 +358,12 @@ func (v *VersionedSignedBeaconBlock) Graffiti() ([32]byte, error) { } return v.Fulu.Message.Body.Graffiti, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil || v.Gloas.Message.Body == nil { + return [32]byte{}, errors.New("no gloas block") + } + + return v.Gloas.Message.Body.Graffiti, nil default: return [32]byte{}, errors.New("unknown version") } @@ -433,6 +471,20 @@ func (v *VersionedSignedBeaconBlock) Attestations() ([]*VersionedAttestation, er } } + return versionedAttestations, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil || v.Gloas.Message.Body == nil { + return nil, errors.New("no gloas block") + } + + versionedAttestations := make([]*VersionedAttestation, len(v.Gloas.Message.Body.Attestations)) + for i, attestation := range v.Gloas.Message.Body.Attestations { + versionedAttestations[i] = &VersionedAttestation{ + Version: DataVersionGloas, + Gloas: attestation, + } + } + return versionedAttestations, nil default: return nil, errors.New("unknown version") @@ -484,12 +536,20 @@ func (v *VersionedSignedBeaconBlock) Root() (phase0.Root, error) { } return v.Fulu.Message.HashTreeRoot() + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return phase0.Root{}, errors.New("no gloas block") + } + + return v.Gloas.Message.HashTreeRoot() default: return phase0.Root{}, errors.New("unknown version") } } // BodyRoot returns the body root of the beacon block. +// +//nolint:gocyclo func (v *VersionedSignedBeaconBlock) BodyRoot() (phase0.Root, error) { switch v.Version { case DataVersionPhase0: @@ -534,6 +594,12 @@ func (v *VersionedSignedBeaconBlock) BodyRoot() (phase0.Root, error) { } return v.Fulu.Message.Body.HashTreeRoot() + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil || v.Gloas.Message.Body == nil { + return phase0.Root{}, errors.New("no gloas block") + } + + return v.Gloas.Message.Body.HashTreeRoot() default: return phase0.Root{}, errors.New("unknown version") } @@ -584,6 +650,12 @@ func (v *VersionedSignedBeaconBlock) ParentRoot() (phase0.Root, error) { } return v.Fulu.Message.ParentRoot, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return phase0.Root{}, errors.New("no gloas block") + } + + return v.Gloas.Message.ParentRoot, nil default: return phase0.Root{}, errors.New("unknown version") } @@ -634,12 +706,20 @@ func (v *VersionedSignedBeaconBlock) StateRoot() (phase0.Root, error) { } return v.Fulu.Message.StateRoot, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return phase0.Root{}, errors.New("no gloas block") + } + + return v.Gloas.Message.StateRoot, nil default: return phase0.Root{}, errors.New("unknown version") } } // RandaoReveal returns the randao reveal of the beacon block. +// +//nolint:gocyclo func (v *VersionedSignedBeaconBlock) RandaoReveal() (phase0.BLSSignature, error) { switch v.Version { case DataVersionPhase0: @@ -684,12 +764,20 @@ func (v *VersionedSignedBeaconBlock) RandaoReveal() (phase0.BLSSignature, error) } return v.Fulu.Message.Body.RANDAOReveal, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil || v.Gloas.Message.Body == nil { + return phase0.BLSSignature{}, errors.New("no gloas block") + } + + return v.Gloas.Message.Body.RANDAOReveal, nil default: return phase0.BLSSignature{}, errors.New("unknown version") } } // ETH1Data returns the eth1 data of the beacon block. +// +//nolint:gocyclo func (v *VersionedSignedBeaconBlock) ETH1Data() (*phase0.ETH1Data, error) { switch v.Version { case DataVersionPhase0: @@ -734,12 +822,20 @@ func (v *VersionedSignedBeaconBlock) ETH1Data() (*phase0.ETH1Data, error) { } return v.Fulu.Message.Body.ETH1Data, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil || v.Gloas.Message.Body == nil { + return nil, errors.New("no gloas block") + } + + return v.Gloas.Message.Body.ETH1Data, nil default: return nil, errors.New("unknown version") } } // Deposits returns the deposits of the beacon block. +// +//nolint:gocyclo func (v *VersionedSignedBeaconBlock) Deposits() ([]*phase0.Deposit, error) { switch v.Version { case DataVersionPhase0: @@ -784,12 +880,20 @@ func (v *VersionedSignedBeaconBlock) Deposits() ([]*phase0.Deposit, error) { } return v.Fulu.Message.Body.Deposits, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil || v.Gloas.Message.Body == nil { + return nil, errors.New("no gloas block") + } + + return v.Gloas.Message.Body.Deposits, nil default: return nil, errors.New("unknown version") } } // VoluntaryExits returns the voluntary exits of the beacon block. +// +//nolint:gocyclo func (v *VersionedSignedBeaconBlock) VoluntaryExits() ([]*phase0.SignedVoluntaryExit, error) { switch v.Version { case DataVersionPhase0: @@ -834,6 +938,12 @@ func (v *VersionedSignedBeaconBlock) VoluntaryExits() ([]*phase0.SignedVoluntary } return v.Fulu.Message.Body.VoluntaryExits, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil || v.Gloas.Message.Body == nil { + return nil, errors.New("no gloas block") + } + + return v.Gloas.Message.Body.VoluntaryExits, nil default: return nil, errors.New("unknown version") } @@ -941,6 +1051,20 @@ func (v *VersionedSignedBeaconBlock) AttesterSlashings() ([]VersionedAttesterSla } } + return versionedAttesterSlashings, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil || v.Gloas.Message.Body == nil { + return nil, errors.New("no gloas block") + } + + versionedAttesterSlashings := make([]VersionedAttesterSlashing, len(v.Gloas.Message.Body.AttesterSlashings)) + for i, attesterSlashing := range v.Gloas.Message.Body.AttesterSlashings { + versionedAttesterSlashings[i] = VersionedAttesterSlashing{ + Version: DataVersionGloas, + Gloas: attesterSlashing, + } + } + return versionedAttesterSlashings, nil default: return nil, errors.New("unknown version") @@ -948,6 +1072,8 @@ func (v *VersionedSignedBeaconBlock) AttesterSlashings() ([]VersionedAttesterSla } // ProposerSlashings returns the proposer slashings of the beacon block. +// +//nolint:gocyclo func (v *VersionedSignedBeaconBlock) ProposerSlashings() ([]*phase0.ProposerSlashing, error) { switch v.Version { case DataVersionPhase0: @@ -992,6 +1118,12 @@ func (v *VersionedSignedBeaconBlock) ProposerSlashings() ([]*phase0.ProposerSlas } return v.Fulu.Message.Body.ProposerSlashings, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil || v.Gloas.Message.Body == nil { + return nil, errors.New("no gloas block") + } + + return v.Gloas.Message.Body.ProposerSlashings, nil default: return nil, errors.New("unknown version") } @@ -1038,6 +1170,12 @@ func (v *VersionedSignedBeaconBlock) SyncAggregate() (*altair.SyncAggregate, err } return v.Fulu.Message.Body.SyncAggregate, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil || v.Gloas.Message.Body == nil { + return nil, errors.New("no gloas block") + } + + return v.Gloas.Message.Body.SyncAggregate, nil default: return nil, errors.New("unknown version") } @@ -1076,6 +1214,12 @@ func (v *VersionedSignedBeaconBlock) BLSToExecutionChanges() ([]*capella.SignedB } return v.Fulu.Message.Body.BLSToExecutionChanges, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil || v.Gloas.Message.Body == nil { + return nil, errors.New("no gloas block") + } + + return v.Gloas.Message.Body.BLSToExecutionChanges, nil default: return nil, errors.New("unknown version") } @@ -1120,6 +1264,8 @@ func (v *VersionedSignedBeaconBlock) Withdrawals() ([]*capella.Withdrawal, error } return v.Fulu.Message.Body.ExecutionPayload.Withdrawals, nil + case DataVersionGloas: + return nil, errors.New("no execution payload withdrawals for gloas block") default: return nil, errors.New("unknown version") } @@ -1154,6 +1300,14 @@ func (v *VersionedSignedBeaconBlock) BlobKZGCommitments() ([]deneb.KZGCommitment } return v.Fulu.Message.Body.BlobKZGCommitments, nil + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil || v.Gloas.Message.Body == nil || + v.Gloas.Message.Body.SignedExecutionPayloadBid == nil || + v.Gloas.Message.Body.SignedExecutionPayloadBid.Message == nil { + return nil, errors.New("no gloas block") + } + + return v.Gloas.Message.Body.SignedExecutionPayloadBid.Message.BlobKZGCommitments, nil default: return nil, errors.New("unknown version") } @@ -1186,6 +1340,8 @@ func (v *VersionedSignedBeaconBlock) ExecutionRequests() (*electra.ExecutionRequ } return v.Fulu.Message.Body.ExecutionRequests, nil + case DataVersionGloas: + return nil, errors.New("gloas block does not have execution requests") default: return nil, errors.New("unknown version") } @@ -1232,6 +1388,8 @@ func (v *VersionedSignedBeaconBlock) ExecutionPayload() (*VersionedExecutionPayl } versionedExecutionPayload.Fulu = v.Fulu.Message.Body.ExecutionPayload + case DataVersionGloas: + return nil, errors.New("no execution payload in gloas") default: return nil, errors.New("unknown version") } @@ -1284,7 +1442,44 @@ func (v *VersionedSignedBeaconBlock) String() string { } return v.Fulu.String() + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() default: return "unknown version" } } + +// SignedExecutionPayloadBid returns the execution payload bid of the beacon block. +func (v *VersionedSignedBeaconBlock) SignedExecutionPayloadBid() (*VersionedSignedExecutionPayloadBid, error) { + switch v.Version { + case DataVersionPhase0: + return nil, errors.New("no signed execution payload bid in phase0") + case DataVersionAltair: + return nil, errors.New("no signed execution payload bid in altair") + case DataVersionBellatrix: + return nil, errors.New("no signed execution payload bid in bellatrix") + case DataVersionCapella: + return nil, errors.New("no signed execution payload bid in capella") + case DataVersionDeneb: + return nil, errors.New("no signed execution payload bid in deneb") + case DataVersionElectra: + return nil, errors.New("no signed execution payload bid in electra") + case DataVersionFulu: + return nil, errors.New("no signed execution payload bid in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil || v.Gloas.Message.Body == nil { + return nil, errors.New("no gloas block") + } + + return &VersionedSignedExecutionPayloadBid{ + Version: DataVersionGloas, + Gloas: v.Gloas.Message.Body.SignedExecutionPayloadBid, + }, nil + default: + return nil, errors.New("unknown version") + } +} diff --git a/spec/versionedsignedexecutionpayloadbid.go b/spec/versionedsignedexecutionpayloadbid.go new file mode 100644 index 00000000..159151e4 --- /dev/null +++ b/spec/versionedsignedexecutionpayloadbid.go @@ -0,0 +1,354 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spec + +import ( + "errors" + + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// VersionedSignedExecutionPayloadBid contains a versioned signed execution payload bid. +type VersionedSignedExecutionPayloadBid struct { + Version DataVersion + Gloas *gloas.SignedExecutionPayloadBid +} + +// String returns a string version of the structure. +func (v *VersionedSignedExecutionPayloadBid) String() string { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, + DataVersionDeneb, DataVersionElectra, DataVersionFulu: + return "" + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() + default: + return "unknown version" + } +} + +// ParentBlockHash returns the parent block hash of the execution payload bid. +func (v *VersionedSignedExecutionPayloadBid) ParentBlockHash() (phase0.Hash32, error) { + switch v.Version { + case DataVersionPhase0: + return phase0.Hash32{}, errors.New("no execution payload bid in phase0") + case DataVersionAltair: + return phase0.Hash32{}, errors.New("no execution payload bid in altair") + case DataVersionBellatrix: + return phase0.Hash32{}, errors.New("no execution payload bid in bellatrix") + case DataVersionCapella: + return phase0.Hash32{}, errors.New("no execution payload bid in capella") + case DataVersionDeneb: + return phase0.Hash32{}, errors.New("no execution payload bid in deneb") + case DataVersionElectra: + return phase0.Hash32{}, errors.New("no execution payload bid in electra") + case DataVersionFulu: + return phase0.Hash32{}, errors.New("no execution payload bid in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return phase0.Hash32{}, errors.New("no gloas execution payload bid") + } + + return v.Gloas.Message.ParentBlockHash, nil + default: + return phase0.Hash32{}, errors.New("unknown version") + } +} + +// ParentBlockRoot returns the parent block root of the execution payload bid. +func (v *VersionedSignedExecutionPayloadBid) ParentBlockRoot() (phase0.Root, error) { + switch v.Version { + case DataVersionPhase0: + return phase0.Root{}, errors.New("no execution payload bid in phase0") + case DataVersionAltair: + return phase0.Root{}, errors.New("no execution payload bid in altair") + case DataVersionBellatrix: + return phase0.Root{}, errors.New("no execution payload bid in bellatrix") + case DataVersionCapella: + return phase0.Root{}, errors.New("no execution payload bid in capella") + case DataVersionDeneb: + return phase0.Root{}, errors.New("no execution payload bid in deneb") + case DataVersionElectra: + return phase0.Root{}, errors.New("no execution payload bid in electra") + case DataVersionFulu: + return phase0.Root{}, errors.New("no execution payload bid in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return phase0.Root{}, errors.New("no gloas execution payload bid") + } + + return v.Gloas.Message.ParentBlockRoot, nil + default: + return phase0.Root{}, errors.New("unknown version") + } +} + +// BlockHash returns the block hash of the execution payload bid. +func (v *VersionedSignedExecutionPayloadBid) BlockHash() (phase0.Hash32, error) { + switch v.Version { + case DataVersionPhase0: + return phase0.Hash32{}, errors.New("no execution payload bid in phase0") + case DataVersionAltair: + return phase0.Hash32{}, errors.New("no execution payload bid in altair") + case DataVersionBellatrix: + return phase0.Hash32{}, errors.New("no execution payload bid in bellatrix") + case DataVersionCapella: + return phase0.Hash32{}, errors.New("no execution payload bid in capella") + case DataVersionDeneb: + return phase0.Hash32{}, errors.New("no execution payload bid in deneb") + case DataVersionElectra: + return phase0.Hash32{}, errors.New("no execution payload bid in electra") + case DataVersionFulu: + return phase0.Hash32{}, errors.New("no execution payload bid in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return phase0.Hash32{}, errors.New("no gloas execution payload bid") + } + + return v.Gloas.Message.BlockHash, nil + default: + return phase0.Hash32{}, errors.New("unknown version") + } +} + +// FeeRecipient returns the fee recipient of the execution payload bid. +func (v *VersionedSignedExecutionPayloadBid) FeeRecipient() (bellatrix.ExecutionAddress, error) { + switch v.Version { + case DataVersionPhase0: + return bellatrix.ExecutionAddress{}, errors.New("no execution payload bid in phase0") + case DataVersionAltair: + return bellatrix.ExecutionAddress{}, errors.New("no execution payload bid in altair") + case DataVersionBellatrix: + return bellatrix.ExecutionAddress{}, errors.New("no execution payload bid in bellatrix") + case DataVersionCapella: + return bellatrix.ExecutionAddress{}, errors.New("no execution payload bid in capella") + case DataVersionDeneb: + return bellatrix.ExecutionAddress{}, errors.New("no execution payload bid in deneb") + case DataVersionElectra: + return bellatrix.ExecutionAddress{}, errors.New("no execution payload bid in electra") + case DataVersionFulu: + return bellatrix.ExecutionAddress{}, errors.New("no execution payload bid in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return bellatrix.ExecutionAddress{}, errors.New("no gloas execution payload bid") + } + + return v.Gloas.Message.FeeRecipient, nil + default: + return bellatrix.ExecutionAddress{}, errors.New("unknown version") + } +} + +// GasLimit returns the gas limit of the execution payload bid. +func (v *VersionedSignedExecutionPayloadBid) GasLimit() (uint64, error) { + switch v.Version { + case DataVersionPhase0: + return 0, errors.New("no execution payload bid in phase0") + case DataVersionAltair: + return 0, errors.New("no execution payload bid in altair") + case DataVersionBellatrix: + return 0, errors.New("no execution payload bid in bellatrix") + case DataVersionCapella: + return 0, errors.New("no execution payload bid in capella") + case DataVersionDeneb: + return 0, errors.New("no execution payload bid in deneb") + case DataVersionElectra: + return 0, errors.New("no execution payload bid in electra") + case DataVersionFulu: + return 0, errors.New("no execution payload bid in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return 0, errors.New("no gloas execution payload bid") + } + + return v.Gloas.Message.GasLimit, nil + default: + return 0, errors.New("unknown version") + } +} + +// BuilderIndex returns the builder index of the execution payload bid. +func (v *VersionedSignedExecutionPayloadBid) BuilderIndex() (gloas.BuilderIndex, error) { + switch v.Version { + case DataVersionPhase0: + return 0, errors.New("no execution payload bid in phase0") + case DataVersionAltair: + return 0, errors.New("no execution payload bid in altair") + case DataVersionBellatrix: + return 0, errors.New("no execution payload bid in bellatrix") + case DataVersionCapella: + return 0, errors.New("no execution payload bid in capella") + case DataVersionDeneb: + return 0, errors.New("no execution payload bid in deneb") + case DataVersionElectra: + return 0, errors.New("no execution payload bid in electra") + case DataVersionFulu: + return 0, errors.New("no execution payload bid in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return 0, errors.New("no gloas execution payload bid") + } + + return v.Gloas.Message.BuilderIndex, nil + default: + return 0, errors.New("unknown version") + } +} + +// Slot returns the slot of the execution payload bid. +func (v *VersionedSignedExecutionPayloadBid) Slot() (phase0.Slot, error) { + switch v.Version { + case DataVersionPhase0: + return 0, errors.New("no execution payload bid in phase0") + case DataVersionAltair: + return 0, errors.New("no execution payload bid in altair") + case DataVersionBellatrix: + return 0, errors.New("no execution payload bid in bellatrix") + case DataVersionCapella: + return 0, errors.New("no execution payload bid in capella") + case DataVersionDeneb: + return 0, errors.New("no execution payload bid in deneb") + case DataVersionElectra: + return 0, errors.New("no execution payload bid in electra") + case DataVersionFulu: + return 0, errors.New("no execution payload bid in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return 0, errors.New("no gloas execution payload bid") + } + + return v.Gloas.Message.Slot, nil + default: + return 0, errors.New("unknown version") + } +} + +// Value returns the value of the execution payload bid. +func (v *VersionedSignedExecutionPayloadBid) Value() (phase0.Gwei, error) { + switch v.Version { + case DataVersionPhase0: + return 0, errors.New("no execution payload bid in phase0") + case DataVersionAltair: + return 0, errors.New("no execution payload bid in altair") + case DataVersionBellatrix: + return 0, errors.New("no execution payload bid in bellatrix") + case DataVersionCapella: + return 0, errors.New("no execution payload bid in capella") + case DataVersionDeneb: + return 0, errors.New("no execution payload bid in deneb") + case DataVersionElectra: + return 0, errors.New("no execution payload bid in electra") + case DataVersionFulu: + return 0, errors.New("no execution payload bid in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return 0, errors.New("no gloas execution payload bid") + } + + return v.Gloas.Message.Value, nil + default: + return 0, errors.New("unknown version") + } +} + +// ExecutionPayment returns the execution payment of the execution payload bid. +func (v *VersionedSignedExecutionPayloadBid) ExecutionPayment() (phase0.Gwei, error) { + switch v.Version { + case DataVersionPhase0: + return 0, errors.New("no execution payload bid in phase0") + case DataVersionAltair: + return 0, errors.New("no execution payload bid in altair") + case DataVersionBellatrix: + return 0, errors.New("no execution payload bid in bellatrix") + case DataVersionCapella: + return 0, errors.New("no execution payload bid in capella") + case DataVersionDeneb: + return 0, errors.New("no execution payload bid in deneb") + case DataVersionElectra: + return 0, errors.New("no execution payload bid in electra") + case DataVersionFulu: + return 0, errors.New("no execution payload bid in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return 0, errors.New("no gloas execution payload bid") + } + + return v.Gloas.Message.ExecutionPayment, nil + default: + return 0, errors.New("unknown version") + } +} + +// BlobKZGCommitments returns the blob kzg commitments of the execution payload bid. +func (v *VersionedSignedExecutionPayloadBid) BlobKZGCommitments() ([]deneb.KZGCommitment, error) { + switch v.Version { + case DataVersionPhase0: + return nil, errors.New("no execution payload bid in phase0") + case DataVersionAltair: + return nil, errors.New("no execution payload bid in altair") + case DataVersionBellatrix: + return nil, errors.New("no execution payload bid in bellatrix") + case DataVersionCapella: + return nil, errors.New("no execution payload bid in capella") + case DataVersionDeneb: + return nil, errors.New("no execution payload bid in deneb") + case DataVersionElectra: + return nil, errors.New("no execution payload bid in electra") + case DataVersionFulu: + return nil, errors.New("no execution payload bid in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return nil, errors.New("no gloas execution payload bid") + } + + return v.Gloas.Message.BlobKZGCommitments, nil + default: + return nil, errors.New("unknown version") + } +} + +// Signature returns the signature of the execution payload bid. +func (v *VersionedSignedExecutionPayloadBid) Signature() (phase0.BLSSignature, error) { + switch v.Version { + case DataVersionPhase0: + return phase0.BLSSignature{}, errors.New("no execution payload bid in phase0") + case DataVersionAltair: + return phase0.BLSSignature{}, errors.New("no execution payload bid in altair") + case DataVersionBellatrix: + return phase0.BLSSignature{}, errors.New("no execution payload bid in bellatrix") + case DataVersionCapella: + return phase0.BLSSignature{}, errors.New("no execution payload bid in capella") + case DataVersionDeneb: + return phase0.BLSSignature{}, errors.New("no execution payload bid in deneb") + case DataVersionElectra: + return phase0.BLSSignature{}, errors.New("no execution payload bid in electra") + case DataVersionFulu: + return phase0.BLSSignature{}, errors.New("no execution payload bid in fulu") + case DataVersionGloas: + if v.Gloas == nil { + return phase0.BLSSignature{}, errors.New("no gloas execution payload bid") + } + + return v.Gloas.Signature, nil + default: + return phase0.BLSSignature{}, errors.New("unknown version") + } +} diff --git a/spec/versionedsignedexecutionpayloadbid_test.go b/spec/versionedsignedexecutionpayloadbid_test.go new file mode 100644 index 00000000..a9c0928f --- /dev/null +++ b/spec/versionedsignedexecutionpayloadbid_test.go @@ -0,0 +1,203 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spec_test + +import ( + "testing" + + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +func TestVersionedSignedExecutionPayloadBidAccessors(t *testing.T) { + parentBlockHash := phase0.Hash32{0x01} + parentBlockRoot := phase0.Root{0x02} + blockHash := phase0.Hash32{0x03} + feeRecipient := bellatrix.ExecutionAddress{0x04} + blobKZGCommitments := []deneb.KZGCommitment{{0x05}} + signature := phase0.BLSSignature{0x06} + populated := &spec.VersionedSignedExecutionPayloadBid{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedExecutionPayloadBid{ + Message: &gloas.ExecutionPayloadBid{ + ParentBlockHash: parentBlockHash, + ParentBlockRoot: parentBlockRoot, + BlockHash: blockHash, + FeeRecipient: feeRecipient, + GasLimit: 7, + BuilderIndex: 8, + Slot: 9, + Value: 10, + ExecutionPayment: 11, + BlobKZGCommitments: blobKZGCommitments, + }, + Signature: signature, + }, + } + gloasNoMessage := &spec.VersionedSignedExecutionPayloadBid{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedExecutionPayloadBid{}, + } + gloasNil := &spec.VersionedSignedExecutionPayloadBid{Version: spec.DataVersionGloas} + tests := []struct { + name string + accessor func(*spec.VersionedSignedExecutionPayloadBid) (any, error) + want any + missing *spec.VersionedSignedExecutionPayloadBid + }{ + { + name: "ParentBlockHash", + accessor: func(v *spec.VersionedSignedExecutionPayloadBid) (any, error) { + return v.ParentBlockHash() + }, + want: parentBlockHash, + missing: gloasNoMessage, + }, + { + name: "ParentBlockRoot", + accessor: func(v *spec.VersionedSignedExecutionPayloadBid) (any, error) { + return v.ParentBlockRoot() + }, + want: parentBlockRoot, + missing: gloasNoMessage, + }, + { + name: "BlockHash", + accessor: func(v *spec.VersionedSignedExecutionPayloadBid) (any, error) { + return v.BlockHash() + }, + want: blockHash, + missing: gloasNoMessage, + }, + { + name: "FeeRecipient", + accessor: func(v *spec.VersionedSignedExecutionPayloadBid) (any, error) { + return v.FeeRecipient() + }, + want: feeRecipient, + missing: gloasNoMessage, + }, + { + name: "GasLimit", + accessor: func(v *spec.VersionedSignedExecutionPayloadBid) (any, error) { + return v.GasLimit() + }, + want: uint64(7), + missing: gloasNoMessage, + }, + { + name: "BuilderIndex", + accessor: func(v *spec.VersionedSignedExecutionPayloadBid) (any, error) { + return v.BuilderIndex() + }, + want: gloas.BuilderIndex(8), + missing: gloasNoMessage, + }, + { + name: "Slot", + accessor: func(v *spec.VersionedSignedExecutionPayloadBid) (any, error) { + return v.Slot() + }, + want: phase0.Slot(9), + missing: gloasNoMessage, + }, + { + name: "Value", + accessor: func(v *spec.VersionedSignedExecutionPayloadBid) (any, error) { + return v.Value() + }, + want: phase0.Gwei(10), + missing: gloasNoMessage, + }, + { + name: "ExecutionPayment", + accessor: func(v *spec.VersionedSignedExecutionPayloadBid) (any, error) { + return v.ExecutionPayment() + }, + want: phase0.Gwei(11), + missing: gloasNoMessage, + }, + { + name: "BlobKZGCommitments", + accessor: func(v *spec.VersionedSignedExecutionPayloadBid) (any, error) { + return v.BlobKZGCommitments() + }, + want: blobKZGCommitments, + missing: gloasNoMessage, + }, + { + name: "Signature", + accessor: func(v *spec.VersionedSignedExecutionPayloadBid) (any, error) { + return v.Signature() + }, + want: signature, + missing: gloasNil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := test.accessor(populated) + require.NoError(t, err) + require.Equal(t, test.want, got) + + _, err = test.accessor(test.missing) + require.EqualError(t, err, "no gloas execution payload bid") + }) + } +} + +func TestVersionedSignedExecutionPayloadBidString(t *testing.T) { + tests := []struct { + name string + bid *spec.VersionedSignedExecutionPayloadBid + expected string + }{ + { + name: "Gloas", + bid: &spec.VersionedSignedExecutionPayloadBid{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedExecutionPayloadBid{ + Message: &gloas.ExecutionPayloadBid{}, + }, + }, + expected: "parent_block_hash", + }, + { + name: "GloasNil", + bid: &spec.VersionedSignedExecutionPayloadBid{ + Version: spec.DataVersionGloas, + }, + }, + { + name: "UnknownVersion", + bid: &spec.VersionedSignedExecutionPayloadBid{}, + expected: "unknown version", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.expected == "" { + require.Empty(t, test.bid.String()) + } else { + require.Contains(t, test.bid.String(), test.expected) + } + }) + } +} diff --git a/spec/versionedsignedexecutionpayloadenvelope.go b/spec/versionedsignedexecutionpayloadenvelope.go new file mode 100644 index 00000000..05f12d05 --- /dev/null +++ b/spec/versionedsignedexecutionpayloadenvelope.go @@ -0,0 +1,251 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spec + +import ( + "errors" + + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// VersionedSignedExecutionPayloadEnvelope contains a versioned signed +// execution payload envelope (EIP-7732). The envelope is a gloas-onwards +// container; this wrapper exists so callers can treat it uniformly with +// other versioned types. +type VersionedSignedExecutionPayloadEnvelope struct { + Version DataVersion + Gloas *gloas.SignedExecutionPayloadEnvelope +} + +// IsEmpty returns true if no fork-specific envelope is populated. +func (v *VersionedSignedExecutionPayloadEnvelope) IsEmpty() bool { + return v.Gloas == nil +} + +// String returns a string version of the structure. +func (v *VersionedSignedExecutionPayloadEnvelope) String() string { + switch v.Version { + case DataVersionPhase0, DataVersionAltair, DataVersionBellatrix, DataVersionCapella, + DataVersionDeneb, DataVersionElectra, DataVersionFulu: + return "" + case DataVersionGloas: + if v.Gloas == nil { + return "" + } + + return v.Gloas.String() + default: + return "unknown version" + } +} + +// Message returns the inner ExecutionPayloadEnvelope. +func (v *VersionedSignedExecutionPayloadEnvelope) Message() (*gloas.ExecutionPayloadEnvelope, error) { + switch v.Version { + case DataVersionPhase0: + return nil, errors.New("no execution payload envelope in phase0") + case DataVersionAltair: + return nil, errors.New("no execution payload envelope in altair") + case DataVersionBellatrix: + return nil, errors.New("no execution payload envelope in bellatrix") + case DataVersionCapella: + return nil, errors.New("no execution payload envelope in capella") + case DataVersionDeneb: + return nil, errors.New("no execution payload envelope in deneb") + case DataVersionElectra: + return nil, errors.New("no execution payload envelope in electra") + case DataVersionFulu: + return nil, errors.New("no execution payload envelope in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return nil, errors.New("no gloas signed execution payload envelope") + } + + return v.Gloas.Message, nil + default: + return nil, errors.New("unknown version") + } +} + +// Payload returns the execution payload contained in the signed envelope. +func (v *VersionedSignedExecutionPayloadEnvelope) Payload() (*gloas.ExecutionPayload, error) { + switch v.Version { + case DataVersionPhase0: + return nil, errors.New("no execution payload envelope in phase0") + case DataVersionAltair: + return nil, errors.New("no execution payload envelope in altair") + case DataVersionBellatrix: + return nil, errors.New("no execution payload envelope in bellatrix") + case DataVersionCapella: + return nil, errors.New("no execution payload envelope in capella") + case DataVersionDeneb: + return nil, errors.New("no execution payload envelope in deneb") + case DataVersionElectra: + return nil, errors.New("no execution payload envelope in electra") + case DataVersionFulu: + return nil, errors.New("no execution payload envelope in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return nil, errors.New("no gloas signed execution payload envelope") + } + + return v.Gloas.Message.Payload, nil + default: + return nil, errors.New("unknown version") + } +} + +// ExecutionRequests returns the execution requests contained in the signed envelope. +func (v *VersionedSignedExecutionPayloadEnvelope) ExecutionRequests() (*VersionedExecutionRequests, error) { + switch v.Version { + case DataVersionPhase0: + return nil, errors.New("no execution payload envelope in phase0") + case DataVersionAltair: + return nil, errors.New("no execution payload envelope in altair") + case DataVersionBellatrix: + return nil, errors.New("no execution payload envelope in bellatrix") + case DataVersionCapella: + return nil, errors.New("no execution payload envelope in capella") + case DataVersionDeneb: + return nil, errors.New("no execution payload envelope in deneb") + case DataVersionElectra: + return nil, errors.New("no execution payload envelope in electra") + case DataVersionFulu: + return nil, errors.New("no execution payload envelope in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return nil, errors.New("no gloas signed execution payload envelope") + } + + return &VersionedExecutionRequests{ + Version: DataVersionGloas, + Gloas: v.Gloas.Message.ExecutionRequests, + }, nil + default: + return nil, errors.New("unknown version") + } +} + +// BuilderIndex returns the builder index of the signed envelope. +func (v *VersionedSignedExecutionPayloadEnvelope) BuilderIndex() (gloas.BuilderIndex, error) { + switch v.Version { + case DataVersionPhase0: + return 0, errors.New("no execution payload envelope in phase0") + case DataVersionAltair: + return 0, errors.New("no execution payload envelope in altair") + case DataVersionBellatrix: + return 0, errors.New("no execution payload envelope in bellatrix") + case DataVersionCapella: + return 0, errors.New("no execution payload envelope in capella") + case DataVersionDeneb: + return 0, errors.New("no execution payload envelope in deneb") + case DataVersionElectra: + return 0, errors.New("no execution payload envelope in electra") + case DataVersionFulu: + return 0, errors.New("no execution payload envelope in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return 0, errors.New("no gloas signed execution payload envelope") + } + + return v.Gloas.Message.BuilderIndex, nil + default: + return 0, errors.New("unknown version") + } +} + +// BeaconBlockRoot returns the beacon block root of the signed envelope. +func (v *VersionedSignedExecutionPayloadEnvelope) BeaconBlockRoot() (phase0.Root, error) { + switch v.Version { + case DataVersionPhase0: + return phase0.Root{}, errors.New("no execution payload envelope in phase0") + case DataVersionAltair: + return phase0.Root{}, errors.New("no execution payload envelope in altair") + case DataVersionBellatrix: + return phase0.Root{}, errors.New("no execution payload envelope in bellatrix") + case DataVersionCapella: + return phase0.Root{}, errors.New("no execution payload envelope in capella") + case DataVersionDeneb: + return phase0.Root{}, errors.New("no execution payload envelope in deneb") + case DataVersionElectra: + return phase0.Root{}, errors.New("no execution payload envelope in electra") + case DataVersionFulu: + return phase0.Root{}, errors.New("no execution payload envelope in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return phase0.Root{}, errors.New("no gloas signed execution payload envelope") + } + + return v.Gloas.Message.BeaconBlockRoot, nil + default: + return phase0.Root{}, errors.New("unknown version") + } +} + +// ParentBeaconBlockRoot returns the parent beacon block root of the signed envelope. +func (v *VersionedSignedExecutionPayloadEnvelope) ParentBeaconBlockRoot() (phase0.Root, error) { + switch v.Version { + case DataVersionPhase0: + return phase0.Root{}, errors.New("no execution payload envelope in phase0") + case DataVersionAltair: + return phase0.Root{}, errors.New("no execution payload envelope in altair") + case DataVersionBellatrix: + return phase0.Root{}, errors.New("no execution payload envelope in bellatrix") + case DataVersionCapella: + return phase0.Root{}, errors.New("no execution payload envelope in capella") + case DataVersionDeneb: + return phase0.Root{}, errors.New("no execution payload envelope in deneb") + case DataVersionElectra: + return phase0.Root{}, errors.New("no execution payload envelope in electra") + case DataVersionFulu: + return phase0.Root{}, errors.New("no execution payload envelope in fulu") + case DataVersionGloas: + if v.Gloas == nil || v.Gloas.Message == nil { + return phase0.Root{}, errors.New("no gloas signed execution payload envelope") + } + + return v.Gloas.Message.ParentBeaconBlockRoot, nil + default: + return phase0.Root{}, errors.New("unknown version") + } +} + +// Signature returns the signature on the envelope. +func (v *VersionedSignedExecutionPayloadEnvelope) Signature() (phase0.BLSSignature, error) { + switch v.Version { + case DataVersionPhase0: + return phase0.BLSSignature{}, errors.New("no execution payload envelope in phase0") + case DataVersionAltair: + return phase0.BLSSignature{}, errors.New("no execution payload envelope in altair") + case DataVersionBellatrix: + return phase0.BLSSignature{}, errors.New("no execution payload envelope in bellatrix") + case DataVersionCapella: + return phase0.BLSSignature{}, errors.New("no execution payload envelope in capella") + case DataVersionDeneb: + return phase0.BLSSignature{}, errors.New("no execution payload envelope in deneb") + case DataVersionElectra: + return phase0.BLSSignature{}, errors.New("no execution payload envelope in electra") + case DataVersionFulu: + return phase0.BLSSignature{}, errors.New("no execution payload envelope in fulu") + case DataVersionGloas: + if v.Gloas == nil { + return phase0.BLSSignature{}, errors.New("no gloas signed execution payload envelope") + } + + return v.Gloas.Signature, nil + default: + return phase0.BLSSignature{}, errors.New("unknown version") + } +} diff --git a/spec/versionedsignedexecutionpayloadenvelope_test.go b/spec/versionedsignedexecutionpayloadenvelope_test.go new file mode 100644 index 00000000..7de2ae80 --- /dev/null +++ b/spec/versionedsignedexecutionpayloadenvelope_test.go @@ -0,0 +1,209 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spec_test + +import ( + "testing" + + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/holiman/uint256" + require "github.com/stretchr/testify/require" +) + +// TestVersionedSignedExecutionPayloadEnvelopeString verifies that stringifying the +// versioned wrapper does not panic when the innermost payload carries a nil +// BaseFeePerGas. String() delegates to the gloas envelope, whose YAML marshaler +// descends into ExecutionPayload.MarshalYAML, so the whole chain depends on that +// marshaler's nil guard. +func TestVersionedSignedExecutionPayloadEnvelopeString(t *testing.T) { + tests := []struct { + name string + envelope *spec.VersionedSignedExecutionPayloadEnvelope + // expected is a substring the rendering must contain. + expected string + }{ + { + // The zero value of DataVersion is DataVersionUnknown, not + // DataVersionPhase0, so a zero-value wrapper takes the default arm. + name: "ZeroValue", + envelope: &spec.VersionedSignedExecutionPayloadEnvelope{}, + expected: "unknown version", + }, + { + // The shape mock.Service.SignedExecutionPayloadEnvelope returns. + name: "GloasNilBaseFeePerGas", + envelope: &spec.VersionedSignedExecutionPayloadEnvelope{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedExecutionPayloadEnvelope{ + Message: &gloas.ExecutionPayloadEnvelope{ + Payload: &gloas.ExecutionPayload{}, + }, + }, + }, + expected: `base_fee_per_gas: '0'`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var str string + require.NotPanics(t, func() { + str = test.envelope.String() + }) + require.Contains(t, str, test.expected) + }) + } +} + +func TestVersionedSignedExecutionPayloadEnvelopeUnknownVersion(t *testing.T) { + _, err := (&spec.VersionedSignedExecutionPayloadEnvelope{ + Version: spec.DataVersion(255), + }).Payload() + require.EqualError(t, err, "unknown version") +} + +func TestVersionedSignedExecutionPayloadEnvelopeAccessors(t *testing.T) { + payload := &gloas.ExecutionPayload{BaseFeePerGas: uint256.NewInt(1)} + executionRequests := &gloas.ExecutionRequests{} + beaconBlockRoot := phase0.Root{0x01} + parentBeaconBlockRoot := phase0.Root{0x02} + message := &gloas.ExecutionPayloadEnvelope{ + Payload: payload, + ExecutionRequests: executionRequests, + BuilderIndex: 3, + BeaconBlockRoot: beaconBlockRoot, + ParentBeaconBlockRoot: parentBeaconBlockRoot, + } + signature := phase0.BLSSignature{0x04} + populated := &spec.VersionedSignedExecutionPayloadEnvelope{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedExecutionPayloadEnvelope{ + Message: message, + Signature: signature, + }, + } + gloasNoMessage := &spec.VersionedSignedExecutionPayloadEnvelope{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedExecutionPayloadEnvelope{}, + } + gloasNil := &spec.VersionedSignedExecutionPayloadEnvelope{Version: spec.DataVersionGloas} + tests := []struct { + name string + accessor func(*spec.VersionedSignedExecutionPayloadEnvelope) (any, error) + want any + missing *spec.VersionedSignedExecutionPayloadEnvelope + }{ + { + name: "Message", + accessor: func(v *spec.VersionedSignedExecutionPayloadEnvelope) (any, error) { + return v.Message() + }, + want: message, + missing: gloasNoMessage, + }, + { + name: "Payload", + accessor: func(v *spec.VersionedSignedExecutionPayloadEnvelope) (any, error) { + return v.Payload() + }, + want: payload, + missing: gloasNoMessage, + }, + { + name: "ExecutionRequests", + accessor: func(v *spec.VersionedSignedExecutionPayloadEnvelope) (any, error) { + return v.ExecutionRequests() + }, + want: &spec.VersionedExecutionRequests{ + Version: spec.DataVersionGloas, + Gloas: executionRequests, + }, + missing: gloasNoMessage, + }, + { + name: "BuilderIndex", + accessor: func(v *spec.VersionedSignedExecutionPayloadEnvelope) (any, error) { + return v.BuilderIndex() + }, + want: gloas.BuilderIndex(3), + missing: gloasNoMessage, + }, + { + name: "BeaconBlockRoot", + accessor: func(v *spec.VersionedSignedExecutionPayloadEnvelope) (any, error) { + return v.BeaconBlockRoot() + }, + want: beaconBlockRoot, + missing: gloasNoMessage, + }, + { + name: "ParentBeaconBlockRoot", + accessor: func(v *spec.VersionedSignedExecutionPayloadEnvelope) (any, error) { + return v.ParentBeaconBlockRoot() + }, + want: parentBeaconBlockRoot, + missing: gloasNoMessage, + }, + { + name: "Signature", + accessor: func(v *spec.VersionedSignedExecutionPayloadEnvelope) (any, error) { + return v.Signature() + }, + want: signature, + missing: gloasNil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := test.accessor(populated) + require.NoError(t, err) + require.Equal(t, test.want, got) + + _, err = test.accessor(test.missing) + require.EqualError(t, err, "no gloas signed execution payload envelope") + }) + } +} + +func TestVersionedSignedExecutionPayloadEnvelopeIsEmpty(t *testing.T) { + tests := []struct { + name string + envelope *spec.VersionedSignedExecutionPayloadEnvelope + expected bool + }{ + { + name: "Gloas", + envelope: &spec.VersionedSignedExecutionPayloadEnvelope{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedExecutionPayloadEnvelope{}, + }, + }, + { + name: "GloasNil", + envelope: &spec.VersionedSignedExecutionPayloadEnvelope{ + Version: spec.DataVersionGloas, + }, + expected: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.expected, test.envelope.IsEmpty()) + }) + } +} diff --git a/testclients/erroring.go b/testclients/erroring.go index 5d33b82b..af1d534f 100644 --- a/testclients/erroring.go +++ b/testclients/erroring.go @@ -1,4 +1,4 @@ -// Copyright © 2021 - 2025 Attestant Limited. +// Copyright © 2021 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -696,6 +696,25 @@ func (s *Erroring) ProposerDuties(ctx context.Context, return next.ProposerDuties(ctx, opts) } +// ProposerDutiesV2 obtains proposer duties for the given epoch using the v2 API. +func (s *Erroring) ProposerDutiesV2(ctx context.Context, + opts *api.ProposerDutiesOpts, +) ( + *api.Response[[]*apiv1.ProposerDuty], + error, +) { + if err := s.maybeError(ctx); err != nil { + return nil, err + } + + next, isNext := s.next.(consensusclient.ProposerDutiesV2Provider) + if !isNext { + return nil, fmt.Errorf("%s@%s does not support this call", s.next.Name(), s.next.Address()) + } + + return next.ProposerDutiesV2(ctx, opts) +} + // SyncCommittee fetches the sync committee for the given state. func (s *Erroring) SyncCommittee(ctx context.Context, opts *api.SyncCommitteeOpts, @@ -1096,3 +1115,158 @@ func (s *Erroring) PendingDeposits(ctx context.Context, return next.PendingDeposits(ctx, opts) } + +// SignedExecutionPayloadEnvelope fetches a signed execution payload envelope given a block ID. +func (s *Erroring) SignedExecutionPayloadEnvelope(ctx context.Context, + opts *api.SignedExecutionPayloadEnvelopeOpts, +) ( + *api.Response[*spec.VersionedSignedExecutionPayloadEnvelope], + error, +) { + if err := s.maybeError(ctx); err != nil { + return nil, err + } + + next, isNext := s.next.(consensusclient.ExecutionPayloadProvider) + if !isNext { + return nil, fmt.Errorf("%s@%s does not support this call", s.next.Name(), s.next.Address()) + } + + return next.SignedExecutionPayloadEnvelope(ctx, opts) +} + +// SubmitExecutionPayloadBid submits an execution payload bid. +func (s *Erroring) SubmitExecutionPayloadBid(ctx context.Context, opts *api.SubmitExecutionPayloadBidOpts) error { + if err := s.maybeError(ctx); err != nil { + return err + } + + next, isNext := s.next.(consensusclient.ExecutionPayloadBidSubmitter) + if !isNext { + return fmt.Errorf("%s@%s does not support this call", s.next.Name(), s.next.Address()) + } + + return next.SubmitExecutionPayloadBid(ctx, opts) +} + +// SubmitProposerPreferences submits signed proposer preferences. +func (s *Erroring) SubmitProposerPreferences(ctx context.Context, opts *api.SubmitProposerPreferencesOpts) error { + if err := s.maybeError(ctx); err != nil { + return err + } + next, ok := s.next.(consensusclient.ProposerPreferencesSubmitter) + if !ok { + return fmt.Errorf("%s@%s does not support this call", s.next.Name(), s.next.Address()) + } + + return next.SubmitProposerPreferences(ctx, opts) +} + +// SubmitExecutionPayloadEnvelope submits a signed execution payload envelope. +func (s *Erroring) SubmitExecutionPayloadEnvelope(ctx context.Context, opts *api.SubmitExecutionPayloadEnvelopeOpts) error { + if err := s.maybeError(ctx); err != nil { + return err + } + + next, isNext := s.next.(consensusclient.ExecutionPayloadEnvelopeSubmitter) + if !isNext { + return fmt.Errorf("%s@%s does not support this call", s.next.Name(), s.next.Address()) + } + + return next.SubmitExecutionPayloadEnvelope(ctx, opts) +} + +// EPBSProposal fetches an ePBS proposal for signing. +func (s *Erroring) EPBSProposal(ctx context.Context, + opts *api.EPBSProposalOpts, +) ( + *api.Response[*api.VersionedEPBSProposal], + error, +) { + if err := s.maybeError(ctx); err != nil { + return nil, err + } + + next, isNext := s.next.(consensusclient.EPBSProposalProvider) + if !isNext { + return nil, fmt.Errorf("%s@%s does not support this call", s.next.Name(), s.next.Address()) + } + + return next.EPBSProposal(ctx, opts) +} + +// ExecutionPayloadEnvelope obtains the cached execution payload envelope for the +// given slot and beacon block root. +func (s *Erroring) ExecutionPayloadEnvelope(ctx context.Context, + opts *api.ExecutionPayloadEnvelopeOpts, +) ( + *api.Response[*spec.VersionedExecutionPayloadEnvelope], + error, +) { + if err := s.maybeError(ctx); err != nil { + return nil, err + } + + next, isNext := s.next.(consensusclient.ExecutionPayloadEnvelopeProvider) + if !isNext { + return nil, fmt.Errorf("%s@%s does not support this call", s.next.Name(), s.next.Address()) + } + + return next.ExecutionPayloadEnvelope(ctx, opts) +} + +// PTCDuties obtains payload timeliness committee duties. +func (s *Erroring) PTCDuties(ctx context.Context, opts *api.PTCDutiesOpts) (*api.Response[[]*apiv1.PTCDuty], error) { + if err := s.maybeError(ctx); err != nil { + return nil, err + } + next, ok := s.next.(consensusclient.PTCDutiesProvider) + if !ok { + return nil, fmt.Errorf("%s@%s does not support this call", s.next.Name(), s.next.Address()) + } + + return next.PTCDuties(ctx, opts) +} + +// PayloadAttestationData obtains payload attestation data. +func (s *Erroring) PayloadAttestationData(ctx context.Context, + opts *api.PayloadAttestationDataOpts, +) (*api.Response[*spec.VersionedPayloadAttestationData], error) { + if err := s.maybeError(ctx); err != nil { + return nil, err + } + next, ok := s.next.(consensusclient.PayloadAttestationDataProvider) + if !ok { + return nil, fmt.Errorf("%s@%s does not support this call", s.next.Name(), s.next.Address()) + } + + return next.PayloadAttestationData(ctx, opts) +} + +// PayloadAttestationPool obtains the payload attestation pool. +func (s *Erroring) PayloadAttestationPool(ctx context.Context, + opts *api.PayloadAttestationPoolOpts, +) (*api.Response[[]*spec.VersionedPayloadAttestation], error) { + if err := s.maybeError(ctx); err != nil { + return nil, err + } + next, ok := s.next.(consensusclient.PayloadAttestationPoolProvider) + if !ok { + return nil, fmt.Errorf("%s@%s does not support this call", s.next.Name(), s.next.Address()) + } + + return next.PayloadAttestationPool(ctx, opts) +} + +// SubmitPayloadAttestationMessages submits payload attestation messages. +func (s *Erroring) SubmitPayloadAttestationMessages(ctx context.Context, opts *api.SubmitPayloadAttestationMessagesOpts) error { + if err := s.maybeError(ctx); err != nil { + return err + } + next, ok := s.next.(consensusclient.PayloadAttestationMessagesSubmitter) + if !ok { + return fmt.Errorf("%s@%s does not support this call", s.next.Name(), s.next.Address()) + } + + return next.SubmitPayloadAttestationMessages(ctx, opts) +} diff --git a/testclients/erroring_test.go b/testclients/erroring_test.go index 7b999376..28ddd53d 100644 --- a/testclients/erroring_test.go +++ b/testclients/erroring_test.go @@ -19,12 +19,35 @@ import ( consensusclient "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/api" + apiv1 "github.com/attestantio/go-eth2-client/api/v1" "github.com/attestantio/go-eth2-client/mock" "github.com/attestantio/go-eth2-client/testclients" "github.com/rs/zerolog" "github.com/stretchr/testify/require" ) +func TestErroringProposerDutiesV2(t *testing.T) { + ctx := context.Background() + expected := &api.Response[[]*apiv1.ProposerDuty]{ + Data: []*apiv1.ProposerDuty{{ValidatorIndex: 7}}, + } + + client, err := mock.New(ctx) + require.NoError(t, err) + client.ProposerDutiesV2Func = func(_ context.Context, _ *api.ProposerDutiesOpts) (*api.Response[[]*apiv1.ProposerDuty], error) { + return expected, nil + } + + service, err := testclients.NewErroring(ctx, 0, client) + require.NoError(t, err) + provider, supported := service.(consensusclient.ProposerDutiesV2Provider) + require.True(t, supported) + + response, err := provider.ProposerDutiesV2(ctx, &api.ProposerDutiesOpts{Epoch: 2}) + require.NoError(t, err) + require.Same(t, expected, response) +} + func TestErroringNew(t *testing.T) { ctx := context.Background() diff --git a/testclients/forkutil.go b/testclients/forkutil.go new file mode 100644 index 00000000..c43aff38 --- /dev/null +++ b/testclients/forkutil.go @@ -0,0 +1,103 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package testclients + +import ( + "context" + "strings" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// unknownFork is what the helpers below report when the node cannot be asked. +const unknownFork = "unknown" + +// nodeSpec fetches the node's configuration, or nil if it cannot be obtained. +func nodeSpec(ctx context.Context, service any) map[string]any { + provider, isProvider := service.(client.SpecProvider) + if !isProvider { + return nil + } + response, err := provider.Spec(ctx, &api.SpecOpts{}) + if err != nil || response == nil || response.Data == nil { + return nil + } + + return response.Data +} + +// headForkVersion fetches the fork version in force at the chain head. +func headForkVersion(ctx context.Context, service any) (phase0.Version, bool) { + provider, isProvider := service.(client.ForkProvider) + if !isProvider { + return phase0.Version{}, false + } + response, err := provider.Fork(ctx, &api.ForkOpts{State: "head"}) + if err != nil || response == nil || response.Data == nil { + return phase0.Version{}, false + } + + return response.Data.CurrentVersion, true +} + +// KnowsGloas reports whether the node's configuration includes the Gloas fork. +func KnowsGloas(ctx context.Context, service any) bool { + config := nodeSpec(ctx, service) + if config == nil { + return false + } + _, isPresent := config["GLOAS_FORK_EPOCH"] + + return isPresent +} + +// OnGloas reports whether the chain head is in the Gloas fork. +// +// The head's fork version is compared against GLOAS_FORK_VERSION rather than the +// head block being fetched and its version read: a block fetched over SSZ by a +// service without custom spec support cannot be decoded at all on a non-mainnet +// preset, which would report every Gloas chain as not being on Gloas. +func OnGloas(ctx context.Context, service any) bool { + head, haveHead := headForkVersion(ctx, service) + if !haveHead { + return false + } + + gloas, isVersion := nodeSpec(ctx, service)["GLOAS_FORK_VERSION"].(phase0.Version) + + return isVersion && head == gloas +} + +// HeadVersion returns the name of the fork in force at the chain head, or +// "unknown". It is diagnostic only: use OnGloas to decide. +func HeadVersion(ctx context.Context, service any) string { + head, haveHead := headForkVersion(ctx, service) + if !haveHead { + return unknownFork + } + + for key, value := range nodeSpec(ctx, service) { + version, isVersion := value.(phase0.Version) + if !isVersion || version != head { + continue + } + if name, isForkVersion := strings.CutSuffix(key, "_FORK_VERSION"); isForkVersion { + return strings.ToLower(name) + } + } + + return unknownFork +} diff --git a/testclients/forkutil_test.go b/testclients/forkutil_test.go new file mode 100644 index 00000000..62fdf360 --- /dev/null +++ b/testclients/forkutil_test.go @@ -0,0 +1,78 @@ +package testclients + +import ( + "context" + "testing" + + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +type forkutilTestService struct { + specData map[string]any + head phase0.Version +} + +func (s *forkutilTestService) Spec(context.Context, *api.SpecOpts) (*api.Response[map[string]any], error) { + return &api.Response[map[string]any]{Data: s.specData}, nil +} + +func (s *forkutilTestService) Fork(context.Context, *api.ForkOpts) (*api.Response[*phase0.Fork], error) { + return &api.Response[*phase0.Fork]{Data: &phase0.Fork{CurrentVersion: s.head}}, nil +} + +// gloasConfig is the shape a node that knows Gloas reports. +func gloasConfig() map[string]any { + return map[string]any{ + "GLOAS_FORK_EPOCH": uint64(10), + "GLOAS_FORK_VERSION": phase0.Version{0x70, 0x00, 0x00, 0x00}, + "FULU_FORK_VERSION": phase0.Version{0x60, 0x00, 0x00, 0x00}, + } +} + +func TestForkPosture(t *testing.T) { + tests := []struct { + name string + service any + knowsGloas bool + onGloas bool + head string + }{ + { + name: "GloasHeadAndConfig", + service: &forkutilTestService{specData: gloasConfig(), head: phase0.Version{0x70, 0x00, 0x00, 0x00}}, + knowsGloas: true, + onGloas: true, + head: "gloas", + }, + { + name: "PreGloasHeadStillKnows", + service: &forkutilTestService{specData: gloasConfig(), head: phase0.Version{0x60, 0x00, 0x00, 0x00}}, + knowsGloas: true, + onGloas: false, + head: "fulu", + }, + { + // A node whose head is on a fork its own config does not name. + name: "UnnamedHeadFork", + service: &forkutilTestService{specData: gloasConfig(), head: phase0.Version{0xff}}, + // GLOAS_FORK_EPOCH is still published, so the config knows Gloas. + knowsGloas: true, + head: "unknown", + }, + { + name: "UnknownService", + service: "not a service", + head: "unknown", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.knowsGloas, KnowsGloas(context.Background(), test.service)) + require.Equal(t, test.onGloas, OnGloas(context.Background(), test.service)) + require.Equal(t, test.head, HeadVersion(context.Background(), test.service)) + }) + } +} diff --git a/testclients/sleepy.go b/testclients/sleepy.go index e37ff0ef..92edd102 100644 --- a/testclients/sleepy.go +++ b/testclients/sleepy.go @@ -1,4 +1,4 @@ -// Copyright © 2021 - 2023 Attestant Limited. +// Copyright © 2021 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -545,6 +545,23 @@ func (s *Sleepy) ProposerDuties(ctx context.Context, return next.ProposerDuties(ctx, opts) } +// ProposerDutiesV2 obtains proposer duties for the given epoch using the v2 API. +func (s *Sleepy) ProposerDutiesV2(ctx context.Context, + opts *api.ProposerDutiesOpts, +) ( + *api.Response[[]*apiv1.ProposerDuty], + error, +) { + s.sleep(ctx) + + next, isNext := s.next.(consensusclient.ProposerDutiesV2Provider) + if !isNext { + return nil, errors.New("next does not support this call") + } + + return next.ProposerDutiesV2(ctx, opts) +} + // Spec provides the spec information of the chain. func (s *Sleepy) Spec(ctx context.Context, opts *api.SpecOpts, @@ -832,3 +849,138 @@ func (s *Sleepy) PendingPartialWithdrawals(ctx context.Context, return next.PendingPartialWithdrawals(ctx, opts) } + +// SignedExecutionPayloadEnvelope fetches a signed execution payload envelope given a block ID. +func (s *Sleepy) SignedExecutionPayloadEnvelope(ctx context.Context, + opts *api.SignedExecutionPayloadEnvelopeOpts, +) ( + *api.Response[*spec.VersionedSignedExecutionPayloadEnvelope], + error, +) { + s.sleep(ctx) + + next, isNext := s.next.(consensusclient.ExecutionPayloadProvider) + if !isNext { + return nil, errors.New("next does not support this call") + } + + return next.SignedExecutionPayloadEnvelope(ctx, opts) +} + +// SubmitExecutionPayloadBid submits an execution payload bid. +func (s *Sleepy) SubmitExecutionPayloadBid(ctx context.Context, opts *api.SubmitExecutionPayloadBidOpts) error { + s.sleep(ctx) + + next, isNext := s.next.(consensusclient.ExecutionPayloadBidSubmitter) + if !isNext { + return errors.New("next does not support this call") + } + + return next.SubmitExecutionPayloadBid(ctx, opts) +} + +// SubmitProposerPreferences submits signed proposer preferences. +func (s *Sleepy) SubmitProposerPreferences(ctx context.Context, opts *api.SubmitProposerPreferencesOpts) error { + s.sleep(ctx) + next, ok := s.next.(consensusclient.ProposerPreferencesSubmitter) + if !ok { + return errors.New("next does not support this call") + } + + return next.SubmitProposerPreferences(ctx, opts) +} + +// SubmitExecutionPayloadEnvelope submits a signed execution payload envelope. +func (s *Sleepy) SubmitExecutionPayloadEnvelope(ctx context.Context, opts *api.SubmitExecutionPayloadEnvelopeOpts) error { + s.sleep(ctx) + + next, isNext := s.next.(consensusclient.ExecutionPayloadEnvelopeSubmitter) + if !isNext { + return errors.New("next does not support this call") + } + + return next.SubmitExecutionPayloadEnvelope(ctx, opts) +} + +// EPBSProposal fetches an ePBS proposal for signing. +func (s *Sleepy) EPBSProposal(ctx context.Context, + opts *api.EPBSProposalOpts, +) ( + *api.Response[*api.VersionedEPBSProposal], + error, +) { + s.sleep(ctx) + + next, isNext := s.next.(consensusclient.EPBSProposalProvider) + if !isNext { + return nil, errors.New("next does not support this call") + } + + return next.EPBSProposal(ctx, opts) +} + +// ExecutionPayloadEnvelope obtains the cached execution payload envelope for the +// given slot and beacon block root. +func (s *Sleepy) ExecutionPayloadEnvelope(ctx context.Context, + opts *api.ExecutionPayloadEnvelopeOpts, +) ( + *api.Response[*spec.VersionedExecutionPayloadEnvelope], + error, +) { + s.sleep(ctx) + + next, isNext := s.next.(consensusclient.ExecutionPayloadEnvelopeProvider) + if !isNext { + return nil, errors.New("next does not support this call") + } + + return next.ExecutionPayloadEnvelope(ctx, opts) +} + +// PTCDuties obtains payload timeliness committee duties. +func (s *Sleepy) PTCDuties(ctx context.Context, opts *api.PTCDutiesOpts) (*api.Response[[]*apiv1.PTCDuty], error) { + s.sleep(ctx) + next, ok := s.next.(consensusclient.PTCDutiesProvider) + if !ok { + return nil, errors.New("next does not support this call") + } + + return next.PTCDuties(ctx, opts) +} + +// PayloadAttestationData obtains payload attestation data. +func (s *Sleepy) PayloadAttestationData(ctx context.Context, + opts *api.PayloadAttestationDataOpts, +) (*api.Response[*spec.VersionedPayloadAttestationData], error) { + s.sleep(ctx) + next, ok := s.next.(consensusclient.PayloadAttestationDataProvider) + if !ok { + return nil, errors.New("next does not support this call") + } + + return next.PayloadAttestationData(ctx, opts) +} + +// PayloadAttestationPool obtains the payload attestation pool. +func (s *Sleepy) PayloadAttestationPool(ctx context.Context, + opts *api.PayloadAttestationPoolOpts, +) (*api.Response[[]*spec.VersionedPayloadAttestation], error) { + s.sleep(ctx) + next, ok := s.next.(consensusclient.PayloadAttestationPoolProvider) + if !ok { + return nil, errors.New("next does not support this call") + } + + return next.PayloadAttestationPool(ctx, opts) +} + +// SubmitPayloadAttestationMessages submits payload attestation messages. +func (s *Sleepy) SubmitPayloadAttestationMessages(ctx context.Context, opts *api.SubmitPayloadAttestationMessagesOpts) error { + s.sleep(ctx) + next, ok := s.next.(consensusclient.PayloadAttestationMessagesSubmitter) + if !ok { + return errors.New("next does not support this call") + } + + return next.SubmitPayloadAttestationMessages(ctx, opts) +} diff --git a/testclients/sleepy_test.go b/testclients/sleepy_test.go index 07f48c2c..09fd4301 100644 --- a/testclients/sleepy_test.go +++ b/testclients/sleepy_test.go @@ -20,12 +20,35 @@ import ( consensusclient "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/api" + apiv1 "github.com/attestantio/go-eth2-client/api/v1" "github.com/attestantio/go-eth2-client/mock" "github.com/attestantio/go-eth2-client/testclients" "github.com/rs/zerolog" "github.com/stretchr/testify/require" ) +func TestSleepyProposerDutiesV2(t *testing.T) { + ctx := context.Background() + expected := &api.Response[[]*apiv1.ProposerDuty]{ + Data: []*apiv1.ProposerDuty{{ValidatorIndex: 7}}, + } + + client, err := mock.New(ctx) + require.NoError(t, err) + client.ProposerDutiesV2Func = func(_ context.Context, _ *api.ProposerDutiesOpts) (*api.Response[[]*apiv1.ProposerDuty], error) { + return expected, nil + } + + service, err := testclients.NewSleepy(ctx, time.Millisecond, 2*time.Millisecond, client) + require.NoError(t, err) + provider, supported := service.(consensusclient.ProposerDutiesV2Provider) + require.True(t, supported) + + response, err := provider.ProposerDutiesV2(ctx, &api.ProposerDutiesOpts{Epoch: 2}) + require.NoError(t, err) + require.Same(t, expected, response) +} + func TestSleepyNew(t *testing.T) { ctx := context.Background() From e57e2d4ff868bdcedd3535ad5f3df97fb7b285ed Mon Sep 17 00:00:00 2001 From: Chris Berry <4602753+Bez625@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:02:57 +0100 Subject: [PATCH 2/5] Update changelog and version. (#352) * 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> --- CHANGELOG.md | 23 ++++++++++++++++++++++- http/http.go | 2 +- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4dca405..a4136293 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,25 @@ -dev: +0.30.0: + - support Gloas consensus types and versioned helpers + - add execution payload envelope retrieval and bid/envelope submission APIs + - add payload timeliness committee duties and payload attestation APIs + - add MultiForkProposalProvider for proposal production across the Gloas fork + - support Gloas attestation hashing, submission and payload-availability votes + - fix attestation-pool decoding and validation across forks + - honor custom SSZ presets when submitting proposals + - add mock SubmitBLSToExecutionChanges + - handle invalid DataVersion values without panicking + - bound HTTP response sizes for POSTs and Gloas fetch endpoints + - add SubmitProposerPreferences with JSON and SSZ support in http, multi and mock clients + - update ePBS block production to POST /eth/v4/validator/blocks/{slot} with a required EPBSProposalOpts.BuilderConfig + - add Gloas BuilderConfig, BuilderEntry, BuilderRequestAuth and SignedBuilderRequestAuth types with JSON, YAML and SSZ support + - add VersionedEPBSProposal.BuilderIndex and SubmitProposalOpts.BuilderURL for direct-builder proposal routing + - validate ePBS bid policy and execution-value metadata, including minimum bids and execution-payment caps + - add head_v2 events and HeadV2Handler, including fork version, payload status and epoch-dependent roots + - add light_client_finality_update and light_client_optimistic_update events through the generic event handler + - add ProposerDutiesV2Provider and ProposerDutiesV2 in http, multi and mock clients + - use /eth/v2/validator/duties/proposer/{epoch} and its dependent-root semantics with head_v2; existing v1 behavior is unchanged + - fix multi event subscriptions to preserve topics and handlers, forward only the active client's events, and continue retrying failed subscriptions + - encode empty Gloas execution-payload transactions as "0x" in JSON and YAML, and accept them on JSON decode - add Gloas events: execution_payload, execution_payload_gossip, execution_payload_available, execution_payload_bid, payload_attestation_message, proposer_preferences and fast_confirmation - add http.ValidateEventsOpts, the events options check shared by the http and multi clients - Event.UnmarshalJSON decodes attester_slashing data as electra.AttesterSlashing rather than phase0.AttesterSlashing, matching the events stream diff --git a/http/http.go b/http/http.go index a8936f39..cc97ccb4 100644 --- a/http/http.go +++ b/http/http.go @@ -35,7 +35,7 @@ import ( ) // defaultUserAgent is sent with requests if no other user agent has been supplied. -const defaultUserAgent = "go-eth2-client/0.29.0" +const defaultUserAgent = "go-eth2-client/0.30.0" // post sends an HTTP post request and returns the body. func (s *Service) post(ctx context.Context, From 7eea79bc169ec987ec68a490618e16a02170e4df Mon Sep 17 00:00:00 2001 From: pk910 Date: Wed, 7 Oct 2026 17:23:27 +0200 Subject: [PATCH 3/5] lint: compute new issues against the merge base and clean fork-owned 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. --- .github/workflows/golangci-lint.yml | 9 +++- .golangci.yml | 12 ----- api/v1/all/blindedbeaconblock.go | 8 +++ api/v1/all/blindedbeaconblockbody.go | 8 +++ api/v1/all/blockcontents.go | 8 +++ api/v1/all/jsonyaml.go | 2 +- api/v1/all/signedblindedbeaconblock.go | 8 +++ api/v1/all/signedblockcontents.go | 8 +++ .../signedexecutionpayloadenvelopecontents.go | 8 +++ spec/all/aggregateandproof.go | 8 +++ spec/all/attestation.go | 8 +++ spec/all/attesterslashing.go | 8 +++ spec/all/beaconblock.go | 8 +++ spec/all/beaconblockbody.go | 8 +++ spec/all/beaconstate.go | 8 +++ spec/all/executionpayload.go | 8 +++ spec/all/executionpayloadbid.go | 8 +++ spec/all/executionpayloadenvelope.go | 8 +++ spec/all/executionpayloadheader.go | 8 +++ spec/all/executionrequests.go | 8 +++ spec/all/indexedattestation.go | 8 +++ spec/all/jsonyaml.go | 2 +- spec/all/signedaggregateandproof.go | 8 +++ spec/all/signedbeaconblock.go | 8 +++ spec/all/signedexecutionpayloadbid.go | 8 +++ spec/all/signedexecutionpayloadenvelope.go | 8 +++ spec/altair/syncaggregatorselectiondata.go | 2 +- spec/heze/beaconstate.go | 50 +++++++++---------- spec/heze/executionpayloadbid.go | 4 +- 29 files changed, 213 insertions(+), 44 deletions(-) diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 28533b9a..6bdc0101 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -15,6 +15,9 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 + with: + # Full history so new issues can be computed against the merge base. + fetch-depth: 0 - uses: actions/setup-go@v5 with: @@ -48,6 +51,8 @@ jobs: uses: golangci/golangci-lint-action@v8 with: install-mode: "none" - args: "--timeout=60m" - only-new-issues: true + # Compute new issues locally: the action's own patch fetch fails on pull + # requests whose diff exceeds GitHub's 20000-line limit. + args: "--timeout=60m ${{ github.event_name == 'pull_request' && '--new-from-merge-base=origin/master' || '--new-from-rev=HEAD~1' }}" + only-new-issues: false skip-cache: true diff --git a/.golangci.yml b/.golangci.yml index 204a84b3..8367a1f7 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -142,18 +142,6 @@ linters: - all - -ST1000 - # tagalign: Struct tag alignment and ordering - tagalign: - # Don't column-align tags; alignment padding would push the SSZ tag lines - # past the revive line-length-limit (160). Single-space separation is used. - align: false - # Enforce tag key ordering. ssz-index stays first (it defines the field - # position for EIP-7688 progressive containers); remaining keys follow - # alphabetically. - sort: true - order: - - ssz-index - # tagliatelle: Struct tag naming conventions tagliatelle: case: diff --git a/api/v1/all/blindedbeaconblock.go b/api/v1/all/blindedbeaconblock.go index ce60fdc4..d29dd366 100644 --- a/api/v1/all/blindedbeaconblock.go +++ b/api/v1/all/blindedbeaconblock.go @@ -281,3 +281,11 @@ func (b *BlindedBeaconBlock) UnmarshalYAML(data []byte) error { return nil } + +// Compile-time checks of the view conversions the struct takes part in. +var ( + _ viewProvider = (*BlindedBeaconBlock)(nil) + _ viewer = (*BlindedBeaconBlock)(nil) + _ fromViewer = (*BlindedBeaconBlock)(nil) + _ versionSetter = (*BlindedBeaconBlock)(nil) +) diff --git a/api/v1/all/blindedbeaconblockbody.go b/api/v1/all/blindedbeaconblockbody.go index ce4f918f..bfceeca9 100644 --- a/api/v1/all/blindedbeaconblockbody.go +++ b/api/v1/all/blindedbeaconblockbody.go @@ -278,3 +278,11 @@ func (b *BlindedBeaconBlockBody) UnmarshalYAML(data []byte) error { return nil } + +// Compile-time checks of the view conversions the struct takes part in. +var ( + _ viewProvider = (*BlindedBeaconBlockBody)(nil) + _ viewer = (*BlindedBeaconBlockBody)(nil) + _ fromViewer = (*BlindedBeaconBlockBody)(nil) + _ versionSetter = (*BlindedBeaconBlockBody)(nil) +) diff --git a/api/v1/all/blockcontents.go b/api/v1/all/blockcontents.go index b56dee29..9171a798 100644 --- a/api/v1/all/blockcontents.go +++ b/api/v1/all/blockcontents.go @@ -273,3 +273,11 @@ func (b *BlockContents) UnmarshalYAML(data []byte) error { return nil } + +// Compile-time checks of the view conversions the struct takes part in. +var ( + _ viewProvider = (*BlockContents)(nil) + _ viewer = (*BlockContents)(nil) + _ fromViewer = (*BlockContents)(nil) + _ versionSetter = (*BlockContents)(nil) +) diff --git a/api/v1/all/jsonyaml.go b/api/v1/all/jsonyaml.go index d84238f5..1630b7ea 100644 --- a/api/v1/all/jsonyaml.go +++ b/api/v1/all/jsonyaml.go @@ -142,7 +142,7 @@ func fromVersioned(dst fromViewer, src any) error { return fmt.Errorf("fromVersioned: %T has no Version field", src) } - v, ok := versionField.Interface().(version.DataVersion) + v, ok := reflect.TypeAssert[version.DataVersion](versionField) if !ok { return fmt.Errorf("fromVersioned: Version field on %T is %T not version.DataVersion", src, versionField.Interface()) diff --git a/api/v1/all/signedblindedbeaconblock.go b/api/v1/all/signedblindedbeaconblock.go index 4974cab0..2a908841 100644 --- a/api/v1/all/signedblindedbeaconblock.go +++ b/api/v1/all/signedblindedbeaconblock.go @@ -277,3 +277,11 @@ func (s *SignedBlindedBeaconBlock) UnmarshalYAML(data []byte) error { return nil } + +// Compile-time checks of the view conversions the struct takes part in. +var ( + _ viewProvider = (*SignedBlindedBeaconBlock)(nil) + _ viewer = (*SignedBlindedBeaconBlock)(nil) + _ fromViewer = (*SignedBlindedBeaconBlock)(nil) + _ versionSetter = (*SignedBlindedBeaconBlock)(nil) +) diff --git a/api/v1/all/signedblockcontents.go b/api/v1/all/signedblockcontents.go index 0e09c114..77bcca6d 100644 --- a/api/v1/all/signedblockcontents.go +++ b/api/v1/all/signedblockcontents.go @@ -276,3 +276,11 @@ func (s *SignedBlockContents) UnmarshalYAML(data []byte) error { return nil } + +// Compile-time checks of the view conversions the struct takes part in. +var ( + _ viewProvider = (*SignedBlockContents)(nil) + _ viewer = (*SignedBlockContents)(nil) + _ fromViewer = (*SignedBlockContents)(nil) + _ versionSetter = (*SignedBlockContents)(nil) +) diff --git a/api/v1/all/signedexecutionpayloadenvelopecontents.go b/api/v1/all/signedexecutionpayloadenvelopecontents.go index b33dda52..ad5af8b4 100644 --- a/api/v1/all/signedexecutionpayloadenvelopecontents.go +++ b/api/v1/all/signedexecutionpayloadenvelopecontents.go @@ -252,3 +252,11 @@ func (s *SignedExecutionPayloadEnvelopeContents) UnmarshalYAML(data []byte) erro return nil } + +// Compile-time checks of the view conversions the struct takes part in. +var ( + _ viewProvider = (*SignedExecutionPayloadEnvelopeContents)(nil) + _ viewer = (*SignedExecutionPayloadEnvelopeContents)(nil) + _ fromViewer = (*SignedExecutionPayloadEnvelopeContents)(nil) + _ versionSetter = (*SignedExecutionPayloadEnvelopeContents)(nil) +) diff --git a/spec/all/aggregateandproof.go b/spec/all/aggregateandproof.go index 9591bb6a..cf8fd522 100644 --- a/spec/all/aggregateandproof.go +++ b/spec/all/aggregateandproof.go @@ -273,3 +273,11 @@ func (a *AggregateAndProof) UnmarshalYAML(data []byte) error { return nil } + +// Compile-time checks of the view conversions the struct takes part in. +var ( + _ viewProvider = (*AggregateAndProof)(nil) + _ viewer = (*AggregateAndProof)(nil) + _ fromViewer = (*AggregateAndProof)(nil) + _ versionSetter = (*AggregateAndProof)(nil) +) diff --git a/spec/all/attestation.go b/spec/all/attestation.go index 050ff1a5..705cb04d 100644 --- a/spec/all/attestation.go +++ b/spec/all/attestation.go @@ -274,3 +274,11 @@ func (a *Attestation) UnmarshalYAML(data []byte) error { return nil } + +// Compile-time checks of the view conversions the struct takes part in. +var ( + _ viewProvider = (*Attestation)(nil) + _ viewer = (*Attestation)(nil) + _ fromViewer = (*Attestation)(nil) + _ versionSetter = (*Attestation)(nil) +) diff --git a/spec/all/attesterslashing.go b/spec/all/attesterslashing.go index 790ec50f..a95b050e 100644 --- a/spec/all/attesterslashing.go +++ b/spec/all/attesterslashing.go @@ -275,3 +275,11 @@ func (a *AttesterSlashing) UnmarshalYAML(data []byte) error { return nil } + +// Compile-time checks of the view conversions the struct takes part in. +var ( + _ viewProvider = (*AttesterSlashing)(nil) + _ viewer = (*AttesterSlashing)(nil) + _ fromViewer = (*AttesterSlashing)(nil) + _ versionSetter = (*AttesterSlashing)(nil) +) diff --git a/spec/all/beaconblock.go b/spec/all/beaconblock.go index 8cf3419f..523ed357 100644 --- a/spec/all/beaconblock.go +++ b/spec/all/beaconblock.go @@ -295,3 +295,11 @@ func (b *BeaconBlock) UnmarshalYAML(data []byte) error { return nil } + +// Compile-time checks of the view conversions the struct takes part in. +var ( + _ viewProvider = (*BeaconBlock)(nil) + _ viewer = (*BeaconBlock)(nil) + _ fromViewer = (*BeaconBlock)(nil) + _ versionSetter = (*BeaconBlock)(nil) +) diff --git a/spec/all/beaconblockbody.go b/spec/all/beaconblockbody.go index 987af891..52fbbf08 100644 --- a/spec/all/beaconblockbody.go +++ b/spec/all/beaconblockbody.go @@ -335,3 +335,11 @@ func (b *BeaconBlockBody) UnmarshalYAML(data []byte) error { return nil } + +// Compile-time checks of the view conversions the struct takes part in. +var ( + _ viewProvider = (*BeaconBlockBody)(nil) + _ viewer = (*BeaconBlockBody)(nil) + _ fromViewer = (*BeaconBlockBody)(nil) + _ versionSetter = (*BeaconBlockBody)(nil) +) diff --git a/spec/all/beaconstate.go b/spec/all/beaconstate.go index eda43819..6b34ee3c 100644 --- a/spec/all/beaconstate.go +++ b/spec/all/beaconstate.go @@ -346,3 +346,11 @@ func (b *BeaconState) UnmarshalYAML(data []byte) error { return nil } + +// Compile-time checks of the view conversions the struct takes part in. +var ( + _ viewProvider = (*BeaconState)(nil) + _ viewer = (*BeaconState)(nil) + _ fromViewer = (*BeaconState)(nil) + _ versionSetter = (*BeaconState)(nil) +) diff --git a/spec/all/executionpayload.go b/spec/all/executionpayload.go index c65dac15..a72cab9c 100644 --- a/spec/all/executionpayload.go +++ b/spec/all/executionpayload.go @@ -293,3 +293,11 @@ func (e *ExecutionPayload) UnmarshalYAML(data []byte) error { return nil } + +// Compile-time checks of the view conversions the struct takes part in. +var ( + _ viewProvider = (*ExecutionPayload)(nil) + _ viewer = (*ExecutionPayload)(nil) + _ fromViewer = (*ExecutionPayload)(nil) + _ versionSetter = (*ExecutionPayload)(nil) +) diff --git a/spec/all/executionpayloadbid.go b/spec/all/executionpayloadbid.go index 1e8ddd69..d2791284 100644 --- a/spec/all/executionpayloadbid.go +++ b/spec/all/executionpayloadbid.go @@ -257,3 +257,11 @@ func (e *ExecutionPayloadBid) UnmarshalYAML(data []byte) error { return nil } + +// Compile-time checks of the view conversions the struct takes part in. +var ( + _ viewProvider = (*ExecutionPayloadBid)(nil) + _ viewer = (*ExecutionPayloadBid)(nil) + _ fromViewer = (*ExecutionPayloadBid)(nil) + _ versionSetter = (*ExecutionPayloadBid)(nil) +) diff --git a/spec/all/executionpayloadenvelope.go b/spec/all/executionpayloadenvelope.go index 0c3bfa06..eb2922f7 100644 --- a/spec/all/executionpayloadenvelope.go +++ b/spec/all/executionpayloadenvelope.go @@ -268,3 +268,11 @@ func (e *ExecutionPayloadEnvelope) ToVersioned() (*spec.VersionedExecutionPayloa func (e *ExecutionPayloadEnvelope) FromVersioned(src *spec.VersionedExecutionPayloadEnvelope) error { return fromVersioned(e, src) } + +// Compile-time checks of the view conversions the struct takes part in. +var ( + _ viewProvider = (*ExecutionPayloadEnvelope)(nil) + _ viewer = (*ExecutionPayloadEnvelope)(nil) + _ fromViewer = (*ExecutionPayloadEnvelope)(nil) + _ versionSetter = (*ExecutionPayloadEnvelope)(nil) +) diff --git a/spec/all/executionpayloadheader.go b/spec/all/executionpayloadheader.go index 30023018..23719b91 100644 --- a/spec/all/executionpayloadheader.go +++ b/spec/all/executionpayloadheader.go @@ -268,3 +268,11 @@ func (e *ExecutionPayloadHeader) UnmarshalYAML(data []byte) error { return nil } + +// Compile-time checks of the view conversions the struct takes part in. +var ( + _ viewProvider = (*ExecutionPayloadHeader)(nil) + _ viewer = (*ExecutionPayloadHeader)(nil) + _ fromViewer = (*ExecutionPayloadHeader)(nil) + _ versionSetter = (*ExecutionPayloadHeader)(nil) +) diff --git a/spec/all/executionrequests.go b/spec/all/executionrequests.go index 852208ff..e2a187ff 100644 --- a/spec/all/executionrequests.go +++ b/spec/all/executionrequests.go @@ -263,3 +263,11 @@ func (e *ExecutionRequests) UnmarshalYAML(data []byte) error { return nil } + +// Compile-time checks of the view conversions the struct takes part in. +var ( + _ viewProvider = (*ExecutionRequests)(nil) + _ viewer = (*ExecutionRequests)(nil) + _ fromViewer = (*ExecutionRequests)(nil) + _ versionSetter = (*ExecutionRequests)(nil) +) diff --git a/spec/all/indexedattestation.go b/spec/all/indexedattestation.go index 69d285a2..216ccb8d 100644 --- a/spec/all/indexedattestation.go +++ b/spec/all/indexedattestation.go @@ -269,3 +269,11 @@ func (i *IndexedAttestation) UnmarshalYAML(data []byte) error { return nil } + +// Compile-time checks of the view conversions the struct takes part in. +var ( + _ viewProvider = (*IndexedAttestation)(nil) + _ viewer = (*IndexedAttestation)(nil) + _ fromViewer = (*IndexedAttestation)(nil) + _ versionSetter = (*IndexedAttestation)(nil) +) diff --git a/spec/all/jsonyaml.go b/spec/all/jsonyaml.go index 840e2835..87849fd5 100644 --- a/spec/all/jsonyaml.go +++ b/spec/all/jsonyaml.go @@ -143,7 +143,7 @@ func fromVersioned(dst fromViewer, src any) error { return fmt.Errorf("fromVersioned: %T has no Version field", src) } - v, ok := versionField.Interface().(version.DataVersion) + v, ok := reflect.TypeAssert[version.DataVersion](versionField) if !ok { return fmt.Errorf("fromVersioned: Version field on %T is %T not version.DataVersion", src, versionField.Interface()) diff --git a/spec/all/signedaggregateandproof.go b/spec/all/signedaggregateandproof.go index 5b3c19c0..9f6d79b4 100644 --- a/spec/all/signedaggregateandproof.go +++ b/spec/all/signedaggregateandproof.go @@ -270,3 +270,11 @@ func (s *SignedAggregateAndProof) UnmarshalYAML(data []byte) error { return nil } + +// Compile-time checks of the view conversions the struct takes part in. +var ( + _ viewProvider = (*SignedAggregateAndProof)(nil) + _ viewer = (*SignedAggregateAndProof)(nil) + _ fromViewer = (*SignedAggregateAndProof)(nil) + _ versionSetter = (*SignedAggregateAndProof)(nil) +) diff --git a/spec/all/signedbeaconblock.go b/spec/all/signedbeaconblock.go index e13f265a..8a283825 100644 --- a/spec/all/signedbeaconblock.go +++ b/spec/all/signedbeaconblock.go @@ -291,3 +291,11 @@ func (s *SignedBeaconBlock) UnmarshalYAML(data []byte) error { return nil } + +// Compile-time checks of the view conversions the struct takes part in. +var ( + _ viewProvider = (*SignedBeaconBlock)(nil) + _ viewer = (*SignedBeaconBlock)(nil) + _ fromViewer = (*SignedBeaconBlock)(nil) + _ versionSetter = (*SignedBeaconBlock)(nil) +) diff --git a/spec/all/signedexecutionpayloadbid.go b/spec/all/signedexecutionpayloadbid.go index eba73591..051ea92b 100644 --- a/spec/all/signedexecutionpayloadbid.go +++ b/spec/all/signedexecutionpayloadbid.go @@ -260,3 +260,11 @@ func (s *SignedExecutionPayloadBid) UnmarshalYAML(data []byte) error { return nil } + +// Compile-time checks of the view conversions the struct takes part in. +var ( + _ viewProvider = (*SignedExecutionPayloadBid)(nil) + _ viewer = (*SignedExecutionPayloadBid)(nil) + _ fromViewer = (*SignedExecutionPayloadBid)(nil) + _ versionSetter = (*SignedExecutionPayloadBid)(nil) +) diff --git a/spec/all/signedexecutionpayloadenvelope.go b/spec/all/signedexecutionpayloadenvelope.go index 1b4fb417..4bf403a2 100644 --- a/spec/all/signedexecutionpayloadenvelope.go +++ b/spec/all/signedexecutionpayloadenvelope.go @@ -260,3 +260,11 @@ func (s *SignedExecutionPayloadEnvelope) ToVersioned() (*spec.VersionedSignedExe func (s *SignedExecutionPayloadEnvelope) FromVersioned(src *spec.VersionedSignedExecutionPayloadEnvelope) error { return fromVersioned(s, src) } + +// Compile-time checks of the view conversions the struct takes part in. +var ( + _ viewProvider = (*SignedExecutionPayloadEnvelope)(nil) + _ viewer = (*SignedExecutionPayloadEnvelope)(nil) + _ fromViewer = (*SignedExecutionPayloadEnvelope)(nil) + _ versionSetter = (*SignedExecutionPayloadEnvelope)(nil) +) diff --git a/spec/altair/syncaggregatorselectiondata.go b/spec/altair/syncaggregatorselectiondata.go index bf291313..dac85845 100644 --- a/spec/altair/syncaggregatorselectiondata.go +++ b/spec/altair/syncaggregatorselectiondata.go @@ -20,6 +20,6 @@ import ( // SyncAggregatorSelectionData is an internal struct for // sync committee selection proofs. type SyncAggregatorSelectionData struct { - Slot phase0.Slot `json:"slot" yaml:"slot"` + Slot phase0.Slot `json:"slot" yaml:"slot"` SubcommitteeIndex uint64 `json:"subcommittee_index" yaml:"subcommittee_index"` } diff --git a/spec/heze/beaconstate.go b/spec/heze/beaconstate.go index f730c316..33e65a6f 100644 --- a/spec/heze/beaconstate.go +++ b/spec/heze/beaconstate.go @@ -29,51 +29,51 @@ import ( // bid is the one extended by EIP-7805. type BeaconState struct { GenesisTime uint64 `ssz-index:"0"` - GenesisValidatorsRoot phase0.Root `ssz-index:"1" ssz-size:"32"` + GenesisValidatorsRoot phase0.Root `ssz-index:"1" ssz-size:"32"` Slot phase0.Slot `ssz-index:"2"` Fork *phase0.Fork `ssz-index:"3"` LatestBlockHeader *phase0.BeaconBlockHeader `ssz-index:"4"` - BlockRoots []phase0.Root `ssz-index:"5" dynssz-size:"SLOTS_PER_HISTORICAL_ROOT,32" ssz-size:"8192,32"` - StateRoots []phase0.Root `ssz-index:"6" dynssz-size:"SLOTS_PER_HISTORICAL_ROOT,32" ssz-size:"8192,32"` - HistoricalRoots []phase0.Root `ssz-index:"7" ssz-max:"16777216" ssz-size:"?,32"` + BlockRoots []phase0.Root `dynssz-size:"SLOTS_PER_HISTORICAL_ROOT,32" ssz-index:"5" ssz-size:"8192,32"` + StateRoots []phase0.Root `dynssz-size:"SLOTS_PER_HISTORICAL_ROOT,32" ssz-index:"6" ssz-size:"8192,32"` + HistoricalRoots []phase0.Root `ssz-index:"7" ssz-max:"16777216" ssz-size:"?,32"` ETH1Data *phase0.ETH1Data `ssz-index:"8"` - ETH1DataVotes []*phase0.ETH1Data `ssz-index:"9" dynssz-max:"EPOCHS_PER_ETH1_VOTING_PERIOD*SLOTS_PER_EPOCH" ssz-max:"2048"` + ETH1DataVotes []*phase0.ETH1Data `dynssz-max:"EPOCHS_PER_ETH1_VOTING_PERIOD*SLOTS_PER_EPOCH" ssz-index:"9" ssz-max:"2048"` ETH1DepositIndex uint64 `ssz-index:"10"` - Validators []*phase0.Validator `ssz-index:"11" ssz-type:"progressive-list"` - Balances []phase0.Gwei `ssz-index:"12" ssz-type:"progressive-list"` - RANDAOMixes []phase0.Root `ssz-index:"13" dynssz-size:"EPOCHS_PER_HISTORICAL_VECTOR,32" ssz-size:"65536,32"` - Slashings []phase0.Gwei `ssz-index:"14" dynssz-size:"EPOCHS_PER_SLASHINGS_VECTOR" ssz-size:"8192"` - PreviousEpochParticipation []altair.ParticipationFlags `ssz-index:"15" ssz-type:"progressive-list"` - CurrentEpochParticipation []altair.ParticipationFlags `ssz-index:"16" ssz-type:"progressive-list"` - JustificationBits bitfield.Bitvector4 `ssz-index:"17" ssz-size:"1"` + Validators []*phase0.Validator `ssz-index:"11" ssz-type:"progressive-list"` + Balances []phase0.Gwei `ssz-index:"12" ssz-type:"progressive-list"` + RANDAOMixes []phase0.Root `dynssz-size:"EPOCHS_PER_HISTORICAL_VECTOR,32" ssz-index:"13" ssz-size:"65536,32"` + Slashings []phase0.Gwei `dynssz-size:"EPOCHS_PER_SLASHINGS_VECTOR" ssz-index:"14" ssz-size:"8192"` + PreviousEpochParticipation []altair.ParticipationFlags `ssz-index:"15" ssz-type:"progressive-list"` + CurrentEpochParticipation []altair.ParticipationFlags `ssz-index:"16" ssz-type:"progressive-list"` + JustificationBits bitfield.Bitvector4 `ssz-index:"17" ssz-size:"1"` PreviousJustifiedCheckpoint *phase0.Checkpoint `ssz-index:"18"` CurrentJustifiedCheckpoint *phase0.Checkpoint `ssz-index:"19"` FinalizedCheckpoint *phase0.Checkpoint `ssz-index:"20"` - InactivityScores []uint64 `ssz-index:"21" ssz-type:"progressive-list"` + InactivityScores []uint64 `ssz-index:"21" ssz-type:"progressive-list"` CurrentSyncCommittee *altair.SyncCommittee `ssz-index:"22"` NextSyncCommittee *altair.SyncCommittee `ssz-index:"23"` - LatestBlockHash phase0.Hash32 `ssz-index:"24" ssz-size:"32"` + LatestBlockHash phase0.Hash32 `ssz-index:"24" ssz-size:"32"` NextWithdrawalIndex capella.WithdrawalIndex `ssz-index:"25"` NextWithdrawalValidatorIndex phase0.ValidatorIndex `ssz-index:"26"` - HistoricalSummaries []*capella.HistoricalSummary `ssz-index:"27" ssz-max:"16777216"` + HistoricalSummaries []*capella.HistoricalSummary `ssz-index:"27" ssz-max:"16777216"` DepositRequestsStartIndex uint64 `ssz-index:"28"` DepositBalanceToConsume phase0.Gwei `ssz-index:"29"` ExitBalanceToConsume phase0.Gwei `ssz-index:"30"` EarliestExitEpoch phase0.Epoch `ssz-index:"31"` ConsolidationBalanceToConsume phase0.Gwei `ssz-index:"32"` EarliestConsolidationEpoch phase0.Epoch `ssz-index:"33"` - PendingDeposits []*electra.PendingDeposit `ssz-index:"34" ssz-type:"progressive-list"` - PendingPartialWithdrawals []*electra.PendingPartialWithdrawal `ssz-index:"35" ssz-type:"progressive-list"` - PendingConsolidations []*electra.PendingConsolidation `ssz-index:"36" ssz-type:"progressive-list"` - ProposerLookahead []phase0.ValidatorIndex `ssz-index:"37" dynssz-size:"(MIN_SEED_LOOKAHEAD+1)*SLOTS_PER_EPOCH" ssz-size:"64"` - Builders []*gloas.Builder `ssz-index:"38" ssz-type:"progressive-list"` + PendingDeposits []*electra.PendingDeposit `ssz-index:"34" ssz-type:"progressive-list"` + PendingPartialWithdrawals []*electra.PendingPartialWithdrawal `ssz-index:"35" ssz-type:"progressive-list"` + PendingConsolidations []*electra.PendingConsolidation `ssz-index:"36" ssz-type:"progressive-list"` + ProposerLookahead []phase0.ValidatorIndex `dynssz-size:"(MIN_SEED_LOOKAHEAD+1)*SLOTS_PER_EPOCH" ssz-index:"37" ssz-size:"64"` + Builders []*gloas.Builder `ssz-index:"38" ssz-type:"progressive-list"` NextWithdrawalBuilderIndex gloas.BuilderIndex `ssz-index:"39"` - ExecutionPayloadAvailability []uint8 `ssz-index:"40" dynssz-size:"SLOTS_PER_HISTORICAL_ROOT/8" ssz-size:"1024"` - BuilderPendingPayments []*gloas.BuilderPendingPayment `ssz-index:"41" dynssz-size:"SLOTS_PER_EPOCH*2" ssz-size:"64"` - BuilderPendingWithdrawals []*gloas.BuilderPendingWithdrawal `ssz-index:"42" ssz-type:"progressive-list"` + ExecutionPayloadAvailability []uint8 `dynssz-size:"SLOTS_PER_HISTORICAL_ROOT/8" ssz-index:"40" ssz-size:"1024"` + BuilderPendingPayments []*gloas.BuilderPendingPayment `dynssz-size:"SLOTS_PER_EPOCH*2" ssz-index:"41" ssz-size:"64"` + BuilderPendingWithdrawals []*gloas.BuilderPendingWithdrawal `ssz-index:"42" ssz-type:"progressive-list"` LatestExecutionPayloadBid *ExecutionPayloadBid `ssz-index:"43"` - PayloadExpectedWithdrawals []*capella.Withdrawal `ssz-index:"44" ssz-type:"progressive-list"` - PTCWindow [][]phase0.ValidatorIndex `ssz-index:"45" dynssz-size:"(2+MIN_SEED_LOOKAHEAD)*SLOTS_PER_EPOCH,PTC_SIZE" ssz-size:"96,512"` + PayloadExpectedWithdrawals []*capella.Withdrawal `ssz-index:"44" ssz-type:"progressive-list"` + PTCWindow [][]phase0.ValidatorIndex `dynssz-size:"(2+MIN_SEED_LOOKAHEAD)*SLOTS_PER_EPOCH,PTC_SIZE" ssz-index:"45" ssz-size:"96,512"` } // String returns a string version of the structure. diff --git a/spec/heze/executionpayloadbid.go b/spec/heze/executionpayloadbid.go index 77494fe3..f5a8c68d 100644 --- a/spec/heze/executionpayloadbid.go +++ b/spec/heze/executionpayloadbid.go @@ -36,9 +36,9 @@ type ExecutionPayloadBid struct { Slot phase0.Slot `ssz-index:"7"` Value phase0.Gwei `ssz-index:"8"` ExecutionPayment phase0.Gwei `ssz-index:"9"` - BlobKZGCommitments []deneb.KZGCommitment `ssz-index:"10" ssz-type:"progressive-list"` + BlobKZGCommitments []deneb.KZGCommitment `ssz-index:"10" ssz-type:"progressive-list"` ExecutionRequestsRoot phase0.Root `ssz-index:"11"` - InclusionListBits []byte `ssz-index:"12" dynssz-size:"INCLUSION_LIST_COMMITTEE_SIZE/8" ssz-size:"2"` + InclusionListBits []byte `dynssz-size:"INCLUSION_LIST_COMMITTEE_SIZE/8" ssz-index:"12" ssz-size:"2"` } // String returns a string version of the structure. From fa58e13dd9f8105b784175479d1eebb6178274bc Mon Sep 17 00:00:00 2001 From: pk910 Date: Wed, 7 Oct 2026 17:23:27 +0200 Subject: [PATCH 4/5] events: restore fork fixes lost in the v0.30.0 merge - 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. --- http/events.go | 5 +- internal/eventdispatch/eventdispatch.go | 25 ++++++++++ internal/eventdispatch/eventdispatch_test.go | 50 ++++++++++++++++++++ spec/gloas/builder.go | 41 ++++++++++++---- spec/gloas/builder_test.go | 17 +++++++ 5 files changed, 128 insertions(+), 10 deletions(-) diff --git a/http/events.go b/http/events.go index f377fcc1..da240624 100644 --- a/http/events.go +++ b/http/events.go @@ -59,10 +59,11 @@ func (s *Service) Events(ctx context.Context, opts *api.EventsOpts) error { sseClient.Headers["Accept"] = "text/event-stream" sseClient.Connection.Transport = &http.Transport{ - Dial: (&net.Dialer{ + DialContext: (&net.Dialer{ Timeout: 2 * time.Second, KeepAlive: 2 * time.Second, - }).Dial, + }).DialContext, + ForceAttemptHTTP2: true, } go func() { diff --git a/internal/eventdispatch/eventdispatch.go b/internal/eventdispatch/eventdispatch.go index fec489f2..59b4193c 100644 --- a/internal/eventdispatch/eventdispatch.go +++ b/internal/eventdispatch/eventdispatch.go @@ -16,6 +16,7 @@ package eventdispatch import ( "context" + "encoding/json" "errors" "fmt" "slices" @@ -210,9 +211,33 @@ func Handle(ctx context.Context, opts *api.EventsOpts, topic string, data []byte return fmt.Errorf("%w %s", ErrUnsupportedTopic, topic) } + // Grandine publishes post-Electra single attestations on the attestation topic. They + // do not decode as an aggregate, so hand them to the single_attestation handler. + if topic == "attestation" && isSingleAttestationPayload(data) { + zerolog.Ctx(ctx).Debug().RawJSON("data", data). + Msg("Received single attestation payload on attestation topic; rerouting") + + topic = "single_attestation" + handler = topicHandlers[topic] + } + return handler.handle(ctx, opts, data) } +// isSingleAttestationPayload reports whether the data has the shape of an +// electra.SingleAttestation, which carries attester_index where an aggregate carries +// aggregation_bits. +func isSingleAttestationPayload(data []byte) bool { + var probe struct { + AttesterIndex *string `json:"attester_index"` + } + if err := json.Unmarshal(data, &probe); err != nil { + return false + } + + return probe.AttesterIndex != nil +} + // ForwardingGuarded returns a copy of opts whose handlers forward events only when forward // allows their topic. It leaves missing topic handlers nil so events can reach the generic // handler. It copies the topics slice and never changes opts, so guards for different clients diff --git a/internal/eventdispatch/eventdispatch_test.go b/internal/eventdispatch/eventdispatch_test.go index c677f9e9..ecf07e5f 100644 --- a/internal/eventdispatch/eventdispatch_test.go +++ b/internal/eventdispatch/eventdispatch_test.go @@ -23,6 +23,9 @@ import ( "github.com/ethpandaops/go-eth2-client/api" apiv1 "github.com/ethpandaops/go-eth2-client/api/v1" "github.com/ethpandaops/go-eth2-client/internal/eventdispatch" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/electra" + "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/rs/zerolog" "github.com/stretchr/testify/require" ) @@ -157,3 +160,50 @@ func TestSupports(t *testing.T) { require.False(t, eventdispatch.Supports("unknown")) require.False(t, eventdispatch.Supports("")) } + +// TestHandleReroutesSingleAttestation pins the Grandine shape: a post-Electra +// SingleAttestation published on the attestation topic reaches the single_attestation +// handler instead of failing to decode as an aggregate, while aggregates still reach the +// attestation handler. +func TestHandleReroutesSingleAttestation(t *testing.T) { + ctx := context.Background() + single := []byte(`{"committee_index":"32","attester_index":"1861792","data":{"slot":"14326127","index":"0","beacon_block_root":"0x62ab3e7b1366549bd040a3523f0c2d5a2743164355ac5adbc07d51c1dfd05f88","source":{"epoch":"447690","root":"0x5d419ad7acb52b038214f39171670e4af6b877850fcb1ebf30e18d8f5bb6a372"},"target":{"epoch":"447691","root":"0xdf078bb15f0f0532eb480da59eb63a05025807b77a3d4df5cb598810974a4304"}},"signature":"0x8f5bd89876a78f836a19494d36c10e0c0d6197117f20c4354d74d06022d7e95619889a54c9feadf2c4160f93aee02bb41606b8e70e296d2e39abd1fb67bca01d019ffe6a9714ac7f99273a8acaa91c7f7ce518091051f95134b6017449503472"}`) + aggregate := []byte(`{"aggregation_bits":"0x00002840403040000000020008800040008042800000020220","data":{"slot":"4095945","index":"12","beacon_block_root":"0xff27c7551bf4cfe4dc4cce00920e7a5c5074860d1dbd8aa8b3b5f888523f51ff","source":{"epoch":"127997","root":"0x38758fb180459583bd5e8e1a31711eb09e63eb92be974485397e9a2c57de2783"},"target":{"epoch":"127998","root":"0x46d4629861bd81cfc94007501b4edb1b3ca9444b41d7a98681b6c2f4bdb978bd"}},"signature":"0xacb9f562a28c4ef5b60b88678068ea51573a3237d3331dda3b2d845a0d03bc56ab2994d2deb90d9f074a8bdab59945150d0a7717e74b1bf2627f8971c81091f724c211dfce8fa16fb839c6a1bfd341ddec5e7eb88472682fd1a170e373660534"}`) + + t.Run("SingleToSpecificHandler", func(t *testing.T) { + var received *electra.SingleAttestation + opts := &api.EventsOpts{ + AttestationHandler: func(context.Context, *spec.VersionedAttestation) { + require.Fail(t, "attestation handler called") + }, + SingleAttestationHandler: func(_ context.Context, attestation *electra.SingleAttestation) { + received = attestation + }, + } + require.NoError(t, eventdispatch.Handle(ctx, opts, "attestation", single)) + require.NotNil(t, received) + require.Equal(t, phase0.ValidatorIndex(1861792), received.AttesterIndex) + }) + + t.Run("SingleToGenericHandler", func(t *testing.T) { + var received *apiv1.Event + opts := &api.EventsOpts{Handler: func(event *apiv1.Event) { received = event }} + require.NoError(t, eventdispatch.Handle(ctx, opts, "attestation", single)) + require.Equal(t, "single_attestation", received.Topic) + require.IsType(t, &electra.SingleAttestation{}, received.Data) + }) + + t.Run("AggregateNotRerouted", func(t *testing.T) { + var received *spec.VersionedAttestation + opts := &api.EventsOpts{ + AttestationHandler: func(_ context.Context, attestation *spec.VersionedAttestation) { + received = attestation + }, + SingleAttestationHandler: func(context.Context, *electra.SingleAttestation) { + require.Fail(t, "single attestation handler called") + }, + } + require.NoError(t, eventdispatch.Handle(ctx, opts, "attestation", aggregate)) + require.NotNil(t, received) + }) +} diff --git a/spec/gloas/builder.go b/spec/gloas/builder.go index d5050ad2..5d4ff678 100644 --- a/spec/gloas/builder.go +++ b/spec/gloas/builder.go @@ -39,12 +39,37 @@ type Builder struct { // builderJSON is the spec representation of the struct. type builderJSON struct { - PublicKey string `json:"pubkey"` - Version uint8 `json:"version"` - ExecutionAddress string `json:"execution_address"` - Balance string `json:"balance"` - DepositEpoch string `json:"deposit_epoch"` - WithdrawableEpoch string `json:"withdrawable_epoch"` + PublicKey string `json:"pubkey"` + Version builderVersionJSON `json:"version"` + ExecutionAddress string `json:"execution_address"` + Balance string `json:"balance"` + DepositEpoch string `json:"deposit_epoch"` + WithdrawableEpoch string `json:"withdrawable_epoch"` +} + +// builderVersionJSON is written as a bare number but reads either a bare number or a +// quoted decimal string, which is how Lighthouse serves the field. +type builderVersionJSON uint8 + +// UnmarshalJSON implements json.Unmarshaler. +func (v *builderVersionJSON) UnmarshalJSON(input []byte) error { + return v.parse(input) +} + +// UnmarshalYAML implements yaml.BytesUnmarshaler. +func (v *builderVersionJSON) UnmarshalYAML(input []byte) error { + return v.parse(input) +} + +func (v *builderVersionJSON) parse(input []byte) error { + version, err := strconv.ParseUint(string(bytes.Trim(bytes.TrimSpace(input), `"'`)), 10, 8) + if err != nil { + return errors.Wrap(err, "invalid value for version") + } + + *v = builderVersionJSON(version) + + return nil } // builderYAML is the spec representation of the struct. @@ -61,7 +86,7 @@ type builderYAML struct { func (v *Builder) MarshalJSON() ([]byte, error) { return json.Marshal(&builderJSON{ PublicKey: fmt.Sprintf("%#x", v.PublicKey), - Version: v.Version, + Version: builderVersionJSON(v.Version), ExecutionAddress: v.ExecutionAddress.String(), Balance: fmt.Sprintf("%d", v.Balance), DepositEpoch: fmt.Sprintf("%d", v.DepositEpoch), @@ -95,7 +120,7 @@ func (v *Builder) unpack(builderJSON *builderJSON) error { copy(v.PublicKey[:], publicKey) - v.Version = builderJSON.Version + v.Version = uint8(builderJSON.Version) if builderJSON.ExecutionAddress == "" { return errors.New("execution address missing") diff --git a/spec/gloas/builder_test.go b/spec/gloas/builder_test.go index 44237c85..cce3af1a 100644 --- a/spec/gloas/builder_test.go +++ b/spec/gloas/builder_test.go @@ -15,6 +15,7 @@ package gloas_test import ( "encoding/json" + "strings" "testing" "github.com/ethpandaops/go-eth2-client/spec/bellatrix" @@ -72,3 +73,19 @@ func TestBuilderVersionYAML(t *testing.T) { require.NoError(t, decoded.UnmarshalYAML(data)) require.Equal(t, builder, &decoded) } + +// TestBuilderVersionUnmarshalString verifies that a quoted version, which is how +// Lighthouse serves the field in a Gloas BeaconState, decodes the same as the bare +// number this package writes. +func TestBuilderVersionUnmarshalString(t *testing.T) { + builder := testBuilder() + + quoted := strings.Replace(mustMarshal(t, builder), `"version":1`, `"version":"1"`, 1) + require.Contains(t, quoted, `"version":"1"`) + + var decoded gloas.Builder + require.NoError(t, json.Unmarshal([]byte(quoted), &decoded)) + require.Equal(t, builder, &decoded) + + require.Error(t, json.Unmarshal([]byte(`{"pubkey":"0x00","version":"x"}`), &decoded)) +} From 752ade2738bba32f6a460a8806a4ab9c50edab1b Mon Sep 17 00:00:00 2001 From: pk910 Date: Wed, 7 Oct 2026 18:23:46 +0200 Subject: [PATCH 5/5] Restore fork interop fixes lost in the v0.30.0 merge - 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. --- .golangci.yml | 7 ++++ api/v1/headeventv2.go | 44 +++++++++++++------------- api/v1/headeventv2_test.go | 34 ++++++++++---------- http/submitexecutionpayloadenvelope.go | 4 +++ spec/all/aggregateandproof.go | 2 +- spec/all/attestation.go | 2 +- spec/all/attesterslashing.go | 2 +- spec/gloas/beaconstate_json.go | 22 +++++++------ spec/gloas/beaconstate_test.go | 10 ++++-- 9 files changed, 73 insertions(+), 54 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 8367a1f7..d4127a03 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -195,6 +195,13 @@ linters: text: "line is [0-9]+ characters" linters: - revive + # The http package is named after the API it wraps. revive reports the + # clash with net/http once per package on an arbitrary file, so a merge + # that adds files to the package would otherwise fail the new-lines check. + - path: ^http/ + text: 'package-naming: don''t use "http"' + linters: + - revive # Formatters: Code formatting tools formatters: enable: diff --git a/api/v1/headeventv2.go b/api/v1/headeventv2.go index 0f0f9701..5c3eb86c 100644 --- a/api/v1/headeventv2.go +++ b/api/v1/headeventv2.go @@ -87,11 +87,12 @@ func (e *HeadEventV2) UnmarshalJSON(input []byte) error { if err := json.Unmarshal(input, &event); err != nil { return errors.Wrap(err, "invalid JSON") } - if event.Version == spec.DataVersionUnknown { - return errors.New("version missing") - } if event.Data == nil { - return errors.New("data missing") + // Some clients publish the data bare, without the {"version","data"} wrapper. + event.Data = &headEventV2DataJSON{} + if err := json.Unmarshal(input, event.Data); err != nil { + return errors.Wrap(err, "invalid JSON") + } } if event.Data.PayloadStatus == "" { return errors.New("payload status missing") @@ -116,25 +117,24 @@ func (e *HeadEventV2) UnmarshalJSON(input []byte) error { if err := decodeFixedBytes(e.State[:], event.Data.State, "state"); err != nil { return err } - if event.Data.CurrentEpochDependentRoot == "" { - return errors.New("current epoch dependent root missing") - } - if err := decodeFixedBytes( - e.CurrentEpochDependentRoot[:], - event.Data.CurrentEpochDependentRoot, - "current epoch dependent root", - ); err != nil { - return err + // Dependent roots only have partial client coverage so do not complain if not present. + if event.Data.CurrentEpochDependentRoot != "" { + if err := decodeFixedBytes( + e.CurrentEpochDependentRoot[:], + event.Data.CurrentEpochDependentRoot, + "current epoch dependent root", + ); err != nil { + return err + } } - if event.Data.NextEpochDependentRoot == "" { - return errors.New("next epoch dependent root missing") - } - if err := decodeFixedBytes( - e.NextEpochDependentRoot[:], - event.Data.NextEpochDependentRoot, - "next epoch dependent root", - ); err != nil { - return err + if event.Data.NextEpochDependentRoot != "" { + if err := decodeFixedBytes( + e.NextEpochDependentRoot[:], + event.Data.NextEpochDependentRoot, + "next epoch dependent root", + ); err != nil { + return err + } } e.Version = event.Version diff --git a/api/v1/headeventv2_test.go b/api/v1/headeventv2_test.go index c29f9722..c7283bfc 100644 --- a/api/v1/headeventv2_test.go +++ b/api/v1/headeventv2_test.go @@ -35,14 +35,9 @@ func TestHeadEventV2JSONErrors(t *testing.T) { err: "invalid JSON: json: cannot unmarshal array into Go value of type v1.headEventV2JSON", }, { - name: "VersionMissing", - input: []byte(`{"data":{}}`), - err: "version missing", - }, - { - name: "DataMissing", + name: "BareDataMissingSlot", input: []byte(`{"version":"gloas"}`), - err: "data missing", + err: "payload status missing", }, { name: "PayloadStatusMissing", @@ -74,16 +69,6 @@ func TestHeadEventV2JSONErrors(t *testing.T) { input: []byte(`{"version":"gloas","data":{"slot":"10","block":"0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf","payload_status":"empty"}}`), err: "state missing", }, - { - name: "CurrentEpochDependentRootMissing", - input: []byte(`{"version":"gloas","data":{"slot":"10","block":"0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf","state":"0x600e852a08c1200654ddf11025f1ceacb3c2e74bdd5c630cde0838b2591b69f9","payload_status":"empty"}}`), - err: "current epoch dependent root missing", - }, - { - name: "NextEpochDependentRootMissing", - input: []byte(`{"version":"gloas","data":{"slot":"10","block":"0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf","state":"0x600e852a08c1200654ddf11025f1ceacb3c2e74bdd5c630cde0838b2591b69f9","payload_status":"empty","current_epoch_dependent_root":"0x5e0043f107cb57913498fbf2f99ff55e730bf1e151f02f221e977c91a90a0e91"}}`), - err: "next epoch dependent root missing", - }, } for _, test := range tests { @@ -114,3 +99,18 @@ func TestHeadEventV2JSON(t *testing.T) { require.JSONEq(t, string(input), string(output)) require.JSONEq(t, string(input), actual.String()) } + +// TestHeadEventV2JSONBare covers clients that publish the event without the +// {"version","data"} wrapper and without the dependent roots. +func TestHeadEventV2JSONBare(t *testing.T) { + input := []byte(`{"slot":"10","block":"0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf","state":"0x600e852a08c1200654ddf11025f1ceacb3c2e74bdd5c630cde0838b2591b69f9","payload_status":"empty","epoch_transition":true,"execution_optimistic":false}`) + + var actual api.HeadEventV2 + require.NoError(t, json.Unmarshal(input, &actual)) + require.Equal(t, spec.DataVersionUnknown, actual.Version) + require.Equal(t, phase0.Slot(10), actual.Slot) + require.Equal(t, "empty", actual.PayloadStatus) + require.True(t, actual.EpochTransition) + require.Equal(t, phase0.Root{}, actual.CurrentEpochDependentRoot) + require.Equal(t, phase0.Root{}, actual.NextEpochDependentRoot) +} diff --git a/http/submitexecutionpayloadenvelope.go b/http/submitexecutionpayloadenvelope.go index 9db8ce77..4aa9e3bc 100644 --- a/http/submitexecutionpayloadenvelope.go +++ b/http/submitexecutionpayloadenvelope.go @@ -147,6 +147,10 @@ func (s *Service) postExecutionPayloadEnvelope(ctx context.Context, // schema (signed envelope with its blobs and KZG proofs). Strict consensus // clients reject the request when the header is missing. headers["Eth-Blob-Data-Included"] = "true" + // The pre-beacon-APIs#624 discriminator (Eth-Execution-Payload-Blinded "false" + // selects the Contents body) is still sent for beacon nodes that have not + // adopted the rename yet. + headers["Eth-Execution-Payload-Blinded"] = "false" if _, err := s.post(ctx, endpoint, query, common, bytes.NewBuffer(body), contentType, headers); err != nil { return errors.Join(errors.New("failed to submit execution payload envelope"), err) diff --git a/spec/all/aggregateandproof.go b/spec/all/aggregateandproof.go index cf8fd522..b54586b2 100644 --- a/spec/all/aggregateandproof.go +++ b/spec/all/aggregateandproof.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// Copyright © 2023 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/spec/all/attestation.go b/spec/all/attestation.go index 705cb04d..79a549e3 100644 --- a/spec/all/attestation.go +++ b/spec/all/attestation.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// Copyright © 2023 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/spec/all/attesterslashing.go b/spec/all/attesterslashing.go index a95b050e..c8b9d1b7 100644 --- a/spec/all/attesterslashing.go +++ b/spec/all/attesterslashing.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// Copyright © 2023 - 2026 Attestant Limited. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/spec/gloas/beaconstate_json.go b/spec/gloas/beaconstate_json.go index 77dea48a..8ac8d4a3 100644 --- a/spec/gloas/beaconstate_json.go +++ b/spec/gloas/beaconstate_json.go @@ -444,29 +444,31 @@ func (b *BeaconState) UnmarshalJSON(input []byte) error { } } - ptcWindowStr := make([][]string, 0) - if err := json.Unmarshal(raw["ptc_window"], &ptcWindowStr); err != nil { + // The indices are read as raw values because Lighthouse serves them as bare JSON + // numbers where the spec quotes them; the quotes are stripped before parsing. + ptcWindowRaw := make([][]json.RawMessage, 0) + if err := json.Unmarshal(raw["ptc_window"], &ptcWindowRaw); err != nil { // Prysm wraps each inner vector in an object carrying validator_indices, // because proto3 cannot express a nested repeated field without an // intermediate message. The spec shape is tried first and is the only one // marshalled, so the deviation is absorbed where it arrives rather than // propagated; on failure of both the spec shape's error is the one reported. wrapped := make([]struct { - ValidatorIndices []string `json:"validator_indices"` + ValidatorIndices []json.RawMessage `json:"validator_indices"` }, 0) if wrappedErr := json.Unmarshal(raw["ptc_window"], &wrapped); wrappedErr != nil { return errors.Wrap(err, "ptc_window") } - ptcWindowStr = make([][]string, len(wrapped)) + ptcWindowRaw = make([][]json.RawMessage, len(wrapped)) for i := range wrapped { - ptcWindowStr[i] = wrapped[i].ValidatorIndices + ptcWindowRaw[i] = wrapped[i].ValidatorIndices } } - b.PTCWindow = make([][]phase0.ValidatorIndex, len(ptcWindowStr)) - for i := range ptcWindowStr { - b.PTCWindow[i] = make([]phase0.ValidatorIndex, len(ptcWindowStr[i])) - for j := range ptcWindowStr[i] { - idx, parseErr := strconv.ParseUint(ptcWindowStr[i][j], 10, 64) + b.PTCWindow = make([][]phase0.ValidatorIndex, len(ptcWindowRaw)) + for i := range ptcWindowRaw { + b.PTCWindow[i] = make([]phase0.ValidatorIndex, len(ptcWindowRaw[i])) + for j := range ptcWindowRaw[i] { + idx, parseErr := strconv.ParseUint(string(bytes.Trim(ptcWindowRaw[i][j], `"`)), 10, 64) if parseErr != nil { return errors.Wrap(parseErr, fmt.Sprintf("ptc_window[%d][%d]", i, j)) } diff --git a/spec/gloas/beaconstate_test.go b/spec/gloas/beaconstate_test.go index 53025d49..2ef8ae8a 100644 --- a/spec/gloas/beaconstate_test.go +++ b/spec/gloas/beaconstate_test.go @@ -254,6 +254,11 @@ func TestBeaconStatePTCWindowJSONShapes(t *testing.T) { name: "Spec", ptcWindow: `[["57","58"],["59","60"]]`, }, + { + // Lighthouse serves the indices as bare JSON numbers. + name: "LighthouseBareNumbers", + ptcWindow: `[[57,58],[59,60]]`, + }, { // Observed from prysm on a Gloas devnet. name: "PrysmObjectWrapper", @@ -264,7 +269,8 @@ func TestBeaconStatePTCWindowJSONShapes(t *testing.T) { // a second shape does not cost the familiar diagnostic. name: "NeitherShape", ptcWindow: `{"validator_indices":["57"]}`, - err: "ptc_window: json: cannot unmarshal object into Go value of type [][]string", + // The Go type in the message differs between encoding/json versions. + err: "ptc_window: json: cannot unmarshal object into Go value of type", }, { // The wrapper is a shape, not an escape from validation. @@ -287,7 +293,7 @@ func TestBeaconStatePTCWindowJSONShapes(t *testing.T) { var decoded gloas.BeaconState err = json.Unmarshal(input, &decoded) if test.err != "" { - require.EqualError(t, err, test.err) + require.ErrorContains(t, err, test.err) return }