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..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: @@ -33,7 +36,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 @@ -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/.gitignore b/.gitignore index 641bc22c..008267d2 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 @@ -25,3 +28,6 @@ Makefile # Local TODO TODO.md + +# Consensus spec vectors fetched by .github/scripts/fetch-spec-tests.sh +.spec-tests/ diff --git a/.golangci.yml b/.golangci.yml index 18b125a4..d4127a03 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -41,6 +41,7 @@ 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 @@ -48,6 +49,7 @@ linters: - gocognit # Disable cognitive complexity check - goconst # Disable constant check - gocyclo # Disable gocyclo (redundant with cyclop; version-dispatch switches legitimately exceed default thresholds) + - gomodguard # Superseded by gomodguard_v2, which default: all enables - intrange # Disable integer range check - ireturn # Disable return check - lll # Disable line length check @@ -140,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: @@ -169,7 +159,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 @@ -194,6 +184,24 @@ 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 + # 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/CHANGELOG.md b/CHANGELOG.md index b4371499..a4136293 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,32 @@ +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 + - 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..fc76df9e --- /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/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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 71c5dacd..df0e7981 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 @@ -147,7 +147,7 @@ type ExecutionPayloadAvailableEventHandlerFunc func(context.Context, *apiv1.Exec type ExecutionPayloadBidEventHandlerFunc func(context.Context, *gloas.SignedExecutionPayloadBid) // ExecutionPayloadGossipEventHandlerFunc is the handler for execution_payload_gossip events. -type ExecutionPayloadGossipEventHandlerFunc func(context.Context, *apiv1.ExecutionPayloadEvent) +type ExecutionPayloadGossipEventHandlerFunc func(context.Context, *apiv1.ExecutionPayloadGossipEvent) // FastConfirmationEventHandlerFunc is the handler for fast_confirmation events. type FastConfirmationEventHandlerFunc func(context.Context, *apiv1.FastConfirmationEvent) diff --git a/api/executionpayloadenvelopeopts.go b/api/executionpayloadenvelopeopts.go new file mode 100644 index 00000000..2dd49cd3 --- /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/ethpandaops/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..647ba0c8 --- /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/ethpandaops/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..17171d4e --- /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/ethpandaops/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..c7cc6e3e --- /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/ethpandaops/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 index b991983d..271918ce 100644 --- a/api/signedexecutionpayloadenvelopeopts.go +++ b/api/signedexecutionpayloadenvelopeopts.go @@ -1,4 +1,4 @@ -// Copyright © 2025 Attestant Limited. +// 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 diff --git a/api/submitexecutionpayloadbidopts.go b/api/submitexecutionpayloadbidopts.go index a1da3539..0237a136 100644 --- a/api/submitexecutionpayloadbidopts.go +++ b/api/submitexecutionpayloadbidopts.go @@ -28,13 +28,10 @@ type SubmitExecutionPayloadBidOpts struct { SignedExecutionPayloadBid *spec.VersionedSignedExecutionPayloadBid } -// SubmitAgnosticExecutionPayloadBidOpts are the options for submitting -// execution payload bids supplied as a fork-agnostic -// *all.SignedExecutionPayloadBid. +// SubmitAgnosticExecutionPayloadBidOpts are the options for submitting a fork-agnostic execution payload bid. type SubmitAgnosticExecutionPayloadBidOpts struct { Common CommonOpts - // SignedExecutionPayloadBid is the signed bid to publish. Its Version - // selects the wire schema and the consensus version header. + // SignedExecutionPayloadBid is the fork-agnostic signed execution payload bid to submit. SignedExecutionPayloadBid *all.SignedExecutionPayloadBid } diff --git a/api/submitexecutionpayloadenvelopeopts.go b/api/submitexecutionpayloadenvelopeopts.go index 9a6797b1..f01ccfc9 100644 --- a/api/submitexecutionpayloadenvelopeopts.go +++ b/api/submitexecutionpayloadenvelopeopts.go @@ -45,25 +45,19 @@ type SubmitExecutionPayloadEnvelopeOpts struct { BroadcastValidation *apiv2.BroadcastValidation } -// SubmitAgnosticExecutionPayloadEnvelopeOpts are the options for submitting -// execution payload envelopes supplied as a fork-agnostic -// *all.SignedExecutionPayloadEnvelope. See -// SubmitExecutionPayloadEnvelopeOpts for the request form semantics. +// SubmitAgnosticExecutionPayloadEnvelopeOpts are the options for submitting a fork-agnostic execution payload envelope. type SubmitAgnosticExecutionPayloadEnvelopeOpts struct { Common CommonOpts - // SignedExecutionPayloadEnvelope is the signed envelope to publish. - // Its Version selects the wire schema and the consensus version header. + // SignedExecutionPayloadEnvelope is the fork-agnostic signed execution payload envelope to submit. SignedExecutionPayloadEnvelope *all.SignedExecutionPayloadEnvelope - // KZGProofs are the cell KZG proofs for the blobs committed to by the - // envelope's payload. + // KZGProofs are the KZG proofs for the blobs in the envelope. KZGProofs []deneb.KZGProof - // Blobs are the blobs committed to by the envelope's payload. + // Blobs are the blobs in the envelope. Blobs []deneb.Blob - // BroadcastValidation is the validation required of the consensus node - // before broadcasting the envelope. + // BroadcastValidation is the validation level to apply before broadcasting. BroadcastValidation *apiv2.BroadcastValidation } diff --git a/api/submitpayloadattestationmessagesopts.go b/api/submitpayloadattestationmessagesopts.go new file mode 100644 index 00000000..9a3f2a92 --- /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/ethpandaops/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 90651d30..9a1640f2 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..a3326d44 --- /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/ethpandaops/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/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/api/v1/event.go b/api/v1/event.go index caca02d3..28630e1e 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,11 +18,6 @@ import ( "fmt" "github.com/ethpandaops/go-eth2-client/spec" - "github.com/ethpandaops/go-eth2-client/spec/altair" - "github.com/ethpandaops/go-eth2-client/spec/capella" - "github.com/ethpandaops/go-eth2-client/spec/electra" - "github.com/ethpandaops/go-eth2-client/spec/gloas" - "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/pkg/errors" ) @@ -34,34 +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, - "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, - "inclusion_list": true, - "payload_attestation_message": true, - "payload_attributes": true, - "proposer_preferences": 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"` @@ -81,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, @@ -106,54 +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 "execution_payload", "execution_payload_gossip": - e.Data = &gloas.SignedExecutionPayloadEnvelope{} - case "execution_payload_available": - e.Data = &ExecutionPayloadAvailableEvent{} - case "execution_payload_bid": - e.Data = &gloas.SignedExecutionPayloadBid{} - case "fast_confirmation": - e.Data = &FastConfirmationEvent{} - case "finalized_checkpoint": - e.Data = &FinalizedCheckpointEvent{} - case "head": - e.Data = &HeadEvent{} - case "head_v2": - e.Data = &HeadEventV2{} - case "payload_attestation_message": - e.Data = &gloas.PayloadAttestationMessage{} - case "payload_attributes": - e.Data = &PayloadAttributesEvent{} - case "proposer_preferences": - e.Data = &gloas.SignedProposerPreferences{} - case "proposer_slashing": - e.Data = &phase0.ProposerSlashing{} - case "single_attestation": - e.Data = &electra.SingleAttestation{} - case "voluntary_exit": - e.Data = &phase0.SignedVoluntaryExit{} - case "inclusion_list": - e.Data = &InclusionListEvent{} - default: + topic, exists := eventTopicsByName[eventJSON.Topic] + if !exists { return fmt.Errorf("unsupported event topic %s", eventJSON.Topic) } @@ -162,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 7e20e712..b7b55aba 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/ethpandaops/go-eth2-client/api/v1" + "github.com/ethpandaops/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..321f54b2 --- /dev/null +++ b/api/v1/eventtopic.go @@ -0,0 +1,84 @@ +// 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/ethpandaops/go-eth2-client/internal/eventtopic" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/altair" + "github.com/ethpandaops/go-eth2-client/spec/capella" + "github.com/ethpandaops/go-eth2-client/spec/electra" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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, spec.DataVersionHeze), + 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[InclusionListEvent]("inclusion_list"), + eventtopic.New[LightClientFinalityUpdateEvent]("light_client_finality_update"), + eventtopic.New[LightClientOptimisticUpdateEvent]("light_client_optimistic_update"), + eventtopic.NewVersioned[gloas.PayloadAttestationMessage]("payload_attestation_message", spec.DataVersionGloas, spec.DataVersionHeze), + eventtopic.New[PayloadAttributesEvent]("payload_attributes"), + eventtopic.NewVersioned[gloas.SignedProposerPreferences]("proposer_preferences", spec.DataVersionGloas, spec.DataVersionHeze), + 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..e6618178 --- /dev/null +++ b/api/v1/eventtopic_internal_test.go @@ -0,0 +1,124 @@ +// 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/ethpandaops/go-eth2-client/internal/eventtopic" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/altair" + "github.com/ethpandaops/go-eth2-client/spec/capella" + "github.com/ethpandaops/go-eth2-client/spec/electra" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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: "inclusion_list", expected: &InclusionListEvent{}}, + {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") + // inclusion_list is the EIP-7805 (Heze) topic this fork carries ahead of the catalog. + require.True(t, registered["inclusion_list"], "fork topic must remain supported") + delete(registered, "inclusion_list") + 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 index f6962d30..322ee462 100644 --- a/api/v1/executionpayloadavailableevent.go +++ b/api/v1/executionpayloadavailableevent.go @@ -1,4 +1,4 @@ -// Copyright © 2025 Attestant Limited. +// 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 @@ -14,11 +14,9 @@ package v1 import ( - "encoding/hex" "encoding/json" "fmt" "strconv" - "strings" "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/pkg/errors" @@ -55,14 +53,9 @@ func (e *ExecutionPayloadAvailableEvent) UnmarshalJSON(input []byte) error { if executionPayloadAvailableEventJSON.BlockRoot == "" { return errors.New("block root missing") } - block, err := hex.DecodeString(strings.TrimPrefix(executionPayloadAvailableEventJSON.BlockRoot, "0x")) - if err != nil { - return errors.Wrap(err, "invalid value for block root") - } - if len(block) != rootLength { - return fmt.Errorf("incorrect length %d for block root", len(block)) + if err := decodeFixedBytes(e.BlockRoot[:], executionPayloadAvailableEventJSON.BlockRoot, "block root"); err != nil { + return err } - copy(e.BlockRoot[:], block) if executionPayloadAvailableEventJSON.Slot == "" { return errors.New("slot missing") } diff --git a/api/v1/executionpayloadavailableevent_test.go b/api/v1/executionpayloadavailableevent_test.go new file mode 100644 index 00000000..3d0a8de1 --- /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/ethpandaops/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 index 04e7a7ab..3394616c 100644 --- a/api/v1/executionpayloadevent.go +++ b/api/v1/executionpayloadevent.go @@ -20,20 +20,16 @@ import ( "strconv" "strings" + "github.com/ethpandaops/go-eth2-client/spec/gloas" "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/pkg/errors" ) -// ExecutionPayloadEvent is the data for the `execution_payload` and -// `execution_payload_gossip` EIP-7732 events. Both carry a flat summary of a -// revealed execution payload (not the full SignedExecutionPayloadEnvelope): the -// `execution_payload` event fires when the envelope is imported into the -// fork-choice store, and `execution_payload_gossip` when it passes gossip -// validation. ExecutionOptimistic is only present on the `execution_payload` -// event. +// 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 uint64 + BuilderIndex gloas.BuilderIndex BlockHash phase0.Hash32 BlockRoot phase0.Root ExecutionOptimistic bool @@ -61,10 +57,8 @@ func (e *ExecutionPayloadEvent) MarshalJSON() ([]byte, error) { // UnmarshalJSON implements json.Unmarshaler. // -// Only the identifying slot and block root are required. builder_index and -// block_hash are parsed when present and validated for length, tolerating -// per-client field divergence (e.g. some clients emit an extra, non-spec -// state_root, which is ignored). +// 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 { @@ -83,22 +77,24 @@ func (e *ExecutionPayloadEvent) UnmarshalJSON(input []byte) error { if data.BlockRoot == "" { return errors.New("block root missing") } - if err := decodeFixedBytes(e.BlockRoot[:], data.BlockRoot, rootLength, "block root"); err != nil { + if err := decodeFixedBytes(e.BlockRoot[:], data.BlockRoot, "block root"); err != nil { return err } - if data.BuilderIndex != "" { - builderIndex, err := strconv.ParseUint(data.BuilderIndex, 10, 64) - if err != nil { - return errors.Wrap(err, "invalid value for builder index") - } - e.BuilderIndex = builderIndex + 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 != "" { - if err := decodeFixedBytes(e.BlockHash[:], data.BlockHash, phase0.Hash32Length, "block hash"); err != nil { - return err - } + 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 @@ -116,16 +112,15 @@ func (e *ExecutionPayloadEvent) String() string { return string(data) } -// decodeFixedBytes hex-decodes a 0x-prefixed value into dst, requiring exactly -// wantLen bytes. -// -//nolint:unparam // every current field is 32 bytes; wantLen keeps the helper usable for 20-byte addresses et al. -func decodeFixedBytes(dst []byte, value string, wantLen int, name string) error { +// 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) != wantLen { + if len(decoded) != len(dst) { return fmt.Errorf("incorrect length %d for %s", len(decoded), name) } copy(dst, decoded) diff --git a/api/v1/executionpayloadevent_test.go b/api/v1/executionpayloadevent_test.go index 3ff5e168..028c1579 100644 --- a/api/v1/executionpayloadevent_test.go +++ b/api/v1/executionpayloadevent_test.go @@ -15,8 +15,12 @@ package v1_test import ( "encoding/json" + "math" + "reflect" "testing" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + api "github.com/ethpandaops/go-eth2-client/api/v1" "github.com/stretchr/testify/assert" require "github.com/stretchr/testify/require" @@ -39,33 +43,52 @@ func TestExecutionPayloadEventJSON(t *testing.T) { }, { name: "SlotMissing", - input: []byte(`{"builder_index":"1","block_hash":"0xc8ac520b396aad76b4cf448aa4ff8ca9b4c1fe600e6416234849654e874db714","block_root":"0x2e52507aad249d4dfe1557f9031d5154c1c7b2a2e5819d0639f719725aa538af","execution_optimistic":true}`), + 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":"91000","builder_index":"1","block_hash":"0xc8ac520b396aad76b4cf448aa4ff8ca9b4c1fe600e6416234849654e874db714","execution_optimistic":true}`), + input: []byte(`{"slot":"4095940"}`), err: "block root missing", }, { - name: "BlockRootInvalidLength", - input: []byte(`{"slot":"91000","builder_index":"1","block_hash":"0xc8ac520b396aad76b4cf448aa4ff8ca9b4c1fe600e6416234849654e874db714","block_root":"0x2e52","execution_optimistic":true}`), - err: "incorrect length 2 for block root", + 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", }, { - // Real prysm/lighthouse-shape sample: no state_root, execution_optimistic present. - name: "GoodBare", - input: []byte(`{"slot":"91000","builder_index":"1","block_hash":"0xc8ac520b396aad76b4cf448aa4ff8ca9b4c1fe600e6416234849654e874db714","block_root":"0x2e52507aad249d4dfe1557f9031d5154c1c7b2a2e5819d0639f719725aa538af","execution_optimistic":true}`), + name: "BlockRootLong", + input: []byte(`{"slot":"4095940","block_root":"0x9999e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028"}`), + err: "incorrect length 33 for block root", }, { - // Real nimbus-shape sample: carries an extra, non-spec state_root that must be ignored. - name: "GoodWithNonSpecStateRoot", - input: []byte(`{"slot":"91894","builder_index":"18446744073709551615","block_hash":"0x716b390040980aa80e891c4881b5f266812ca1fdbf14efc4ef048145b8d0edb6","block_root":"0x957251f4a7a2b9713520a3f44d008a7814efaf5c63ff2cfe2ff908f28d09866f","state_root":"0x0000000000000000000000000000000000000000000000000000000000000000","execution_optimistic":true}`), + name: "BuilderIndexInvalid", + input: []byte(`{"slot":"4095940","block_root":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","builder_index":"-1"}`), + err: "invalid value for builder index: strconv.ParseUint: parsing \"-1\": invalid syntax", }, { - // Gossip-shape sample: no execution_optimistic. - name: "GoodGossip", - input: []byte(`{"slot":"91001","builder_index":"1","block_hash":"0x45aaeada228ea83858022991d8e6e8a2d6492f58e60d5d4a30acc528fcd5063d","block_root":"0xe7b490b90ad5bcc30d933c88f0177df4df2eb32094036421e455737582880268"}`), + 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}`), }, } @@ -79,21 +102,74 @@ func TestExecutionPayloadEventJSON(t *testing.T) { require.NoError(t, err) rt, err := json.Marshal(&res) require.NoError(t, err) - assert.NotEmpty(t, rt) + assert.Equal(t, string(test.input), string(rt)) + assert.Equal(t, string(rt), res.String()) } }) } + } -func TestExecutionPayloadEventValues(t *testing.T) { - input := []byte(`{"slot":"91894","builder_index":"18446744073709551615","block_hash":"0x716b390040980aa80e891c4881b5f266812ca1fdbf14efc4ef048145b8d0edb6","block_root":"0x957251f4a7a2b9713520a3f44d008a7814efaf5c63ff2cfe2ff908f28d09866f","state_root":"0x0000000000000000000000000000000000000000000000000000000000000000","execution_optimistic":true}`) +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) + }) + } +} - var res api.ExecutionPayloadEvent - require.NoError(t, json.Unmarshal(input, &res)) +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) + } +} - assert.Equal(t, uint64(91894), uint64(res.Slot)) - assert.Equal(t, uint64(18446744073709551615), res.BuilderIndex) - assert.Equal(t, "0x716b390040980aa80e891c4881b5f266812ca1fdbf14efc4ef048145b8d0edb6", res.BlockHash.String()) - assert.Equal(t, "0x957251f4a7a2b9713520a3f44d008a7814efaf5c63ff2cfe2ff908f28d09866f", res.BlockRoot.String()) - assert.True(t, res.ExecutionOptimistic) +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..f19cfcbe --- /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/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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 index 2a06b3c9..64d0125d 100644 --- a/api/v1/fastconfirmationevent.go +++ b/api/v1/fastconfirmationevent.go @@ -14,34 +14,42 @@ package v1 import ( - "encoding/hex" "encoding/json" "fmt" "strconv" - "strings" "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/pkg/errors" ) -// FastConfirmationEvent is the data for the fast confirmation event. +// 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 + 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"` + Slot string `json:"slot"` + Block string `json:"block"` + CurrentSlot string `json:"current_slot,omitempty"` } // MarshalJSON implements json.Marshaler. func (e *FastConfirmationEvent) MarshalJSON() ([]byte, error) { - return json.Marshal(&fastConfirmationEventJSON{ + 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. @@ -66,17 +74,24 @@ func (e *FastConfirmationEvent) UnmarshalJSON(input []byte) error { return errors.New("block missing") } - block, err := hex.DecodeString(strings.TrimPrefix(data.Block, "0x")) - if err != nil { - return errors.Wrap(err, "invalid value for block") + if err := decodeFixedBytes(e.Block[:], data.Block, "block"); err != nil { + return err } - if len(block) != rootLength { - return fmt.Errorf("incorrect length %d for block", len(block)) + // 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 } - copy(e.Block[:], block) - return nil } diff --git a/api/v1/fastconfirmationevent_test.go b/api/v1/fastconfirmationevent_test.go new file mode 100644 index 00000000..104d76a1 --- /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/ethpandaops/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..965b57fc --- /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/ethpandaops/go-eth2-client/spec/deneb" + "github.com/ethpandaops/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..66190766 --- /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/ethpandaops/go-eth2-client/codecs" + "github.com/ethpandaops/go-eth2-client/spec/deneb" + "github.com/ethpandaops/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..47580797 --- /dev/null +++ b/api/v1/gloas/blockcontents_ssz.go @@ -0,0 +1,277 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 1cf2ea9eabb235d6f1c1c39855da573db821af93cda47d04e9d6412a54489aac +// Version: v1.4.0-pre.3 (https://github.com/pk910/dynamic-ssz) + +package gloas + +import ( + "encoding/binary" + "math" + + "github.com/ethpandaops/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' + if off := uint64(len(dst)) - uint64(dstlen); off > math.MaxUint32 { + return nil, sszutils.ErrOffsetOverflowFn(off) + } else { + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(off)) + } + 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' + if off := uint64(len(dst)) - uint64(dstlen); off > math.MaxUint32 { + return nil, sszutils.ErrOffsetOverflowFn(off) + } else { + binary.LittleEndian.PutUint32(dst[dstlen+4:], uint32(off)) + } + 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' + if off := uint64(len(dst)) - uint64(dstlen); off > math.MaxUint32 { + return nil, sszutils.ErrOffsetOverflowFn(off) + } else { + binary.LittleEndian.PutUint32(dst[dstlen+8:], uint32(off)) + } + 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' + if off := uint64(len(dst)) - uint64(dstlen); off > math.MaxUint32 { + return nil, sszutils.ErrOffsetOverflowFn(off) + } else { + binary.LittleEndian.PutUint32(dst[dstlen+12:], uint32(off)) + } + 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 := uint64(len(buf)) + if buflen < 16 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 16) + } + maxOffset := uint32(sszutils.Min(buflen, math.MaxUint32)) + // Field #0 'Block' (offset) + offset0 := binary.LittleEndian.Uint32(buf[0:4]) + if offset0 != 16 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 16), "Block:o") + } + // Field #1 'ExecutionPayloadEnvelope' (offset) + offset1 := binary.LittleEndian.Uint32(buf[4:8]) + if offset1 < offset0 || offset1 > maxOffset { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset1, offset0, buflen), "ExecutionPayloadEnvelope:o") + } + // Field #2 'KZGProofs' (offset) + offset2 := binary.LittleEndian.Uint32(buf[8:12]) + if offset2 < offset1 || offset2 > maxOffset { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset2, offset1, buflen), "KZGProofs:o") + } + // Field #3 'Blobs' (offset) + offset3 := binary.LittleEndian.Uint32(buf[12:16]) + if offset3 < offset2 || offset3 > maxOffset { + 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() int { + var size int64 + 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' + s1 := int64(t.Block.SizeSSZ()) + if s1 < 0 { + return -1 + } + size += s1 + } + { // Dynamic field #1 'ExecutionPayloadEnvelope' + s2 := int64(t.ExecutionPayloadEnvelope.SizeSSZ()) + if s2 < 0 { + return -1 + } + size += s2 + } + { // Dynamic field #2 'KZGProofs' + size += int64(len(t.KZGProofs)) * 48 + } + { // Dynamic field #3 'Blobs' + size += int64(len(t.Blobs)) * 131072 + } + if uint64(size) > sszutils.MaxSszSize { + return -1 + } + return int(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 len(t) { + 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 len(t) { + 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..f9405523 --- /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/ethpandaops/go-eth2-client/api/v1/gloas" + "github.com/ethpandaops/go-eth2-client/spec/altair" + "github.com/ethpandaops/go-eth2-client/spec/deneb" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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..c38fa4ec --- /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/ethpandaops/go-eth2-client/spec/deneb" + "github.com/ethpandaops/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.yaml b/api/v1/gloas/generate.yaml index afca147b..b37fd9a9 100644 --- a/api/v1/gloas/generate.yaml +++ b/api/v1/gloas/generate.yaml @@ -3,5 +3,7 @@ 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 index 8c75ae85..ecac76c1 100644 --- a/api/v1/gloas/signedexecutionpayloadenvelopecontents.go +++ b/api/v1/gloas/signedexecutionpayloadenvelopecontents.go @@ -32,6 +32,19 @@ type SignedExecutionPayloadEnvelopeContents struct { 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) diff --git a/api/v1/gloas/signedexecutionpayloadenvelopecontents_json.go b/api/v1/gloas/signedexecutionpayloadenvelopecontents_json.go index 2651d85a..6eaf0df5 100644 --- a/api/v1/gloas/signedexecutionpayloadenvelopecontents_json.go +++ b/api/v1/gloas/signedexecutionpayloadenvelopecontents_json.go @@ -17,21 +17,12 @@ import ( "encoding/json" "github.com/ethpandaops/go-eth2-client/codecs" - "github.com/ethpandaops/go-eth2-client/spec/deneb" - "github.com/ethpandaops/go-eth2-client/spec/gloas" "github.com/pkg/errors" ) -// signedExecutionPayloadEnvelopeContentsJSON is the spec representation of the struct. -type signedExecutionPayloadEnvelopeContentsJSON struct { - SignedExecutionPayloadEnvelope *gloas.SignedExecutionPayloadEnvelope `json:"signed_execution_payload_envelope"` - KZGProofs []deneb.KZGProof `json:"kzg_proofs"` - Blobs []deneb.Blob `json:"blobs"` -} - // MarshalJSON implements json.Marshaler. func (s *SignedExecutionPayloadEnvelopeContents) MarshalJSON() ([]byte, error) { - return json.Marshal(&signedExecutionPayloadEnvelopeContentsJSON{ + return json.Marshal(&signedExecutionPayloadEnvelopeContents{ SignedExecutionPayloadEnvelope: s.SignedExecutionPayloadEnvelope, KZGProofs: s.KZGProofs, Blobs: s.Blobs, @@ -40,22 +31,25 @@ func (s *SignedExecutionPayloadEnvelopeContents) MarshalJSON() ([]byte, error) { // UnmarshalJSON implements json.Unmarshaler. func (s *SignedExecutionPayloadEnvelopeContents) UnmarshalJSON(input []byte) error { - raw, err := codecs.RawJSON(&signedExecutionPayloadEnvelopeContentsJSON{}, input) + raw, err := codecs.RawJSON(&signedExecutionPayloadEnvelopeContents{}, input) if err != nil { return err } - if err := json.Unmarshal(raw["signed_execution_payload_envelope"], &s.SignedExecutionPayloadEnvelope); err != nil { + 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"], &s.KZGProofs); err != nil { + if err := json.Unmarshal(raw["kzg_proofs"], &unmarshaled.KZGProofs); err != nil { return errors.Wrap(err, "kzg_proofs") } - if err := json.Unmarshal(raw["blobs"], &s.Blobs); err != nil { + 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_test.go b/api/v1/gloas/signedexecutionpayloadenvelopecontents_test.go new file mode 100644 index 00000000..eed3e4e4 --- /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/ethpandaops/go-eth2-client/api/v1/gloas" + "github.com/ethpandaops/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 index ddb3179e..59fb7563 100644 --- a/api/v1/gloas/signedexecutionpayloadenvelopecontents_yaml.go +++ b/api/v1/gloas/signedexecutionpayloadenvelopecontents_yaml.go @@ -15,24 +15,14 @@ package gloas import ( "bytes" - "encoding/json" - "github.com/ethpandaops/go-eth2-client/spec/deneb" - "github.com/ethpandaops/go-eth2-client/spec/gloas" "github.com/goccy/go-yaml" "github.com/pkg/errors" ) -// signedExecutionPayloadEnvelopeContentsYAML is the spec representation of the struct. -type signedExecutionPayloadEnvelopeContentsYAML struct { - SignedExecutionPayloadEnvelope *gloas.SignedExecutionPayloadEnvelope `yaml:"signed_execution_payload_envelope"` - KZGProofs []deneb.KZGProof `yaml:"kzg_proofs"` - Blobs []deneb.Blob `yaml:"blobs"` -} - // MarshalYAML implements yaml.Marshaler. func (s *SignedExecutionPayloadEnvelopeContents) MarshalYAML() ([]byte, error) { - yamlBytes, err := yaml.MarshalWithOptions(&signedExecutionPayloadEnvelopeContentsYAML{ + yamlBytes, err := yaml.MarshalWithOptions(&signedExecutionPayloadEnvelopeContents{ SignedExecutionPayloadEnvelope: s.SignedExecutionPayloadEnvelope, KZGProofs: s.KZGProofs, Blobs: s.Blobs, @@ -46,16 +36,12 @@ func (s *SignedExecutionPayloadEnvelopeContents) MarshalYAML() ([]byte, error) { // UnmarshalYAML implements yaml.Unmarshaler. func (s *SignedExecutionPayloadEnvelopeContents) UnmarshalYAML(input []byte) error { - // We unmarshal to the JSON struct to save on duplicate code. - var unmarshaled signedExecutionPayloadEnvelopeContentsJSON + var unmarshaled signedExecutionPayloadEnvelopeContents 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") - } + s.unpack(&unmarshaled) - return s.UnmarshalJSON(marshaled) + return nil } diff --git a/api/v1/headevent.go b/api/v1/headevent.go index 26897afb..537a4b32 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 bd1727a0..44eb8536 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 index 5f28af85..5c3eb86c 100644 --- a/api/v1/headeventv2.go +++ b/api/v1/headeventv2.go @@ -14,23 +14,18 @@ package v1 import ( - "bytes" "encoding/json" "fmt" "strconv" + "github.com/ethpandaops/go-eth2-client/spec" "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/pkg/errors" ) -// HeadEventV2 is the data for the head_v2 event (Gloas / EIP-7732). -// -// Relative to HeadEvent it drops previous_duty_dependent_root, renames -// current_duty_dependent_root to current_epoch_dependent_root, and adds -// next_epoch_dependent_root plus payload_status. The event can be emitted -// twice for the same block when payload_status transitions from empty to -// full. +// HeadEventV2 is the data for the head_v2 event. type HeadEventV2 struct { + Version spec.DataVersion Slot phase0.Slot Block phase0.Root State phase0.Root @@ -41,101 +36,112 @@ type HeadEventV2 struct { ExecutionOptimistic bool } -// headEventV2JSON is the spec representation of the struct. +// 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,omitempty"` + PayloadStatus string `json:"payload_status"` EpochTransition bool `json:"epoch_transition"` - CurrentEpochDependentRoot string `json:"current_epoch_dependent_root,omitempty"` - NextEpochDependentRoot string `json:"next_epoch_dependent_root,omitempty"` + 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) { - data := &headEventV2JSON{ - Slot: fmt.Sprintf("%d", e.Slot), - Block: fmt.Sprintf("%#x", e.Block), - State: fmt.Sprintf("%#x", e.State), - PayloadStatus: e.PayloadStatus, - EpochTransition: e.EpochTransition, - ExecutionOptimistic: e.ExecutionOptimistic, - } - - var zeroRoot phase0.Root - if !bytes.Equal(zeroRoot[:], e.CurrentEpochDependentRoot[:]) { - data.CurrentEpochDependentRoot = fmt.Sprintf("%#x", e.CurrentEpochDependentRoot) - } + 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, + }, + }) +} - if !bytes.Equal(zeroRoot[:], e.NextEpochDependentRoot[:]) { - data.NextEpochDependentRoot = fmt.Sprintf("%#x", e.NextEpochDependentRoot) +// 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 json.Marshal(data) + return string(data) } // UnmarshalJSON implements json.Unmarshaler. func (e *HeadEventV2) UnmarshalJSON(input []byte) error { - var headEventV2JSON headEventV2JSON - if err := json.Unmarshal(input, &headEventV2JSON); err != nil { + var event headEventV2JSON + if err := json.Unmarshal(input, &event); err != nil { return errors.Wrap(err, "invalid JSON") } - - if headEventV2JSON.Slot == "" { + if event.Data == nil { + // 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") + } + if event.Data.Slot == "" { return errors.New("slot missing") } - slot, err := strconv.ParseUint(headEventV2JSON.Slot, 10, 64) + slot, err := strconv.ParseUint(event.Data.Slot, 10, 64) if err != nil { return errors.Wrap(err, "invalid value for slot") } - - e.Slot = phase0.Slot(slot) - - if headEventV2JSON.Block == "" { + if event.Data.Block == "" { return errors.New("block missing") } - - if err := decodeFixedBytes(e.Block[:], headEventV2JSON.Block, rootLength, "block"); err != nil { + if err := decodeFixedBytes(e.Block[:], event.Data.Block, "block"); err != nil { return err } - - if headEventV2JSON.State == "" { + if event.Data.State == "" { return errors.New("state missing") } - - if err := decodeFixedBytes(e.State[:], headEventV2JSON.State, rootLength, "state"); err != nil { + if err := decodeFixedBytes(e.State[:], event.Data.State, "state"); err != nil { return err } - - e.PayloadStatus = headEventV2JSON.PayloadStatus - e.EpochTransition = headEventV2JSON.EpochTransition - e.ExecutionOptimistic = headEventV2JSON.ExecutionOptimistic - // Dependent roots only have partial client coverage so do not complain if not present. - if headEventV2JSON.CurrentEpochDependentRoot != "" { - if err := decodeFixedBytes(e.CurrentEpochDependentRoot[:], headEventV2JSON.CurrentEpochDependentRoot, rootLength, "current epoch dependent root"); err != nil { + if event.Data.CurrentEpochDependentRoot != "" { + if err := decodeFixedBytes( + e.CurrentEpochDependentRoot[:], + event.Data.CurrentEpochDependentRoot, + "current epoch dependent root", + ); err != nil { return err } } - - if headEventV2JSON.NextEpochDependentRoot != "" { - if err := decodeFixedBytes(e.NextEpochDependentRoot[:], headEventV2JSON.NextEpochDependentRoot, rootLength, "next epoch dependent root"); err != nil { + if event.Data.NextEpochDependentRoot != "" { + if err := decodeFixedBytes( + e.NextEpochDependentRoot[:], + event.Data.NextEpochDependentRoot, + "next epoch dependent root", + ); err != nil { return err } } - return nil -} - -// 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) - } + 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 string(data) + return nil } diff --git a/api/v1/headeventv2_test.go b/api/v1/headeventv2_test.go index 9c2aa78e..c7283bfc 100644 --- a/api/v1/headeventv2_test.go +++ b/api/v1/headeventv2_test.go @@ -18,81 +18,99 @@ import ( "testing" api "github.com/ethpandaops/go-eth2-client/api/v1" - "github.com/stretchr/testify/assert" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/phase0" require "github.com/stretchr/testify/require" ) -func TestHeadEventV2JSON(t *testing.T) { +func TestHeadEventV2JSONErrors(t *testing.T) { tests := []struct { name string input []byte err string }{ { - name: "Empty", - err: "unexpected end of JSON input", + name: "InvalidJSON", + input: []byte(`[]`), + err: "invalid JSON: json: cannot unmarshal array into Go value of type v1.headEventV2JSON", }, { - name: "JSONBad", - input: []byte("[]"), - err: "invalid JSON: json: cannot unmarshal array into Go value of type v1.headEventV2JSON", + name: "BareDataMissingSlot", + input: []byte(`{"version":"gloas"}`), + err: "payload status 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(`{"block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","state":"0x749a95b1355828b758864ea601c007e69aabed7b34a0f2084c43c26242f77e28","payload_status":"full","epoch_transition":false,"current_epoch_dependent_root":"0x907a3462a2905e3df2624869aa7f9a8635eb35bdcf9ce68a26fab691f9dada61","next_epoch_dependent_root":"0x935569bdc1aaad65dbeb532a125390d039058924ea81799238ed53e4e4639a11","execution_optimistic":false}`), + input: []byte(`{"version":"gloas","data":{"payload_status":"empty"}}`), err: "slot missing", }, { name: "SlotInvalid", - input: []byte(`{"slot":"-1","block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","state":"0x749a95b1355828b758864ea601c007e69aabed7b34a0f2084c43c26242f77e28","payload_status":"full","epoch_transition":false,"execution_optimistic":false}`), - err: "invalid value for slot: strconv.ParseUint: parsing \"-1\": invalid syntax", + 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(`{"slot":"525277","state":"0x749a95b1355828b758864ea601c007e69aabed7b34a0f2084c43c26242f77e28","payload_status":"full","epoch_transition":false,"execution_optimistic":false}`), + input: []byte(`{"version":"gloas","data":{"slot":"10","payload_status":"empty"}}`), err: "block missing", }, { - name: "BlockWrongLength", - input: []byte(`{"slot":"525277","block":"0xe3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","state":"0x749a95b1355828b758864ea601c007e69aabed7b34a0f2084c43c26242f77e28","payload_status":"full","epoch_transition":false,"execution_optimistic":false}`), - err: "incorrect length 31 for block", + name: "BlockInvalid", + input: []byte(`{"version":"gloas","data":{"slot":"10","block":"0x01","payload_status":"empty"}}`), + err: "incorrect length 1 for block", }, { name: "StateMissing", - input: []byte(`{"slot":"525277","block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","payload_status":"full","epoch_transition":false,"execution_optimistic":false}`), + input: []byte(`{"version":"gloas","data":{"slot":"10","block":"0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf","payload_status":"empty"}}`), err: "state missing", }, - { - name: "DependentRootsMissing", - input: []byte(`{"slot":"525277","block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","state":"0x749a95b1355828b758864ea601c007e69aabed7b34a0f2084c43c26242f77e28","payload_status":"empty","epoch_transition":false,"execution_optimistic":false}`), - }, - { - name: "CurrentEpochDependentRootWrongLength", - input: []byte(`{"slot":"525277","block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","state":"0x749a95b1355828b758864ea601c007e69aabed7b34a0f2084c43c26242f77e28","payload_status":"full","epoch_transition":false,"current_epoch_dependent_root":"0x7a3462a2905e3df2624869aa7f9a8635eb35bdcf9ce68a26fab691f9dada61","next_epoch_dependent_root":"0x935569bdc1aaad65dbeb532a125390d039058924ea81799238ed53e4e4639a11","execution_optimistic":false}`), - err: "incorrect length 31 for current epoch dependent root", - }, - { - name: "Good", - input: []byte(`{"slot":"525277","block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","state":"0x749a95b1355828b758864ea601c007e69aabed7b34a0f2084c43c26242f77e28","payload_status":"full","epoch_transition":false,"current_epoch_dependent_root":"0x907a3462a2905e3df2624869aa7f9a8635eb35bdcf9ce68a26fab691f9dada61","next_epoch_dependent_root":"0x935569bdc1aaad65dbeb532a125390d039058924ea81799238ed53e4e4639a11","execution_optimistic":false}`), - }, - { - name: "GoodEpochTransition", - input: []byte(`{"slot":"525280","block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","state":"0x749a95b1355828b758864ea601c007e69aabed7b34a0f2084c43c26242f77e28","payload_status":"empty","epoch_transition":true,"current_epoch_dependent_root":"0x907a3462a2905e3df2624869aa7f9a8635eb35bdcf9ce68a26fab691f9dada61","next_epoch_dependent_root":"0x935569bdc1aaad65dbeb532a125390d039058924ea81799238ed53e4e4639a11","execution_optimistic":true}`), - }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - var res api.HeadEventV2 - 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)) - } + 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()) +} + +// 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/api/v1/lightclientevents.go b/api/v1/lightclientevents.go new file mode 100644 index 00000000..f04fc4ac --- /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/ethpandaops/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..4bf226ec --- /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/ethpandaops/go-eth2-client/api/v1" + "github.com/ethpandaops/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..141286f0 --- /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/ethpandaops/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..031484bf --- /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/ethpandaops/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..6f718a03 --- /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/ethpandaops/go-eth2-client/api/v1/gloas" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/deneb" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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..b2ebaf64 --- /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/ethpandaops/go-eth2-client/api" + apiv1gloas "github.com/ethpandaops/go-eth2-client/api/v1/gloas" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/deneb" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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 eade2db7..710ac3e0 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 @@ -60,6 +60,8 @@ type VersionedSignedProposal struct { // 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: @@ -111,10 +113,18 @@ func (v *VersionedSignedProposal) AssertPresent() error { 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") } case spec.DataVersionHeze: + if v.Blinded { + return errors.New("heze proposals are never blinded") + } + if v.Heze == nil { return errors.New("heze proposal not present") } @@ -264,6 +274,9 @@ 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 case spec.DataVersionHeze: return v.Heze.Message.Body.SignedExecutionPayloadBid.Message.BlockHash, nil @@ -546,6 +559,8 @@ func (v *VersionedSignedProposal) assertExecutionPayloadPresent() error { } } 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 || diff --git a/api/versionedsignedproposal_test.go b/api/versionedsignedproposal_test.go new file mode 100644 index 00000000..15774d81 --- /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/ethpandaops/go-eth2-client/api" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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..82e172d6 --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" + apiv1 "github.com/ethpandaops/go-eth2-client/api/v1" + eth2http "github.com/ethpandaops/go-eth2-client/http" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/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 74f54301..5a927222 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 @@ -178,6 +178,7 @@ func decodeAggregateAttestation(httpResponse *httpResponse) (*spec.VersionedAtte gloasData, gloasMetadata, decodeErr := decodeJSONResponse(bytes.NewReader(httpResponse.body), &gloas.Attestation{}) metadata = gloasMetadata data.Gloas = gloasData + if decodeErr != nil { return &spec.VersionedAttestation{}, nil, decodeErr } diff --git a/http/aggregateattestation_internal_test.go b/http/aggregateattestation_internal_test.go index e82615af..8a00a9b4 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/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/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 fb212415..9015a7aa 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 987dd436..9dc2ff26 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/ethpandaops/go-eth2-client" "github.com/ethpandaops/go-eth2-client/api" + "github.com/ethpandaops/go-eth2-client/http" "github.com/ethpandaops/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 3a479e7e..4ff7d29d 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,6 +20,7 @@ import ( "fmt" "strings" + bitfield "github.com/OffchainLabs/go-bitfield" client "github.com/ethpandaops/go-eth2-client" "github.com/ethpandaops/go-eth2-client/api" "github.com/ethpandaops/go-eth2-client/spec" @@ -74,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 } @@ -133,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") } @@ -145,28 +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 { - return errors.New("attestation data not for requested slot") + if data == nil { + return errors.New("nil attestation in response") } - if opts.CommitteeIndex == nil { - // No committee index specified in opts so skipping check. - // This means we won't filter by committee indices and will attempt to match all committee indices. - return nil - } + return verifyPostElectraAttestation(opts, data.Data, data.CommitteeBits) +} - for _, committeeIndex := range data.CommitteeBits.BitIndices() { - if phase0.CommitteeIndex(committeeIndex) == *opts.CommitteeIndex { - // We have a match. - return nil - } +func verifyGloasAttestation(opts *api.AttestationPoolOpts, data *gloas.Attestation) error { + if data == nil { + return errors.New("nil attestation in response") } - return errors.New("attestation data not for requested committee index") + return verifyPostElectraAttestation(opts, data.Data, data.CommitteeBits) } -func verifyGloasAttestation(opts *api.AttestationPoolOpts, data *gloas.Attestation) error { - if opts.Slot != nil && data.Data.Slot != *opts.Slot { +// 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") } @@ -176,7 +253,7 @@ func verifyGloasAttestation(opts *api.AttestationPoolOpts, data *gloas.Attestati 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..48001de9 --- /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/ethpandaops/go-eth2-client/api" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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 f2db9bd6..e99e4660 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 @@ -156,13 +156,37 @@ func (s *Service) beaconStateFromSSZ(ctx context.Context, res *httpResponse) (*a err = dynSSZ.UnmarshalSSZ(response.Data.Electra, res.body) case spec.DataVersionFulu: response.Data.Fulu = &fulu.BeaconState{} - err = dynSSZ.UnmarshalSSZ(response.Data.Fulu, res.body) + if s.customSpecSupport { + err = dynSSZ.UnmarshalSSZ(response.Data.Fulu, res.body) + } else { + err = response.Data.Fulu.UnmarshalSSZ(res.body) + } + + if err != nil { + return nil, errors.Join(errors.New("failed to decode fulu beacon state"), err) + } case spec.DataVersionGloas: response.Data.Gloas = &gloas.BeaconState{} - err = dynSSZ.UnmarshalSSZ(response.Data.Gloas, res.body) + 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) + } case spec.DataVersionHeze: response.Data.Heze = &heze.BeaconState{} - err = dynSSZ.UnmarshalSSZ(response.Data.Heze, res.body) + if s.customSpecSupport { + err = dynSSZ.UnmarshalSSZ(response.Data.Heze, res.body) + } else { + err = response.Data.Heze.UnmarshalSSZ(res.body) + } + + if err != nil { + return nil, errors.Join(errors.New("failed to decode heze beacon state"), err) + } default: return nil, fmt.Errorf("unhandled state version %s", res.consensusVersion) } diff --git a/http/blobs_test.go b/http/blobs_test.go index b335680f..c6b58f83 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 4aca0c92..f5d8e8b5 100644 --- a/http/blobsidecars_test.go +++ b/http/blobsidecars_test.go @@ -21,6 +21,7 @@ import ( client "github.com/ethpandaops/go-eth2-client" "github.com/ethpandaops/go-eth2-client/api" + "github.com/ethpandaops/go-eth2-client/spec" "github.com/ethpandaops/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..9f0417b8 --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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..3d22a39b --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" + apiv1gloas "github.com/ethpandaops/go-eth2-client/api/v1/gloas" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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..785f2053 --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" + eth2http "github.com/ethpandaops/go-eth2-client/http" + "github.com/ethpandaops/go-eth2-client/mock" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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..2b1067c1 --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" + apiv1gloas "github.com/ethpandaops/go-eth2-client/api/v1/gloas" + eth2http "github.com/ethpandaops/go-eth2-client/http" + "github.com/ethpandaops/go-eth2-client/spec/altair" + "github.com/ethpandaops/go-eth2-client/spec/bellatrix" + "github.com/ethpandaops/go-eth2-client/spec/capella" + "github.com/ethpandaops/go-eth2-client/spec/electra" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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..2467b4a6 --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" + apiv1gloas "github.com/ethpandaops/go-eth2-client/api/v1/gloas" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/altair" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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..666c0aea --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" + apiv1gloas "github.com/ethpandaops/go-eth2-client/api/v1/gloas" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/altair" + "github.com/ethpandaops/go-eth2-client/spec/deneb" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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..d9a37078 --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" + "github.com/ethpandaops/go-eth2-client/mock" + "github.com/ethpandaops/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..6ee62ccd --- /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/ethpandaops/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..9c9f24c9 --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" + "github.com/ethpandaops/go-eth2-client/http" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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..95c30853 --- /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/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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..e8bea806 --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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 23578bc3..da240624 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,13 +26,7 @@ import ( client "github.com/ethpandaops/go-eth2-client" "github.com/ethpandaops/go-eth2-client/api" - apiv1 "github.com/ethpandaops/go-eth2-client/api/v1" - "github.com/ethpandaops/go-eth2-client/spec" - "github.com/ethpandaops/go-eth2-client/spec/altair" - "github.com/ethpandaops/go-eth2-client/spec/capella" - "github.com/ethpandaops/go-eth2-client/spec/electra" - "github.com/ethpandaops/go-eth2-client/spec/gloas" - "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/ethpandaops/go-eth2-client/internal/eventdispatch" "github.com/r3labs/sse/v2" "github.com/rs/zerolog" ) @@ -44,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) @@ -73,10 +58,6 @@ func (s *Service) Events(ctx context.Context, opts *api.EventsOpts) error { } sseClient.Headers["Accept"] = "text/event-stream" - // A custom dial function disqualifies the transport from automatic HTTP/2, - // so it has to be requested explicitly. Some beacon endpoints (Prysm and - // Grandine behind common reverse proxies) never flush an HTTP/1.1 events - // response, leaving the stream connected but silently empty for ever. sseClient.Connection.Transport = &http.Transport{ DialContext: (&net.Dialer{ Timeout: 2 * time.Second, @@ -109,89 +90,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 "execution_payload": - hasHandler = opts.ExecutionPayloadHandler != nil - case "execution_payload_available": - hasHandler = opts.ExecutionPayloadAvailableHandler != nil - case "execution_payload_bid": - hasHandler = opts.ExecutionPayloadBidHandler != nil - case "execution_payload_gossip": - hasHandler = opts.ExecutionPayloadGossipHandler != nil - case "fast_confirmation": - hasHandler = opts.FastConfirmationHandler != nil - case "finalized_checkpoint": - hasHandler = opts.FinalizedCheckpointHandler != nil - case "head": - hasHandler = opts.HeadHandler != nil - case "head_v2": - hasHandler = opts.HeadV2Handler != nil - case "payload_attestation_message": - hasHandler = opts.PayloadAttestationMessageHandler != nil - case "payload_attributes": - hasHandler = opts.PayloadAttributesHandler != nil - case "proposer_preferences": - hasHandler = opts.ProposerPreferencesHandler != 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, ) { @@ -203,723 +125,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 "execution_payload": - s.handleExecutionPayloadEvent(ctx, msg, opts) - case "execution_payload_available": - s.handleExecutionPayloadAvailableEvent(ctx, msg, opts) - case "execution_payload_bid": - s.handleExecutionPayloadBidEvent(ctx, msg, opts) - case "execution_payload_gossip": - s.handleExecutionPayloadGossipEvent(ctx, msg, opts) - case "fast_confirmation": - s.handleFastConfirmationEvent(ctx, msg, opts) - case "finalized_checkpoint": - s.handleFinalizedCheckpointEvent(ctx, msg, opts) - case "head": - s.handleHeadEvent(ctx, msg, opts) - case "head_v2": - s.handleHeadV2Event(ctx, msg, opts) - case "payload_attestation_message": - s.handlePayloadAttestationMessageEvent(ctx, msg, opts) - case "payload_attributes": - s.handlePayloadAttributesEvent(ctx, msg, opts) - case "proposer_preferences": - s.handleProposerPreferencesEvent(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 (s *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 { - if isSingleAttestationPayload(msg.Data) { - log.Debug().RawJSON("data", msg.Data). - Msg("Received single attestation payload on attestation topic; rerouting") - rerouted := *msg - rerouted.Event = []byte("single_attestation") - s.handleSingleAttestationEvent(ctx, &rerouted, opts) - - return - } - - 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") - } -} - -// isSingleAttestationPayload reports whether the JSON payload is a -// SingleAttestation rather than an aggregated Attestation. Some consensus -// clients (e.g. Grandine post-Electra) emit SingleAttestation objects on the -// "attestation" SSE topic instead of "single_attestation", so we detect the -// shape and reroute to keep the event stream functional. -// -// SingleAttestation is an Electra (EIP-7549) construct, so the probe never -// matches on pre-Electra forks — pre-Electra payloads always carry -// aggregation_bits and parse through the normal VersionedAttestation path. -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 -} - -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) handleFastConfirmationEvent(ctx context.Context, - msg *sse.Event, - opts *api.EventsOpts, -) { - log := zerolog.Ctx(ctx) - data := &apiv1.FastConfirmationEvent{} - - err := json.Unmarshal(msg.Data, data) - if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse fast confirmation event") - - return - } - - switch { - case opts.FastConfirmationHandler != nil: - opts.FastConfirmationHandler(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) handleHeadV2Event(ctx context.Context, - msg *sse.Event, - opts *api.EventsOpts, -) { - log := zerolog.Ctx(ctx) - data := &apiv1.HeadEventV2{} - - err := unmarshalVersionedEventData(msg.Data, data) - if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse head_v2 event") - - return - } - - switch { - case opts.HeadV2Handler != nil: - opts.HeadV2Handler(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") - - 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 - } - - switch { - case opts.DataColumnSidecarHandler != nil: - opts.DataColumnSidecarHandler(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) handleExecutionPayloadEvent(ctx context.Context, - msg *sse.Event, - opts *api.EventsOpts, -) { - log := zerolog.Ctx(ctx) - data := &apiv1.ExecutionPayloadEvent{} - - err := unmarshalVersionedEventData(msg.Data, data) - if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse execution payload event") - - return - } - - switch { - case opts.ExecutionPayloadHandler != nil: - opts.ExecutionPayloadHandler(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) handleExecutionPayloadAvailableEvent(ctx context.Context, - msg *sse.Event, - opts *api.EventsOpts, -) { - log := zerolog.Ctx(ctx) - data := &apiv1.ExecutionPayloadAvailableEvent{} - - err := json.Unmarshal(msg.Data, data) - if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse execution payload available event") - - return - } - - switch { - case opts.ExecutionPayloadAvailableHandler != nil: - opts.ExecutionPayloadAvailableHandler(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) handleExecutionPayloadBidEvent(ctx context.Context, - msg *sse.Event, - opts *api.EventsOpts, -) { - log := zerolog.Ctx(ctx) - data := &gloas.SignedExecutionPayloadBid{} - - err := unmarshalVersionedEventData(msg.Data, data) - if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse execution payload bid event") - - return - } - - switch { - case opts.ExecutionPayloadBidHandler != nil: - opts.ExecutionPayloadBidHandler(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) handleExecutionPayloadGossipEvent(ctx context.Context, - msg *sse.Event, - opts *api.EventsOpts, -) { - log := zerolog.Ctx(ctx) - data := &apiv1.ExecutionPayloadEvent{} - - err := unmarshalVersionedEventData(msg.Data, data) - if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse execution payload gossip event") - - return - } - - switch { - case opts.ExecutionPayloadGossipHandler != nil: - opts.ExecutionPayloadGossipHandler(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") - } -} - -// unmarshalVersionedEventData decodes an SSE event payload that the beacon-API -// spec wraps as {"version": "...", "data": {...}}. Some clients (Prysm) still -// send the bare unwrapped object, so that shape is accepted as a fallback. -func unmarshalVersionedEventData(raw []byte, v any) error { - var wrapper struct { - Version string `json:"version"` - Data json.RawMessage `json:"data"` - } - - if err := json.Unmarshal(raw, &wrapper); err == nil && len(wrapper.Data) > 0 && wrapper.Version != "" { - return json.Unmarshal(wrapper.Data, v) - } - - return json.Unmarshal(raw, v) -} - -func (*Service) handlePayloadAttestationMessageEvent(ctx context.Context, - msg *sse.Event, - opts *api.EventsOpts, -) { - log := zerolog.Ctx(ctx) - data := &gloas.PayloadAttestationMessage{} - - err := unmarshalVersionedEventData(msg.Data, data) - if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse payload attestation message event") + topic := string(msg.Event) + if len(topic) == 0 { + // Used as keepalive. Ignore. return } - switch { - case opts.PayloadAttestationMessageHandler != nil: - opts.PayloadAttestationMessageHandler(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) handleProposerPreferencesEvent(ctx context.Context, - msg *sse.Event, - opts *api.EventsOpts, -) { - log := zerolog.Ctx(ctx) - data := &gloas.SignedProposerPreferences{} - - err := unmarshalVersionedEventData(msg.Data, data) - if err != nil { - log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse proposer preferences event") - - return - } + err := eventdispatch.Handle(ctx, opts, topic, msg.Data) switch { - case opts.ProposerPreferencesHandler != nil: - opts.ProposerPreferencesHandler(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_attestation_internal_test.go b/http/events_attestation_internal_test.go deleted file mode 100644 index fb4bd165..00000000 --- a/http/events_attestation_internal_test.go +++ /dev/null @@ -1,60 +0,0 @@ -// 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" -) - -func TestIsSingleAttestationPayload(t *testing.T) { - tests := []struct { - name string - data string - want bool - }{ - { - name: "GrandineSingleAttestationOnAttestationTopic", - data: `{"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"}`, - want: true, - }, - { - name: "Phase0Attestation", - data: `{"aggregation_bits":"0x00002840403040000000020008800040008042800000020220","data":{"slot":"4095945","index":"12","beacon_block_root":"0xff27c7551bf4cfe4dc4cce00920e7a5c5074860d1dbd8aa8b3b5f888523f51ff","source":{"epoch":"127997","root":"0x38758fb180459583bd5e8e1a31711eb09e63eb92be974485397e9a2c57de2783"},"target":{"epoch":"127998","root":"0x46d4629861bd81cfc94007501b4edb1b3ca9444b41d7a98681b6c2f4bdb978bd"}},"signature":"0xacb9f562a28c4ef5b60b88678068ea51573a3237d3331dda3b2d845a0d03bc56ab2994d2deb90d9f074a8bdab59945150d0a7717e74b1bf2627f8971c81091f724c211dfce8fa16fb839c6a1bfd341ddec5e7eb88472682fd1a170e373660534"}`, - want: false, - }, - { - name: "ElectraAggregateAttestation", - data: `{"aggregation_bits":"0xff","committee_bits":"0x0100000000000000","data":{"slot":"1","index":"0","beacon_block_root":"0x0000000000000000000000000000000000000000000000000000000000000000","source":{"epoch":"0","root":"0x0000000000000000000000000000000000000000000000000000000000000000"},"target":{"epoch":"0","root":"0x0000000000000000000000000000000000000000000000000000000000000000"}},"signature":"0x0000"}`, - want: false, - }, - { - name: "InvalidJSON", - data: `not json`, - want: false, - }, - { - name: "EmptyObject", - data: `{}`, - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - require.Equal(t, tt.want, isSingleAttestationPayload([]byte(tt.data))) - }) - } -} diff --git a/http/events_internal_test.go b/http/events_internal_test.go index a64a8c6b..a944e322 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,348 @@ package http import ( "bytes" "context" - "fmt" - "os" + "encoding/json" + "reflect" "testing" - "time" - client "github.com/ethpandaops/go-eth2-client" "github.com/ethpandaops/go-eth2-client/api" apiv1 "github.com/ethpandaops/go-eth2-client/api/v1" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/altair" + "github.com/ethpandaops/go-eth2-client/spec/capella" + "github.com/ethpandaops/go-eth2-client/spec/electra" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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}}`, + "inclusion_list": `{"version":"heze","data":{"message":{"slot":"1","validator_index":"1","dependent_root":"0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd","transactions":[]},"signature":"0x000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f"}}`, + "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: "inclusion_list", expected: &apiv1.InclusionListEvent{}}, + {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 a5a923c7..c162cf85 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/ethpandaops/go-eth2-client" "github.com/ethpandaops/go-eth2-client/api" apiv1 "github.com/ethpandaops/go-eth2-client/api/v1" + beaconhttp "github.com/ethpandaops/go-eth2-client/http" + "github.com/ethpandaops/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/events_versioned_test.go b/http/events_versioned_test.go deleted file mode 100644 index f400a133..00000000 --- a/http/events_versioned_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package http - -import ( - "testing" - - "github.com/ethpandaops/go-eth2-client/spec/gloas" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// Lighthouse (spec-compliant) wraps gloas SSE payloads in a versioned -// envelope; Prysm sends the bare object. Both must decode. -func TestUnmarshalVersionedEventData(t *testing.T) { - bare := `{"validator_index":"325","data":{"beacon_block_root":"0x5793a892cd76b015aaaaaf8251a3061bbdb79cb7547fd926208e9beffec67e96","slot":"54715","payload_present":true,"blob_data_available":true},"signature":"0x946643c1b1d601a969fa99680e7d27701ad7c98ab4569caa6d76ed523cc74c1aa81361737ca37e42a21ae659d390bf580166086ccb6b412ad9463eceb31a1a54ac2c9b1c5238181f2ea7cb85750d332763908746f6035bfad655228a4ce45440"}` - wrapped := `{"version":"gloas","data":` + bare + `}` - - for name, payload := range map[string]string{"bare": bare, "wrapped": wrapped} { - t.Run(name, func(t *testing.T) { - msg := &gloas.PayloadAttestationMessage{} - require.NoError(t, unmarshalVersionedEventData([]byte(payload), msg)) - assert.Equal(t, uint64(325), uint64(msg.ValidatorIndex)) - assert.Equal(t, uint64(54715), uint64(msg.Data.Slot)) - assert.True(t, msg.Data.PayloadPresent) - }) - } -} diff --git a/http/executionpayloadenvelope.go b/http/executionpayloadenvelope.go new file mode 100644 index 00000000..ba33e9f2 --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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..50fd1366 --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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..5d5c58a1 --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/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..29e48181 --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/ethpandaops/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..284071aa --- /dev/null +++ b/http/gloasgate_test.go @@ -0,0 +1,92 @@ +package http_test + +import ( + "context" + "fmt" + "testing" + + "github.com/ethpandaops/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 00ffc786..e25e132c 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, @@ -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 be3933ac..ec711a19 100644 --- a/http/main_test.go +++ b/http/main_test.go @@ -24,6 +24,7 @@ import ( "time" client "github.com/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" "github.com/ethpandaops/go-eth2-client/http" "github.com/ethpandaops/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..18926d1d --- /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/ethpandaops/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..8481cfdf --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/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..ad1e965e --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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..9eed8440 --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/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..789d7874 --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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..18073a87 --- /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/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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 714bca5b..3a975951 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 cd39d61c..524cb272 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/ethpandaops/go-eth2-client" "github.com/ethpandaops/go-eth2-client/api" + apiv1 "github.com/ethpandaops/go-eth2-client/api/v1" + eth2http "github.com/ethpandaops/go-eth2-client/http" "github.com/ethpandaops/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..39011243 --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" + apiv1 "github.com/ethpandaops/go-eth2-client/api/v1" + "github.com/ethpandaops/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..9d54b203 --- /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/ethpandaops/go-eth2-client/api/v1" + "github.com/ethpandaops/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 0fbdf18d..18c00964 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 1aca3058..d620b98c 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 @@ -160,13 +160,37 @@ func (s *Service) signedBeaconBlockFromSSZ(ctx context.Context, err = dynSSZ.UnmarshalSSZ(response.Data.Electra, res.body) case spec.DataVersionFulu: response.Data.Fulu = &electra.SignedBeaconBlock{} - err = dynSSZ.UnmarshalSSZ(response.Data.Fulu, res.body) + if s.customSpecSupport { + err = dynSSZ.UnmarshalSSZ(response.Data.Fulu, res.body) + } else { + err = response.Data.Fulu.UnmarshalSSZ(res.body) + } + + 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{} - err = dynSSZ.UnmarshalSSZ(response.Data.Gloas, res.body) + 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) + } case spec.DataVersionHeze: response.Data.Heze = &heze.SignedBeaconBlock{} - err = dynSSZ.UnmarshalSSZ(response.Data.Heze, res.body) + if s.customSpecSupport { + err = dynSSZ.UnmarshalSSZ(response.Data.Heze, res.body) + } else { + err = response.Data.Heze.UnmarshalSSZ(res.body) + } + + if err != nil { + return nil, errors.Join(errors.New("failed to decode heze signed beacon block"), err) + } default: return nil, fmt.Errorf("unhandled signed beacon block version %s", res.consensusVersion) } diff --git a/http/signedbeaconblock_customspec_test.go b/http/signedbeaconblock_customspec_test.go new file mode 100644 index 00000000..a212299c --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" + eth2http "github.com/ethpandaops/go-eth2-client/http" + "github.com/ethpandaops/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 index 808fdec7..ebb18615 100644 --- a/http/signedexecutionpayloadenvelope.go +++ b/http/signedexecutionpayloadenvelope.go @@ -1,4 +1,4 @@ -// Copyright © 2020 - 2024 Attestant Limited. +// 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 @@ -41,8 +41,20 @@ func (s *Service) SignedExecutionPayloadEnvelope(ctx context.Context, return nil, err } - // All current envelope-bearing forks (Gloas, Heze) reuse the gloas - // schema, so the wire bytes always parse into *gloas.SignedExecutionPayloadEnvelope. + 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) @@ -54,12 +66,29 @@ func (s *Service) SignedExecutionPayloadEnvelope(ctx context.Context, } 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) + return nil, errors.Join( + fmt.Errorf("failed to decode %s signed execution payload envelope", httpResponse.consensusVersion), + err, + ) } case ContentTypeJSON: - decoded, jsonMetadata, err := decodeJSONResponse(bytes.NewReader(httpResponse.body), envelope) + // 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) + 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 @@ -78,11 +107,7 @@ func (s *Service) SignedExecutionPayloadEnvelope(ctx context.Context, }, nil } -// AgnosticSignedExecutionPayloadEnvelope fetches a signed execution payload -// envelope and decodes it directly into a fork-agnostic -// *all.SignedExecutionPayloadEnvelope. The Version is set from the consensus -// version header before unmarshaling so the union type's view-aware codec -// dispatches into the correct fork's schema. No intermediate copy. +// AgnosticSignedExecutionPayloadEnvelope provides a fork-agnostic signed execution payload envelope. func (s *Service) AgnosticSignedExecutionPayloadEnvelope(ctx context.Context, opts *api.SignedExecutionPayloadEnvelopeOpts, ) ( @@ -105,12 +130,18 @@ func (s *Service) AgnosticSignedExecutionPayloadEnvelope(ctx context.Context, } if err := envelope.UnmarshalSSZDyn(ds, httpResponse.body); err != nil { - return nil, errors.Join(fmt.Errorf("failed to decode %s signed execution payload envelope", httpResponse.consensusVersion), err) + return nil, errors.Join( + fmt.Errorf("failed to decode %s signed execution payload envelope", httpResponse.consensusVersion), + err, + ) } case ContentTypeJSON: decoded, jsonMetadata, err := decodeJSONResponse(bytes.NewReader(httpResponse.body), envelope) if err != nil { - return nil, errors.Join(fmt.Errorf("failed to decode %s signed execution payload envelope", httpResponse.consensusVersion), err) + return nil, errors.Join( + fmt.Errorf("failed to decode %s signed execution payload envelope", httpResponse.consensusVersion), + err, + ) } envelope = decoded @@ -126,10 +157,9 @@ func (s *Service) AgnosticSignedExecutionPayloadEnvelope(ctx context.Context, }, nil } -// fetchSignedExecutionPayloadEnvelope performs the GET request shared by both -// SignedExecutionPayloadEnvelope and AgnosticSignedExecutionPayloadEnvelope: -// validates opts, hits the endpoint, and rejects responses for forks where -// the envelope doesn't apply. +// 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) { @@ -147,12 +177,14 @@ func (s *Service) fetchSignedExecutionPayloadEnvelope(ctx context.Context, endpoint := fmt.Sprintf("/eth/v1/beacon/execution_payload_envelopes/%s", opts.Block) - httpResponse, err := s.get(ctx, endpoint, "", &opts.Common, true) + httpResponse, err := s.getWithResponseLimit(ctx, endpoint, "", &opts.Common, true, maxEPBSResponseSize) if err != nil { return nil, err } - if httpResponse.consensusVersion != spec.DataVersionGloas && httpResponse.consensusVersion != spec.DataVersionHeze { + switch httpResponse.consensusVersion { + case spec.DataVersionGloas, spec.DataVersionHeze: + default: return nil, fmt.Errorf("execution payload envelope not available for block version %s", httpResponse.consensusVersion) } diff --git a/http/signedexecutionpayloadenvelope_internal_test.go b/http/signedexecutionpayloadenvelope_internal_test.go new file mode 100644 index 00000000..6b93f278 --- /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/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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..211639f0 --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" + "github.com/ethpandaops/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 ae3bfac6..a8e6492e 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,20 +92,52 @@ 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..3979a62f --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/electra" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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 2e857f79..83fbb036 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 @@ -120,12 +120,17 @@ func (s *Service) createUnversionedAttestations(attestations []*spec.VersionedAt 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..08e41271 --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/electra" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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 index c9605a81..39749735 100644 --- a/http/submitexecutionpayloadbid.go +++ b/http/submitexecutionpayloadbid.go @@ -16,7 +16,6 @@ package http import ( "bytes" "context" - "encoding/json" "errors" "fmt" "strings" @@ -70,9 +69,7 @@ func (s *Service) SubmitExecutionPayloadBid(ctx context.Context, return s.postExecutionPayloadBid(ctx, &opts.Common, versioned.Version, bid) } -// SubmitAgnosticExecutionPayloadBid submits a signed execution payload bid -// supplied as a fork-agnostic *all.SignedExecutionPayloadBid for gossip -// broadcast. +// SubmitAgnosticExecutionPayloadBid submits a fork-agnostic execution payload bid. func (s *Service) SubmitAgnosticExecutionPayloadBid(ctx context.Context, opts *api.SubmitAgnosticExecutionPayloadBidOpts, ) error { @@ -93,38 +90,15 @@ func (s *Service) SubmitAgnosticExecutionPayloadBid(ctx context.Context, } // postExecutionPayloadBid marshals the bid to the negotiated content type -// (SSZ unless JSON is enforced) and performs the POST shared by both submit -// variants. +// (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 { - var ( - body []byte - contentType ContentType - err error - ) - - if s.enforceJSON { - contentType = ContentTypeJSON - - body, err = json.Marshal(bid) - if err != nil { - return errors.Join(errors.New("failed to marshal JSON"), err) - } - } else { - contentType = ContentTypeSSZ - - ds, dsErr := s.dynSSZForRequest(ctx) - if dsErr != nil { - return dsErr - } - - body, err = ds.MarshalSSZ(bid) - if err != nil { - return errors.Join(errors.New("failed to marshal SSZ"), err) - } + body, contentType, err := s.marshalRequestBody(ctx, bid) + if err != nil { + return err } endpoint := "/eth/v1/beacon/execution_payload_bids" diff --git a/http/submitexecutionpayloadbid_test.go b/http/submitexecutionpayloadbid_test.go new file mode 100644 index 00000000..5fadc12f --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" + "github.com/ethpandaops/go-eth2-client/http" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/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 index ca4870d3..4aa9e3bc 100644 --- a/http/submitexecutionpayloadenvelope.go +++ b/http/submitexecutionpayloadenvelope.go @@ -16,7 +16,6 @@ package http import ( "bytes" "context" - "encoding/json" "errors" "fmt" "strings" @@ -33,10 +32,10 @@ import ( // 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 (bare -// envelope, header false) only works when the beacon node cached the blobs -// and KZG proofs from its own block production, so builders publishing -// externally-built payloads must use the stateless form. +// 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 { @@ -54,20 +53,8 @@ func (s *Service) SubmitExecutionPayloadEnvelope(ctx context.Context, versioned := opts.SignedExecutionPayloadEnvelope - var contents any - switch versioned.Version { case spec.DataVersionGloas, spec.DataVersionHeze: - // All current envelope-bearing forks (Gloas, Heze) reuse the gloas schema. - if versioned.Gloas == nil { - return errors.Join(errors.New("no gloas envelope supplied"), client.ErrInvalidOptions) - } - - contents = &apiv1gloas.SignedExecutionPayloadEnvelopeContents{ - SignedExecutionPayloadEnvelope: versioned.Gloas, - KZGProofs: nonNilKZGProofs(opts.KZGProofs), - Blobs: nonNilBlobs(opts.Blobs), - } default: return errors.Join( fmt.Errorf("unsupported envelope version %s", versioned.Version), @@ -75,7 +62,23 @@ func (s *Service) SubmitExecutionPayloadEnvelope(ctx context.Context, ) } - body, contentType, err := s.submitExecutionPayloadEnvelopeData(ctx, contents) + 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 } @@ -84,9 +87,7 @@ func (s *Service) SubmitExecutionPayloadEnvelope(ctx context.Context, opts.BroadcastValidation, body, contentType) } -// SubmitAgnosticExecutionPayloadEnvelope submits a signed execution payload -// envelope supplied as a fork-agnostic *all.SignedExecutionPayloadEnvelope, -// using the same stateless request form as SubmitExecutionPayloadEnvelope. +// SubmitAgnosticExecutionPayloadEnvelope submits a fork-agnostic execution payload envelope. func (s *Service) SubmitAgnosticExecutionPayloadEnvelope(ctx context.Context, opts *api.SubmitAgnosticExecutionPayloadEnvelopeOpts, ) error { @@ -105,11 +106,17 @@ func (s *Service) SubmitAgnosticExecutionPayloadEnvelope(ctx context.Context, contents := &apiv1all.SignedExecutionPayloadEnvelopeContents{ Version: opts.SignedExecutionPayloadEnvelope.Version, SignedExecutionPayloadEnvelope: opts.SignedExecutionPayloadEnvelope, - KZGProofs: nonNilKZGProofs(opts.KZGProofs), - Blobs: nonNilBlobs(opts.Blobs), + 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.submitExecutionPayloadEnvelopeData(ctx, contents) + body, contentType, err := s.marshalRequestBody(ctx, contents) if err != nil { return err } @@ -118,8 +125,8 @@ func (s *Service) SubmitAgnosticExecutionPayloadEnvelope(ctx context.Context, opts.BroadcastValidation, body, contentType) } -// postExecutionPayloadEnvelope performs the POST shared by both submit -// variants, setting the consensus version and stateless-form headers. +// 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, @@ -136,12 +143,13 @@ func (s *Service) postExecutionPayloadEnvelope(ctx context.Context, headers := make(map[string]string) headers["Eth-Consensus-Version"] = strings.ToLower(consensusVersion.String()) - // Always the stateless form: Eth-Blob-Data-Included "true" selects the - // Contents body schema (beacon-APIs#624); strict consensus clients reject - // the request when the header is missing. The pre-#624 discriminator - // (Eth-Execution-Payload-Blinded "false" = Contents) is still sent for - // beacon nodes that have not adopted the rename yet. + // 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" + // 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 { @@ -150,56 +158,3 @@ func (s *Service) postExecutionPayloadEnvelope(ctx context.Context, return nil } - -// submitExecutionPayloadEnvelopeData marshals the envelope contents to the -// negotiated content type (SSZ unless JSON is enforced). -func (s *Service) submitExecutionPayloadEnvelopeData(ctx context.Context, - contents any, -) ( - []byte, - ContentType, - error, -) { - if s.enforceJSON { - body, err := json.Marshal(contents) - if err != nil { - return nil, ContentTypeUnknown, errors.Join(errors.New("failed to marshal JSON"), err) - } - - return body, ContentTypeJSON, nil - } - - ds, err := s.dynSSZForRequest(ctx) - if err != nil { - return nil, ContentTypeUnknown, err - } - - body, err := ds.MarshalSSZ(contents) - if err != nil { - return nil, ContentTypeUnknown, errors.Join(errors.New("failed to marshal SSZ"), err) - } - - return body, ContentTypeSSZ, nil -} - -// nonNilKZGProofs normalises a nil KZG proof slice to an empty one: the -// beacon-API schema requires kzg_proofs to be present (as an empty array -// when the payload carries no blobs). -func nonNilKZGProofs(proofs []deneb.KZGProof) []deneb.KZGProof { - if proofs == nil { - return []deneb.KZGProof{} - } - - return proofs -} - -// nonNilBlobs normalises a nil blob slice to an empty one: the beacon-API -// schema requires blobs to be present (as an empty array when the payload -// carries no blobs). -func nonNilBlobs(blobs []deneb.Blob) []deneb.Blob { - if blobs == nil { - return []deneb.Blob{} - } - - return blobs -} diff --git a/http/submitexecutionpayloadenvelope_test.go b/http/submitexecutionpayloadenvelope_test.go new file mode 100644 index 00000000..97a78888 --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" + "github.com/ethpandaops/go-eth2-client/http" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/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..87d99198 --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" + "github.com/ethpandaops/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..4adb367b --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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 5c819ba3..ac57dc6d 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,111 +73,39 @@ 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) - case spec.DataVersionGloas: - specJSON, err = json.Marshal(proposal.Gloas) - case spec.DataVersionHeze: - specJSON, err = json.Marshal(proposal.Heze) - 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: - specSSZ, err = proposal.Gloas.MarshalSSZ() + // 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 case spec.DataVersionHeze: - specSSZ, err = proposal.Heze.MarshalSSZ() + container = proposal.Heze 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..e125debd --- /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/ethpandaops/go-eth2-client/api" + eth2http "github.com/ethpandaops/go-eth2-client/http" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/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..6c7e4467 --- /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/ethpandaops/go-eth2-client/api" + apiv1deneb "github.com/ethpandaops/go-eth2-client/api/v1/deneb" + apiv1electra "github.com/ethpandaops/go-eth2-client/api/v1/electra" + apiv1fulu "github.com/ethpandaops/go-eth2-client/api/v1/fulu" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/altair" + "github.com/ethpandaops/go-eth2-client/spec/bellatrix" + "github.com/ethpandaops/go-eth2-client/spec/capella" + "github.com/ethpandaops/go-eth2-client/spec/deneb" + "github.com/ethpandaops/go-eth2-client/spec/electra" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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 313a47cd..3348687f 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/ethpandaops/go-eth2-client/api/v1/deneb" apiv1electra "github.com/ethpandaops/go-eth2-client/api/v1/electra" apiv1fulu "github.com/ethpandaops/go-eth2-client/api/v1/fulu" + "github.com/ethpandaops/go-eth2-client/http" "github.com/ethpandaops/go-eth2-client/spec" "github.com/ethpandaops/go-eth2-client/spec/altair" "github.com/ethpandaops/go-eth2-client/spec/bellatrix" "github.com/ethpandaops/go-eth2-client/spec/capella" "github.com/ethpandaops/go-eth2-client/spec/deneb" "github.com/ethpandaops/go-eth2-client/spec/electra" + "github.com/ethpandaops/go-eth2-client/spec/gloas" "github.com/ethpandaops/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..e9aee461 --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/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..b5358cbe --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" + clienthttp "github.com/ethpandaops/go-eth2-client/http" + "github.com/ethpandaops/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..59b4193c --- /dev/null +++ b/internal/eventdispatch/eventdispatch.go @@ -0,0 +1,264 @@ +// 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" + "encoding/json" + "errors" + "fmt" + "slices" + + "github.com/ethpandaops/go-eth2-client/api" + apiv1 "github.com/ethpandaops/go-eth2-client/api/v1" + "github.com/ethpandaops/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.InclusionListEvent](), + 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) + } + + // 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 +// 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..ecf07e5f --- /dev/null +++ b/internal/eventdispatch/eventdispatch_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 eventdispatch_test + +import ( + "bytes" + "context" + "maps" + "reflect" + "testing" + + "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" +) + +// 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") + delete(withSpecificHandlers, "inclusion_list") + 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("")) +} + +// 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/internal/eventtopic/eventtopic.go b/internal/eventtopic/eventtopic.go new file mode 100644 index 00000000..001355f9 --- /dev/null +++ b/internal/eventtopic/eventtopic.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 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" + "slices" + + "github.com/ethpandaops/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 + // versions lists the forks of the data of the topic's events, for a topic whose events the + // beacon-API spec wraps as {"version": "...", "data": {...}}, and is empty otherwise. + versions []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, versions ...spec.DataVersion) Topic[T] { + return Topic[T]{ + name: name, + versions: versions, + } +} + +// 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 { + if len(t.versions) == 0 { + return spec.DataVersionUnknown + } + + return t.versions[0] +} + +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 len(t.versions) > 0 { + 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 || !slices.Contains(t.versions, 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..88733521 --- /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/ethpandaops/go-eth2-client/internal/eventtopic" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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..3ba7b7bf --- /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/ethpandaops/go-eth2-client/api" + apiv1gloas "github.com/ethpandaops/go-eth2-client/api/v1/gloas" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/altair" + "github.com/ethpandaops/go-eth2-client/spec/bellatrix" + "github.com/ethpandaops/go-eth2-client/spec/capella" + "github.com/ethpandaops/go-eth2-client/spec/deneb" + "github.com/ethpandaops/go-eth2-client/spec/electra" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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..6bf58635 --- /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/ethpandaops/go-eth2-client/api" + "github.com/ethpandaops/go-eth2-client/mock" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/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..24ce9e3e --- /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/ethpandaops/go-eth2-client/api" + "github.com/ethpandaops/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..f7ac284f --- /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/ethpandaops/go-eth2-client/api" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/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..bdceaada --- /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/ethpandaops/go-eth2-client/api" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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 b63ec016..42aa8948 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..fc54cb64 --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" + apiv1 "github.com/ethpandaops/go-eth2-client/api/v1" + "github.com/ethpandaops/go-eth2-client/mock" + "github.com/ethpandaops/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..9c8b8122 --- /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/ethpandaops/go-eth2-client/api" + apiv1 "github.com/ethpandaops/go-eth2-client/api/v1" + "github.com/ethpandaops/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 c590ea7a..2fc4a5db 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 @@ -56,12 +56,13 @@ type Service struct { 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) - AgnosticBeaconStateFunc func(context.Context, *api.BeaconStateOpts) (*api.Response[*all.BeaconState], 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) @@ -70,15 +71,16 @@ type Service struct { 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) - AgnosticSignedBeaconBlockFunc func(context.Context, *api.SignedBeaconBlockOpts) (*api.Response[*all.SignedBeaconBlock], error) SignedExecutionPayloadEnvelopeFunc func(context.Context, *api.SignedExecutionPayloadEnvelopeOpts) (*api.Response[*spec.VersionedSignedExecutionPayloadEnvelope], error) - AgnosticSignedExecutionPayloadEnvelopeFunc func(context.Context, *api.SignedExecutionPayloadEnvelopeOpts) (*api.Response[*all.SignedExecutionPayloadEnvelope], 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) @@ -87,6 +89,9 @@ type Service struct { 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) + AgnosticBeaconStateFunc func(context.Context, *api.BeaconStateOpts) (*api.Response[*all.BeaconState], error) + AgnosticSignedBeaconBlockFunc func(context.Context, *api.SignedBeaconBlockOpts) (*api.Response[*all.SignedBeaconBlock], error) + AgnosticSignedExecutionPayloadEnvelopeFunc func(context.Context, *api.SignedExecutionPayloadEnvelopeOpts) (*api.Response[*all.SignedExecutionPayloadEnvelope], error) } // log is a service-wide logger. diff --git a/mock/signedexecutionpayloadenvelope.go b/mock/signedexecutionpayloadenvelope.go index 187bb9f7..4cecbbbf 100644 --- a/mock/signedexecutionpayloadenvelope.go +++ b/mock/signedexecutionpayloadenvelope.go @@ -1,4 +1,4 @@ -// Copyright © 2021 Attestant Limited. +// 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 @@ -59,8 +59,7 @@ func (s *Service) SignedExecutionPayloadEnvelope(ctx context.Context, }, nil } -// AgnosticSignedExecutionPayloadEnvelope returns a stub fork-agnostic signed -// execution payload envelope. +// AgnosticSignedExecutionPayloadEnvelope provides a fork-agnostic signed execution payload envelope. func (s *Service) AgnosticSignedExecutionPayloadEnvelope(ctx context.Context, opts *api.SignedExecutionPayloadEnvelopeOpts, ) ( diff --git a/mock/submitexecutionpayloadenvelope.go b/mock/submitexecutionpayloadenvelope.go index 923eaf07..77b6dbca 100644 --- a/mock/submitexecutionpayloadenvelope.go +++ b/mock/submitexecutionpayloadenvelope.go @@ -19,12 +19,14 @@ import ( "github.com/ethpandaops/go-eth2-client/api" ) -// SubmitExecutionPayloadEnvelope submits an execution payload envelope. +// SubmitExecutionPayloadEnvelope submits a signed execution payload envelope. func (*Service) SubmitExecutionPayloadEnvelope(_ context.Context, _ *api.SubmitExecutionPayloadEnvelopeOpts) error { return nil } // SubmitAgnosticExecutionPayloadEnvelope submits a fork-agnostic execution payload envelope. -func (*Service) SubmitAgnosticExecutionPayloadEnvelope(_ context.Context, _ *api.SubmitAgnosticExecutionPayloadEnvelopeOpts) error { +func (*Service) SubmitAgnosticExecutionPayloadEnvelope(_ context.Context, + _ *api.SubmitAgnosticExecutionPayloadEnvelopeOpts, +) error { return nil } diff --git a/mock/submitpayloadattestationmessages.go b/mock/submitpayloadattestationmessages.go new file mode 100644 index 00000000..027143cb --- /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/ethpandaops/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..c4ab7867 --- /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/ethpandaops/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..be8c8d4e --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/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 6c3699cf..068ca19f 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/ethpandaops/go-eth2-client" "github.com/ethpandaops/go-eth2-client/api" - apiv1 "github.com/ethpandaops/go-eth2-client/api/v1" - "github.com/ethpandaops/go-eth2-client/spec" - "github.com/ethpandaops/go-eth2-client/spec/altair" - "github.com/ethpandaops/go-eth2-client/spec/capella" - "github.com/ethpandaops/go-eth2-client/spec/electra" - "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/ethpandaops/go-eth2-client/http" + "github.com/ethpandaops/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..53490412 --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" + apiv1 "github.com/ethpandaops/go-eth2-client/api/v1" + "github.com/ethpandaops/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 c4222bff..d87b28a0 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/ethpandaops/go-eth2-client" "github.com/ethpandaops/go-eth2-client/api" + apiv1 "github.com/ethpandaops/go-eth2-client/api/v1" "github.com/ethpandaops/go-eth2-client/mock" "github.com/ethpandaops/go-eth2-client/multi" "github.com/ethpandaops/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..76ecb9d8 --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" + "github.com/ethpandaops/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 fc330ea8..ae803845 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..cbeb6585 --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" + "github.com/ethpandaops/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..9b626fbf --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" + "github.com/ethpandaops/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 b635d5f5..0c01bc21 100644 --- a/multi/proposerduties.go +++ b/multi/proposerduties.go @@ -15,12 +15,46 @@ package multi import ( "context" + "fmt" consensusclient "github.com/ethpandaops/go-eth2-client" "github.com/ethpandaops/go-eth2-client/api" apiv1 "github.com/ethpandaops/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 bf6de1d2..aa0bc198 100644 --- a/multi/proposerduties_test.go +++ b/multi/proposerduties_test.go @@ -19,13 +19,75 @@ import ( consensusclient "github.com/ethpandaops/go-eth2-client" "github.com/ethpandaops/go-eth2-client/api" + "github.com/ethpandaops/go-eth2-client/api/v1" "github.com/ethpandaops/go-eth2-client/mock" "github.com/ethpandaops/go-eth2-client/multi" + "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/ethpandaops/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..d2b839de --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" + apiv1 "github.com/ethpandaops/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 b32eb393..415cfe1a 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/ethpandaops/go-eth2-client" "github.com/ethpandaops/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 634b8087..a336f570 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/ethpandaops/go-eth2-client" "github.com/ethpandaops/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 index 2c3ab111..9a23d03e 100644 --- a/multi/signedexecutionpayloadenvelope.go +++ b/multi/signedexecutionpayloadenvelope.go @@ -1,4 +1,4 @@ -// Copyright © 2021 Attestant Limited. +// 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 @@ -30,12 +30,12 @@ func (s *Service) SignedExecutionPayloadEnvelope(ctx context.Context, error, ) { res, err := s.doCall(ctx, func(ctx context.Context, client consensusclient.Service) (any, error) { - block, err := client.(consensusclient.ExecutionPayloadProvider).SignedExecutionPayloadEnvelope(ctx, opts) + envelope, err := client.(consensusclient.ExecutionPayloadProvider).SignedExecutionPayloadEnvelope(ctx, opts) if err != nil { return nil, err } - return block, nil + return envelope, nil }, nil) if err != nil { return nil, err @@ -49,8 +49,7 @@ func (s *Service) SignedExecutionPayloadEnvelope(ctx context.Context, return response, nil } -// AgnosticSignedExecutionPayloadEnvelope fetches a signed execution payload -// envelope as a fork-agnostic *all.SignedExecutionPayloadEnvelope. +// AgnosticSignedExecutionPayloadEnvelope provides a fork-agnostic signed execution payload envelope. func (s *Service) AgnosticSignedExecutionPayloadEnvelope(ctx context.Context, opts *api.SignedExecutionPayloadEnvelopeOpts, ) ( diff --git a/multi/submitexecutionpayloadenvelope.go b/multi/submitexecutionpayloadenvelope.go index 15a10c75..6b61cfa4 100644 --- a/multi/submitexecutionpayloadenvelope.go +++ b/multi/submitexecutionpayloadenvelope.go @@ -20,7 +20,7 @@ import ( "github.com/ethpandaops/go-eth2-client/api" ) -// SubmitExecutionPayloadEnvelope submits an execution payload envelope. +// SubmitExecutionPayloadEnvelope submits a signed execution payload envelope. func (s *Service) SubmitExecutionPayloadEnvelope(ctx context.Context, opts *api.SubmitExecutionPayloadEnvelopeOpts, ) error { diff --git a/multi/submitpayloadattestationmessages.go b/multi/submitpayloadattestationmessages.go new file mode 100644 index 00000000..248b777d --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/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..b636cf5d --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/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..1114ff48 --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" + "github.com/ethpandaops/go-eth2-client/mock" + "github.com/ethpandaops/go-eth2-client/multi" + "github.com/ethpandaops/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..94e4efc7 --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/http" + "github.com/ethpandaops/go-eth2-client/mock" + "github.com/ethpandaops/go-eth2-client/multi" + "github.com/ethpandaops/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 ca1529bb..4771fdf7 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 @@ -356,6 +356,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 @@ -602,6 +653,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. @@ -780,3 +842,32 @@ type NodeClientProvider interface { // NodeClient provides the client for the node. NodeClient(ctx context.Context) (*api.Response[string], 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/all/aggregateandproof.go b/spec/all/aggregateandproof.go index 9591bb6a..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 @@ -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..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 @@ -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..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 @@ -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/beaconstate_ssz.go b/spec/all/beaconstate_ssz.go index 9204dc6a..62e97b40 100644 --- a/spec/all/beaconstate_ssz.go +++ b/spec/all/beaconstate_ssz.go @@ -1,5 +1,5 @@ // Code generated by dynamic-ssz. DO NOT EDIT. -// Hash: 86602ff0167a80ef510a6ad7cd1e68969f1d9b1408d7b6953d9decf0130574bd +// Hash: b7d9d10ac17a3f11379aa17d865becf7f2393c631cdcd719294529e7f07f33a5 // Version: v1.4.0-pre.3 (https://github.com/pk910/dynamic-ssz) package all @@ -2945,7 +2945,11 @@ func (t *BeaconState) marshalSSZView_BeaconState_6(ds sszutils.DynamicSpecs, buf err = sszutils.ErrSszSizeLimitFn("size expression (2+MIN_SEED_LOOKAHEAD)*SLOTS_PER_EPOCH", uint64(expr8), 1) return dst, err } - expr9, err := sszutils.ResolveSpecValueWithDefault(ds, "EPOCHS_PER_ETH1_VOTING_PERIOD*SLOTS_PER_EPOCH", 2048) + expr9, err := sszutils.ResolveSpecValueWithDefault(ds, "HISTORICAL_ROOTS_LIMIT", 16777216) + if err != nil { + return dst, err + } + expr10, err := sszutils.ResolveSpecValueWithDefault(ds, "EPOCHS_PER_ETH1_VOTING_PERIOD*SLOTS_PER_EPOCH", 2048) if err != nil { return dst, err } @@ -3260,8 +3264,8 @@ func (t *BeaconState) marshalSSZView_BeaconState_6(ds sszutils.DynamicSpecs, buf } t := t.HistoricalRoots vlen := len(t) - if vlen > 16777216 { - return nil, sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 16777216), "HistoricalRoots") + if uint64(vlen) > expr9 { + return nil, sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, expr9), "HistoricalRoots") } dst = sszutils.MarshalFixedBytesSlice(dst, t[:vlen]) } @@ -3273,8 +3277,8 @@ func (t *BeaconState) marshalSSZView_BeaconState_6(ds sszutils.DynamicSpecs, buf } t := t.ETH1DataVotes vlen := len(t) - if uint64(vlen) > expr9 { - return nil, sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, expr9), "ETH1DataVotes") + if uint64(vlen) > expr10 { + return nil, sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, expr10), "ETH1DataVotes") } for idx1 := range vlen { t := t[idx1] @@ -3356,8 +3360,8 @@ func (t *BeaconState) marshalSSZView_BeaconState_6(ds sszutils.DynamicSpecs, buf } t := t.HistoricalSummaries vlen := len(t) - if vlen > 16777216 { - return nil, sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 16777216), "HistoricalSummaries") + if uint64(vlen) > expr9 { + return nil, sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, expr9), "HistoricalSummaries") } for idx1 := range vlen { t := t[idx1] @@ -7413,7 +7417,11 @@ func (t *BeaconState) unmarshalSSZView_BeaconState_6(ds sszutils.DynamicSpecs, b err = sszutils.ErrSszSizeLimitFn("size expression (2+MIN_SEED_LOOKAHEAD)*SLOTS_PER_EPOCH", uint64(expr8), 1) return err } - expr9, err := sszutils.ResolveSpecValueWithDefault(ds, "EPOCHS_PER_ETH1_VOTING_PERIOD*SLOTS_PER_EPOCH", 2048) + expr9, err := sszutils.ResolveSpecValueWithDefault(ds, "HISTORICAL_ROOTS_LIMIT", 16777216) + if err != nil { + return err + } + expr10, err := sszutils.ResolveSpecValueWithDefault(ds, "EPOCHS_PER_ETH1_VOTING_PERIOD*SLOTS_PER_EPOCH", 2048) if err != nil { return err } @@ -7798,8 +7806,8 @@ func (t *BeaconState) unmarshalSSZView_BeaconState_6(ds sszutils.DynamicSpecs, b 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") + if uint64(itemCount) > expr9 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(itemCount, expr9), "HistoricalRoots") } val13 = sszutils.ExpandSlice(val13, itemCount) sszutils.UnmarshalFixedBytesSlice(val13[:itemCount], buf) @@ -7812,8 +7820,8 @@ func (t *BeaconState) unmarshalSSZView_BeaconState_6(ds sszutils.DynamicSpecs, b if len(buf)%72 != 0 { return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 72), "ETH1DataVotes") } - if uint64(itemCount) > expr9 { - return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(itemCount, expr9), "ETH1DataVotes") + if uint64(itemCount) > expr10 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(itemCount, expr10), "ETH1DataVotes") } val14 = sszutils.ExpandSlice(val14, itemCount) for idx1 := range itemCount { @@ -7897,8 +7905,8 @@ func (t *BeaconState) unmarshalSSZView_BeaconState_6(ds sszutils.DynamicSpecs, b 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") + if uint64(itemCount) > expr9 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(itemCount, expr9), "HistoricalSummaries") } val20 = sszutils.ExpandSlice(val20, itemCount) for idx1 := range itemCount { @@ -13312,80 +13320,84 @@ func (t *BeaconState) hashTreeRootView_BeaconState_6(ds sszutils.DynamicSpecs, h err = sszutils.ErrSszSizeLimitFn("size expression SLOTS_PER_HISTORICAL_ROOT", uint64(expr0), 32) return err } - expr1, err := sszutils.ResolveSpecValueWithDefault(ds, "EPOCHS_PER_ETH1_VOTING_PERIOD*SLOTS_PER_EPOCH", 2048) + expr1, err := sszutils.ResolveSpecValueWithDefault(ds, "HISTORICAL_ROOTS_LIMIT", 16777216) if err != nil { return err } - expr2, err := sszutils.ResolveSpecValueWithDefault(ds, "EPOCHS_PER_HISTORICAL_VECTOR", 65536) + expr2, err := sszutils.ResolveSpecValueWithDefault(ds, "EPOCHS_PER_ETH1_VOTING_PERIOD*SLOTS_PER_EPOCH", 2048) if err != nil { return err } - if expr2 > sszutils.MaxSszSize/32 { - err = sszutils.ErrSszSizeLimitFn("size expression EPOCHS_PER_HISTORICAL_VECTOR", uint64(expr2), 32) + expr3, err := sszutils.ResolveSpecValueWithDefault(ds, "EPOCHS_PER_HISTORICAL_VECTOR", 65536) + if err != nil { return err } - expr3, err := sszutils.ResolveSpecValueWithDefault(ds, "EPOCHS_PER_SLASHINGS_VECTOR", 8192) + if expr3 > sszutils.MaxSszSize/32 { + err = sszutils.ErrSszSizeLimitFn("size expression EPOCHS_PER_HISTORICAL_VECTOR", uint64(expr3), 32) + return err + } + expr4, err := sszutils.ResolveSpecValueWithDefault(ds, "EPOCHS_PER_SLASHINGS_VECTOR", 8192) if err != nil { return err } - if expr3 > sszutils.MaxSszSize/8 { - err = sszutils.ErrSszSizeLimitFn("size expression EPOCHS_PER_SLASHINGS_VECTOR", uint64(expr3), 8) + if expr4 > sszutils.MaxSszSize/8 { + err = sszutils.ErrSszSizeLimitFn("size expression EPOCHS_PER_SLASHINGS_VECTOR", uint64(expr4), 8) return err } - expr4, err := sszutils.ResolveSpecValueWithDefault(ds, "SYNC_COMMITTEE_SIZE", 512) + expr5, err := sszutils.ResolveSpecValueWithDefault(ds, "SYNC_COMMITTEE_SIZE", 512) if err != nil { return err } - if expr4 > sszutils.MaxSszSize/48 { - err = sszutils.ErrSszSizeLimitFn("size expression SYNC_COMMITTEE_SIZE", uint64(expr4), 48) + if expr5 > sszutils.MaxSszSize/48 { + err = sszutils.ErrSszSizeLimitFn("size expression SYNC_COMMITTEE_SIZE", uint64(expr5), 48) return err } - expr5, err := sszutils.ResolveSpecValueWithDefault(ds, "(MIN_SEED_LOOKAHEAD+1)*SLOTS_PER_EPOCH", 64) + expr6, err := sszutils.ResolveSpecValueWithDefault(ds, "(MIN_SEED_LOOKAHEAD+1)*SLOTS_PER_EPOCH", 64) if err != nil { return err } - if expr5 > sszutils.MaxSszSize/8 { - err = sszutils.ErrSszSizeLimitFn("size expression (MIN_SEED_LOOKAHEAD+1)*SLOTS_PER_EPOCH", uint64(expr5), 8) + if expr6 > sszutils.MaxSszSize/8 { + err = sszutils.ErrSszSizeLimitFn("size expression (MIN_SEED_LOOKAHEAD+1)*SLOTS_PER_EPOCH", uint64(expr6), 8) return err } - expr6, err := sszutils.ResolveSpecValueWithDefault(ds, "SLOTS_PER_HISTORICAL_ROOT/8", 1024) + expr7, err := sszutils.ResolveSpecValueWithDefault(ds, "SLOTS_PER_HISTORICAL_ROOT/8", 1024) if err != nil { return err } - if expr6 > sszutils.MaxSszSize { - err = sszutils.ErrSszSizeLimitFn("size expression SLOTS_PER_HISTORICAL_ROOT/8", uint64(expr6), 1) + if expr7 > sszutils.MaxSszSize { + err = sszutils.ErrSszSizeLimitFn("size expression SLOTS_PER_HISTORICAL_ROOT/8", uint64(expr7), 1) return err } - expr7, err := sszutils.ResolveSpecValueWithDefault(ds, "SLOTS_PER_EPOCH*2", 64) + expr8, err := sszutils.ResolveSpecValueWithDefault(ds, "SLOTS_PER_EPOCH*2", 64) if err != nil { return err } - if expr7 > sszutils.MaxSszSize/52 { - err = sszutils.ErrSszSizeLimitFn("size expression SLOTS_PER_EPOCH*2", uint64(expr7), 52) + if expr8 > sszutils.MaxSszSize/52 { + err = sszutils.ErrSszSizeLimitFn("size expression SLOTS_PER_EPOCH*2", uint64(expr8), 52) return err } - expr8, err := sszutils.ResolveSpecValueWithDefault(ds, "PTC_SIZE", 512) + expr9, err := sszutils.ResolveSpecValueWithDefault(ds, "PTC_SIZE", 512) if err != nil { return err } - if expr8 > sszutils.MaxSszSize/8 { - err = sszutils.ErrSszSizeLimitFn("size expression PTC_SIZE", uint64(expr8), 8) + if expr9 > sszutils.MaxSszSize/8 { + err = sszutils.ErrSszSizeLimitFn("size expression PTC_SIZE", uint64(expr9), 8) return err } - expr9, err := sszutils.ResolveSpecValueWithDefault(ds, "(2+MIN_SEED_LOOKAHEAD)*SLOTS_PER_EPOCH", 96) + expr10, err := sszutils.ResolveSpecValueWithDefault(ds, "(2+MIN_SEED_LOOKAHEAD)*SLOTS_PER_EPOCH", 96) if err != nil { return err } - if expr9 > sszutils.MaxSszSize { - err = sszutils.ErrSszSizeLimitFn("size expression (2+MIN_SEED_LOOKAHEAD)*SLOTS_PER_EPOCH", uint64(expr9), 1) + if expr10 > sszutils.MaxSszSize { + err = sszutils.ErrSszSizeLimitFn("size expression (2+MIN_SEED_LOOKAHEAD)*SLOTS_PER_EPOCH", uint64(expr10), 1) return err } - size1 := uint64(8) * uint64(expr8) + size1 := uint64(8) * uint64(expr9) if size1 > sszutils.MaxSszSize { return sszutils.NewSszErrorf(sszutils.SizeLimitSentinel(uint64(size1)), "vector byte size %d exceeds the SSZ size limit", size1) } - if size1 > 0 && expr9 > sszutils.MaxSszSize/size1 { - return sszutils.ErrSszSizeLimitFn("size expression (2+MIN_SEED_LOOKAHEAD)*SLOTS_PER_EPOCH", uint64(expr9), uint64(size1)) + if size1 > 0 && expr10 > sszutils.MaxSszSize/size1 { + return sszutils.ErrSszSizeLimitFn("size expression (2+MIN_SEED_LOOKAHEAD)*SLOTS_PER_EPOCH", uint64(expr10), uint64(size1)) } if t == nil { t = new(BeaconState) @@ -13463,8 +13475,8 @@ func (t *BeaconState) hashTreeRootView_BeaconState_6(ds sszutils.DynamicSpecs, h { // Field #7 'HistoricalRoots' t := t.HistoricalRoots vlen := uint64(len(t)) - if vlen > 16777216 { - return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 16777216), "HistoricalRoots") + if vlen > expr1 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, expr1), "HistoricalRoots") } idx := hh.StartTree(sszutils.TreeTypeBinary) for idx1 := range len(t) { @@ -13473,7 +13485,7 @@ func (t *BeaconState) hashTreeRootView_BeaconState_6(ds sszutils.DynamicSpecs, h hh.Collapse() } } - hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(16777216, vlen, 32)) + hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(expr1, vlen, 32)) } { // Field #8 'ETH1Data' t := t.ETH1Data @@ -13487,8 +13499,8 @@ func (t *BeaconState) hashTreeRootView_BeaconState_6(ds sszutils.DynamicSpecs, h { // Field #9 'ETH1DataVotes' t := t.ETH1DataVotes vlen := uint64(len(t)) - if vlen > expr1 { - return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, expr1), "ETH1DataVotes") + if vlen > expr2 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, expr2), "ETH1DataVotes") } idx := hh.StartTree(sszutils.TreeTypeBinary) for idx1 := range len(t) { @@ -13503,7 +13515,7 @@ func (t *BeaconState) hashTreeRootView_BeaconState_6(ds sszutils.DynamicSpecs, h hh.Collapse() } } - hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(expr1, vlen, 32)) + hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(expr2, vlen, 32)) } { // Field #10 'ETH1DepositIndex' hh.PutUint64(t.ETH1DepositIndex) @@ -13536,12 +13548,12 @@ func (t *BeaconState) hashTreeRootView_BeaconState_6(ds sszutils.DynamicSpecs, h { // Field #13 'RANDAOMixes' t := t.RANDAOMixes vlen := len(t) - if uint64(vlen) > expr2 { - return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, expr2), "RANDAOMixes") + if uint64(vlen) > expr3 { + return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, expr3), "RANDAOMixes") } idx := hh.StartTree(sszutils.TreeTypeBinary) var val3 *phase0.Root - for idx1 := range int(expr2) { + for idx1 := range int(expr3) { if idx1 < vlen { val3 = &t[idx1] } else if idx1 == vlen { @@ -13557,12 +13569,12 @@ func (t *BeaconState) hashTreeRootView_BeaconState_6(ds sszutils.DynamicSpecs, h { // Field #14 'Slashings' t := t.Slashings vlen := len(t) - if uint64(vlen) > expr3 { - return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, expr3), "Slashings") + if uint64(vlen) > expr4 { + return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, expr4), "Slashings") } idx := hh.StartTree(sszutils.TreeTypeBinary | sszutils.TreeTypePacked) var val4, val4Empty phase0.Gwei - for idx1 := range int(expr3) { + for idx1 := range int(expr4) { if idx1 < vlen { val4 = t[idx1] } else if idx1 == vlen { @@ -13653,12 +13665,12 @@ func (t *BeaconState) hashTreeRootView_BeaconState_6(ds sszutils.DynamicSpecs, h { // Field #0 'Pubkeys' t := t.Pubkeys vlen := len(t) - if uint64(vlen) > expr4 { - return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, expr4), "CurrentSyncCommittee.Pubkeys") + if uint64(vlen) > expr5 { + return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, expr5), "CurrentSyncCommittee.Pubkeys") } idx := hh.StartTree(sszutils.TreeTypeBinary) var val5 *phase0.BLSPubKey - for idx1 := range int(expr4) { + for idx1 := range int(expr5) { if idx1 < vlen { val5 = &t[idx1] } else if idx1 == vlen { @@ -13685,12 +13697,12 @@ func (t *BeaconState) hashTreeRootView_BeaconState_6(ds sszutils.DynamicSpecs, h { // Field #0 'Pubkeys' t := t.Pubkeys vlen := len(t) - if uint64(vlen) > expr4 { - return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, expr4), "NextSyncCommittee.Pubkeys") + if uint64(vlen) > expr5 { + return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, expr5), "NextSyncCommittee.Pubkeys") } idx := hh.StartTree(sszutils.TreeTypeBinary) var val6 *phase0.BLSPubKey - for idx1 := range int(expr4) { + for idx1 := range int(expr5) { if idx1 < vlen { val6 = &t[idx1] } else if idx1 == vlen { @@ -13720,8 +13732,8 @@ func (t *BeaconState) hashTreeRootView_BeaconState_6(ds sszutils.DynamicSpecs, h { // Field #27 'HistoricalSummaries' t := t.HistoricalSummaries vlen := uint64(len(t)) - if vlen > 16777216 { - return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 16777216), "HistoricalSummaries") + if vlen > expr1 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, expr1), "HistoricalSummaries") } idx := hh.StartTree(sszutils.TreeTypeBinary) for idx1 := range len(t) { @@ -13736,7 +13748,7 @@ func (t *BeaconState) hashTreeRootView_BeaconState_6(ds sszutils.DynamicSpecs, h hh.Collapse() } } - hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(16777216, vlen, 32)) + hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(expr1, vlen, 32)) } { // Field #28 'DepositRequestsStartIndex' hh.PutUint64(t.DepositRequestsStartIndex) @@ -13813,12 +13825,12 @@ func (t *BeaconState) hashTreeRootView_BeaconState_6(ds sszutils.DynamicSpecs, h { // Field #37 'ProposerLookahead' t := t.ProposerLookahead vlen := len(t) - if uint64(vlen) > expr5 { - return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, expr5), "ProposerLookahead") + if uint64(vlen) > expr6 { + return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, expr6), "ProposerLookahead") } idx := hh.StartTree(sszutils.TreeTypeBinary | sszutils.TreeTypePacked) var val7, val7Empty phase0.ValidatorIndex - for idx1 := range int(expr5) { + for idx1 := range int(expr6) { if idx1 < vlen { val7 = t[idx1] } else if idx1 == vlen { @@ -13854,24 +13866,24 @@ func (t *BeaconState) hashTreeRootView_BeaconState_6(ds sszutils.DynamicSpecs, h } { // Field #40 'ExecutionPayloadAvailability' vlen := len(t.ExecutionPayloadAvailability) - if uint64(vlen) > expr6 { - return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, expr6), "ExecutionPayloadAvailability") + if uint64(vlen) > expr7 { + return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, expr7), "ExecutionPayloadAvailability") } val := t.ExecutionPayloadAvailability[:vlen:vlen] - if uint64(vlen) < expr6 { - val = sszutils.AppendZeroPadding(val, (int(expr6)-vlen)*1) + if uint64(vlen) < expr7 { + val = sszutils.AppendZeroPadding(val, (int(expr7)-vlen)*1) } - hh.PutBytes(val[:int(expr6)]) + hh.PutBytes(val[:int(expr7)]) } { // Field #41 'BuilderPendingPayments' t := t.BuilderPendingPayments vlen := len(t) - if uint64(vlen) > expr7 { - return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, expr7), "BuilderPendingPayments") + if uint64(vlen) > expr8 { + return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, expr8), "BuilderPendingPayments") } idx := hh.StartTree(sszutils.TreeTypeBinary) var val8 *gloas.BuilderPendingPayment - for idx1 := range int(expr7) { + for idx1 := range int(expr8) { if idx1 < vlen { val8 = t[idx1] } else if idx1 == vlen { @@ -13941,24 +13953,24 @@ func (t *BeaconState) hashTreeRootView_BeaconState_6(ds sszutils.DynamicSpecs, h { // Field #45 'PTCWindow' t := t.PTCWindow vlen := len(t) - if uint64(vlen) > expr9 { - return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, expr9), "PTCWindow") + if uint64(vlen) > expr10 { + return sszutils.ErrorWithPath(sszutils.ErrVectorLengthFn(vlen, expr10), "PTCWindow") } idx := hh.StartTree(sszutils.TreeTypeBinary) var val9, val9Empty []phase0.ValidatorIndex - for idx1 := range int(expr9) { + for idx1 := range int(expr10) { if idx1 < vlen { val9 = t[idx1] } else if idx1 == vlen { val9 = val9Empty } vlen := len(val9) - if uint64(vlen) > expr8 { - return sszutils.ErrorWithPathf(sszutils.ErrVectorLengthFn(vlen, expr8), "PTCWindow[%d]", idx1) + if uint64(vlen) > expr9 { + return sszutils.ErrorWithPathf(sszutils.ErrVectorLengthFn(vlen, expr9), "PTCWindow[%d]", idx1) } idx := hh.StartTree(sszutils.TreeTypeBinary | sszutils.TreeTypePacked) var val10, val10Empty phase0.ValidatorIndex - for idx2 := range int(expr8) { + for idx2 := range int(expr9) { if idx2 < vlen { val10 = val9[idx2] } else if idx2 == vlen { 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/dataversion_test.go b/spec/dataversion_test.go new file mode 100644 index 00000000..c82f9633 --- /dev/null +++ b/spec/dataversion_test.go @@ -0,0 +1,64 @@ +// 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/ethpandaops/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"`}, + {name: "Heze", version: spec.DataVersionHeze, expected: `"heze"`}, + // A fork added to the enum but not to the string table lands here. + {name: "PastLastVersion", version: spec.DataVersionHeze + 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 index 47f998ba..85d2b80e 100644 --- a/spec/gloas/aggregateandproof.go +++ b/spec/gloas/aggregateandproof.go @@ -1,4 +1,4 @@ -// Copyright © 2024 Attestant Limited. +// 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 diff --git a/spec/gloas/attestation.go b/spec/gloas/attestation.go index 45995be1..e04ad08b 100644 --- a/spec/gloas/attestation.go +++ b/spec/gloas/attestation.go @@ -1,4 +1,4 @@ -// Copyright © 2024 Attestant Limited. +// 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 @@ -32,7 +32,14 @@ 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 bitfield.Bitvector64 `ssz-index:"3" dynssz-size:"MAX_COMMITTEES_PER_SLOT/8" ssz-size:"8"` + // 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. diff --git a/spec/gloas/attestation_test.go b/spec/gloas/attestation_test.go index 973c137c..1dd2c036 100644 --- a/spec/gloas/attestation_test.go +++ b/spec/gloas/attestation_test.go @@ -17,7 +17,7 @@ import ( "testing" "github.com/OffchainLabs/go-bitfield" - "github.com/ethpandaops/go-eth2-client/spec/electra" + "github.com/ethpandaops/go-eth2-client/spec/gloas" "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -69,7 +69,7 @@ func TestAttestation_CommitteeIndex(t *testing.T) { committeeBits.SetBitAt(uint64(expectedIndex), true) } } - attestation := &electra.Attestation{CommitteeBits: committeeBits} + attestation := &gloas.Attestation{CommitteeBits: committeeBits} committeeIndex, err := attestation.CommitteeIndex() if tt.errorMsg == "" { @@ -131,7 +131,7 @@ func TestAttestation_AggregateValidatorIndex(t *testing.T) { aggregateBits.SetBitAt(uint64(expectedIndex), true) } } - attestation := &electra.Attestation{AggregationBits: aggregateBits} + attestation := &gloas.Attestation{AggregationBits: aggregateBits} aggregateValidatorIndex, err := attestation.AggregateValidatorIndex() if tt.errorMsg == "" { @@ -152,7 +152,7 @@ func TestAttestation_SSZ(t *testing.T) { aggregateBits := bitfield.NewBitlist(aggregateSize) committeeSize := uint64(64) committeeBits := bitfield.NewBitvector64() - attestation := electra.Attestation{ + attestation := gloas.Attestation{ AggregationBits: aggregateBits, CommitteeBits: committeeBits, Data: &phase0.AttestationData{ diff --git a/spec/gloas/attesterslashing.go b/spec/gloas/attesterslashing.go index 141d8ed5..0d37cddf 100644 --- a/spec/gloas/attesterslashing.go +++ b/spec/gloas/attesterslashing.go @@ -1,4 +1,4 @@ -// Copyright © 2024 Attestant Limited. +// 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 diff --git a/spec/gloas/beaconblock.go b/spec/gloas/beaconblock.go index bed329c2..a04ba269 100644 --- a/spec/gloas/beaconblock.go +++ b/spec/gloas/beaconblock.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 diff --git a/spec/gloas/beaconblock_json.go b/spec/gloas/beaconblock_json.go index 517f7fdf..3541466e 100644 --- a/spec/gloas/beaconblock_json.go +++ b/spec/gloas/beaconblock_json.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 diff --git a/spec/gloas/beaconblock_yaml.go b/spec/gloas/beaconblock_yaml.go index 6e8f1ccd..96e55692 100644 --- a/spec/gloas/beaconblock_yaml.go +++ b/spec/gloas/beaconblock_yaml.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 @@ -16,26 +16,17 @@ package gloas import ( "bytes" "encoding/json" + "fmt" - "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/goccy/go-yaml" "github.com/pkg/errors" ) -// beaconBlockYAML is the spec representation of the struct. -type beaconBlockYAML struct { - Slot uint64 `yaml:"slot"` - ProposerIndex uint64 `yaml:"proposer_index"` - ParentRoot phase0.Root `yaml:"parent_root"` - StateRoot phase0.Root `yaml:"state_root"` - Body *BeaconBlockBody `yaml:"body"` -} - // MarshalYAML implements yaml.Marshaler. func (b *BeaconBlock) MarshalYAML() ([]byte, error) { - yamlBytes, err := yaml.MarshalWithOptions(&beaconBlockYAML{ - Slot: uint64(b.Slot), - ProposerIndex: uint64(b.ProposerIndex), + 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, diff --git a/spec/gloas/beaconblockbody.go b/spec/gloas/beaconblockbody.go index 3725fe4a..177cc43f 100644 --- a/spec/gloas/beaconblockbody.go +++ b/spec/gloas/beaconblockbody.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 @@ -24,9 +24,9 @@ import ( // BeaconBlockBody represents the body of a beacon block for EIP-7732. type BeaconBlockBody struct { - RANDAOReveal phase0.BLSSignature `ssz-index:"0"` + RANDAOReveal phase0.BLSSignature `ssz-index:"0" ssz-size:"96"` ETH1Data *phase0.ETH1Data `ssz-index:"1"` - Graffiti [32]byte `ssz-index:"2"` + 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"` diff --git a/spec/gloas/beaconblockbody_json.go b/spec/gloas/beaconblockbody_json.go index 988d59e6..b4df8ac6 100644 --- a/spec/gloas/beaconblockbody_json.go +++ b/spec/gloas/beaconblockbody_json.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 @@ -76,6 +76,9 @@ func (b *BeaconBlockBody) UnmarshalJSON(input []byte) error { 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 @@ -88,6 +91,9 @@ func (b *BeaconBlockBody) UnmarshalJSON(input []byte) error { 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 diff --git a/spec/gloas/beaconblockbody_yaml.go b/spec/gloas/beaconblockbody_yaml.go index 3044be07..3fc8c73a 100644 --- a/spec/gloas/beaconblockbody_yaml.go +++ b/spec/gloas/beaconblockbody_yaml.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 diff --git a/spec/gloas/beaconstate.go b/spec/gloas/beaconstate.go index 780c20bd..05343ff2 100644 --- a/spec/gloas/beaconstate.go +++ b/spec/gloas/beaconstate.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 @@ -27,51 +27,51 @@ import ( // 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"` + 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 `dynssz-max:"HISTORICAL_ROOTS_LIMIT" 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 `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 `ssz-index:"37" dynssz-size:"(MIN_SEED_LOOKAHEAD+1)*SLOTS_PER_EPOCH" ssz-size:"64"` - Builders []*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 []*Builder `ssz-index:"38" ssz-type:"progressive-list"` NextWithdrawalBuilderIndex BuilderIndex `ssz-index:"39"` - ExecutionPayloadAvailability []uint8 `ssz-index:"40" dynssz-size:"SLOTS_PER_HISTORICAL_ROOT/8" ssz-size:"1024"` - BuilderPendingPayments []*BuilderPendingPayment `ssz-index:"41" dynssz-size:"SLOTS_PER_EPOCH*2" ssz-size:"64"` - BuilderPendingWithdrawals []*BuilderPendingWithdrawal `ssz-index:"42" ssz-type:"progressive-list"` + 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 `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/gloas/beaconstate_json.go b/spec/gloas/beaconstate_json.go index 8ad86fc3..8ac8d4a3 100644 --- a/spec/gloas/beaconstate_json.go +++ b/spec/gloas/beaconstate_json.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 @@ -313,6 +313,9 @@ func (b *BeaconState) UnmarshalJSON(input []byte) error { 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 { @@ -401,6 +404,9 @@ func (b *BeaconState) UnmarshalJSON(input []byte) error { 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") @@ -438,11 +444,25 @@ func (b *BeaconState) UnmarshalJSON(input []byte) error { } } - // The beacon API convention quotes uint64 values, but lighthouse serves the - // ptc_window indices as bare JSON numbers — accept either form. + // 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 { - return errors.Wrap(err, "ptc_window") + // 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 []json.RawMessage `json:"validator_indices"` + }, 0) + if wrappedErr := json.Unmarshal(raw["ptc_window"], &wrapped); wrappedErr != nil { + return errors.Wrap(err, "ptc_window") + } + ptcWindowRaw = make([][]json.RawMessage, len(wrapped)) + for i := range wrapped { + ptcWindowRaw[i] = wrapped[i].ValidatorIndices + } } b.PTCWindow = make([][]phase0.ValidatorIndex, len(ptcWindowRaw)) for i := range ptcWindowRaw { diff --git a/spec/gloas/beaconstate_ssz.go b/spec/gloas/beaconstate_ssz.go index 0f9c786c..1491ae60 100644 --- a/spec/gloas/beaconstate_ssz.go +++ b/spec/gloas/beaconstate_ssz.go @@ -1,5 +1,5 @@ // Code generated by dynamic-ssz. DO NOT EDIT. -// Hash: cf09d9ed1e81a6ccb9bb8b05096fea998d1b511361cfae173c60ac12a50965f0 +// Hash: 4b8c4909a32b5422ed25bebba0b0825e3c998bd5334686db4d01a59b0128eaca // Version: v1.4.0-pre.3 (https://github.com/pk910/dynamic-ssz) package gloas diff --git a/spec/gloas/beaconstate_test.go b/spec/gloas/beaconstate_test.go index abbfd297..2ef8ae8a 100644 --- a/spec/gloas/beaconstate_test.go +++ b/spec/gloas/beaconstate_test.go @@ -15,191 +15,323 @@ package gloas_test import ( "encoding/json" - "strings" + "regexp" "testing" bitfield "github.com/OffchainLabs/go-bitfield" "github.com/ethpandaops/go-eth2-client/spec/altair" + "github.com/ethpandaops/go-eth2-client/spec/bellatrix" "github.com/ethpandaops/go-eth2-client/spec/capella" "github.com/ethpandaops/go-eth2-client/spec/deneb" + "github.com/ethpandaops/go-eth2-client/spec/electra" "github.com/ethpandaops/go-eth2-client/spec/gloas" "github.com/ethpandaops/go-eth2-client/spec/phase0" - "github.com/stretchr/testify/require" + require "github.com/stretchr/testify/require" ) -// testBeaconState builds a small but fully-populated state so the JSON -// round-trip exercises every field's encoder and decoder. -func testBeaconState() *gloas.BeaconState { - var pubkey phase0.BLSPubKey - pubkey[0] = 0xaa +// 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{} - syncCommittee := &altair.SyncCommittee{ - Pubkeys: []phase0.BLSPubKey{pubkey}, - AggregatePubkey: pubkey, + 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, } - - return &gloas.BeaconState{ - GenesisTime: 1784030400, - GenesisValidatorsRoot: phase0.Root{0x01}, - Slot: 25736, - Fork: &phase0.Fork{ - PreviousVersion: phase0.Version{0x70, 0x66, 0x95, 0x68}, - CurrentVersion: phase0.Version{0x80, 0x66, 0x95, 0x68}, - Epoch: 38, - }, - LatestBlockHeader: &phase0.BeaconBlockHeader{ - Slot: 25735, - ProposerIndex: 3, - ParentRoot: phase0.Root{0x02}, - StateRoot: phase0.Root{0x03}, - BodyRoot: phase0.Root{0x04}, - }, - BlockRoots: []phase0.Root{{0x05}}, - StateRoots: []phase0.Root{{0x06}}, - HistoricalRoots: []phase0.Root{{0x07}}, - ETH1Data: &phase0.ETH1Data{ - DepositRoot: phase0.Root{0x08}, - DepositCount: 1, - BlockHash: make([]byte, 32), - }, - ETH1DataVotes: []*phase0.ETH1Data{}, - ETH1DepositIndex: 1, - Validators: []*phase0.Validator{ - { - PublicKey: pubkey, - WithdrawalCredentials: make([]byte, 32), - EffectiveBalance: 32000000000, - ActivationEligibilityEpoch: 0, - ActivationEpoch: 0, - ExitEpoch: phase0.Epoch(0xffffffffffffffff), - WithdrawableEpoch: phase0.Epoch(0xffffffffffffffff), - }, - }, - Balances: []phase0.Gwei{32000000000}, - RANDAOMixes: []phase0.Root{{0x09}}, - Slashings: []phase0.Gwei{0}, - PreviousEpochParticipation: []altair.ParticipationFlags{7}, - CurrentEpochParticipation: []altair.ParticipationFlags{3}, - JustificationBits: bitfield.Bitvector4{0x0f}, - PreviousJustifiedCheckpoint: &phase0.Checkpoint{Epoch: 802, Root: phase0.Root{0x0a}}, - CurrentJustifiedCheckpoint: &phase0.Checkpoint{Epoch: 803, Root: phase0.Root{0x0b}}, - FinalizedCheckpoint: &phase0.Checkpoint{Epoch: 802, Root: phase0.Root{0x0c}}, - InactivityScores: []uint64{0}, - CurrentSyncCommittee: syncCommittee, - NextSyncCommittee: syncCommittee, - LatestBlockHash: phase0.Hash32{0x0d}, - NextWithdrawalIndex: 15638, - NextWithdrawalValidatorIndex: 470013, - HistoricalSummaries: []*capella.HistoricalSummary{}, - DepositRequestsStartIndex: 0, - DepositBalanceToConsume: 0, - ExitBalanceToConsume: 256000000000, - EarliestExitEpoch: 804, - ConsolidationBalanceToConsume: 0, - EarliestConsolidationEpoch: 805, - ProposerLookahead: []phase0.ValidatorIndex{1039, 503062}, - Builders: []*gloas.Builder{ - { - PublicKey: pubkey, - Version: 1, - Balance: 32000000000, - DepositEpoch: 12, - WithdrawableEpoch: 18, - }, - }, - NextWithdrawalBuilderIndex: 0, - // A bitvector: one bit per historical slot, hex-encoded on the wire. - ExecutionPayloadAvailability: []uint8{0xff, 0x7f, 0x00, 0xbf}, - BuilderPendingPayments: []*gloas.BuilderPendingPayment{ - { - Weight: 0, - Withdrawal: &gloas.BuilderPendingWithdrawal{ - Amount: 0, - BuilderIndex: 0, - }, - ProposerIndex: 0, - }, - }, - BuilderPendingWithdrawals: []*gloas.BuilderPendingWithdrawal{}, - LatestExecutionPayloadBid: &gloas.ExecutionPayloadBid{ - ParentBlockHash: phase0.Hash32{0x0e}, - ParentBlockRoot: phase0.Root{0x0f}, - BlockHash: phase0.Hash32{0x10}, - PrevRandao: phase0.Root{0x11}, - GasLimit: 60000000, - BuilderIndex: 0, - Slot: 25736, - Value: 0, - ExecutionPayment: 0, - BlobKZGCommitments: []deneb.KZGCommitment{}, - }, - PayloadExpectedWithdrawals: []*capella.Withdrawal{ - { - Index: 15637, - ValidatorIndex: 1099511627778, - Amount: 128018656, - }, - }, - PTCWindow: [][]phase0.ValidatorIndex{ - {1261, 1969, 503057}, - {323, 2478}, + 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 } -func TestBeaconStateJSONRoundTrip(t *testing.T) { - state := testBeaconState() +// 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() - encoded, err := json.Marshal(state) - require.NoError(t, err) + 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"` - // Bitvectors are hex strings on the wire, not per-byte arrays. - require.Contains(t, string(encoded), `"execution_payload_availability":"0xff7f00bf"`) - // uint64 values are quoted per the beacon API convention. - require.Contains(t, string(encoded), `"ptc_window":[["1261","1969","503057"],["323","2478"]]`) + state := populatedBeaconState() + input := mustMarshal(t, state) + require.Equal(t, availability, rawField(t, input, "execution_payload_availability")) - decoded := &gloas.BeaconState{} - require.NoError(t, json.Unmarshal(encoded, decoded)) + var decoded gloas.BeaconState + require.NoError(t, json.Unmarshal([]byte(input), &decoded)) require.Equal(t, state.ExecutionPayloadAvailability, decoded.ExecutionPayloadAvailability) - require.Equal(t, state.PTCWindow, decoded.PTCWindow) - require.Equal(t, state.Builders, decoded.Builders) } -// TestBeaconStateJSONLighthouse checks the encodings lighthouse actually -// serves where they diverge from the quoted-integer convention. -func TestBeaconStateJSONLighthouse(t *testing.T) { - state := testBeaconState() +// 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() - encoded, err := json.Marshal(state) + data, err := state.MarshalYAML() require.NoError(t, err) - // Lighthouse serves ptc_window indices as bare JSON numbers. - lighthouse := strings.Replace( - string(encoded), - `"ptc_window":[["1261","1969","503057"],["323","2478"]]`, - `"ptc_window":[[1261,1969,503057],[323,2478]]`, - 1, - ) - require.NotEqual(t, string(encoded), lighthouse) - - decoded := &gloas.BeaconState{} - require.NoError(t, json.Unmarshal([]byte(lighthouse), decoded)) - require.Equal(t, state.PTCWindow, decoded.PTCWindow) + var decoded gloas.BeaconState + require.NoError(t, decoded.UnmarshalYAML(data)) + require.Equal(t, state.ExecutionPayloadAvailability, decoded.ExecutionPayloadAvailability) } -func TestBeaconStateJSONInvalidAvailability(t *testing.T) { - state := testBeaconState() +// 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"]]`, + }, + { + // Lighthouse serves the indices as bare JSON numbers. + name: "LighthouseBareNumbers", + 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"]}`, + // 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. + name: "PrysmObjectWrapperBadIndex", + ptcWindow: `[{"validator_indices":["fifty-seven"]}]`, + err: `ptc_window[0][0]: strconv.ParseUint: parsing "fifty-seven": invalid syntax`, + }, + } - encoded, err := json.Marshal(state) - require.NoError(t, err) + 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) - invalid := strings.Replace( - string(encoded), - `"execution_payload_availability":"0xff7f00bf"`, - `"execution_payload_availability":"0xzz"`, - 1, - ) + var decoded gloas.BeaconState + err = json.Unmarshal(input, &decoded) + if test.err != "" { + require.ErrorContains(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) + }) + } - decoded := &gloas.BeaconState{} - require.ErrorContains(t, json.Unmarshal([]byte(invalid), decoded), "execution_payload_availability") + // 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 index eb1eed5b..9a144de9 100644 --- a/spec/gloas/beaconstate_yaml.go +++ b/spec/gloas/beaconstate_yaml.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 diff --git a/spec/gloas/builder.go b/spec/gloas/builder.go index aeae43b5..5d4ff678 100644 --- a/spec/gloas/builder.go +++ b/spec/gloas/builder.go @@ -1,4 +1,4 @@ -// Copyright © 2020 Attestant Limited. +// 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 @@ -39,12 +39,37 @@ type Builder struct { // builderJSON is the spec representation of the struct. type builderJSON struct { - PublicKey string `json:"pubkey"` - Version string `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. @@ -52,16 +77,16 @@ type builderYAML struct { PublicKey string `yaml:"pubkey"` Version uint8 `yaml:"version"` ExecutionAddress string `yaml:"execution_address"` - Balance uint64 `yaml:"balance"` - DepositEpoch uint64 `yaml:"deposit_epoch"` - WithdrawableEpoch uint64 `yaml:"withdrawable_epoch"` + 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: fmt.Sprintf("%d", v.Version), + Version: builderVersionJSON(v.Version), ExecutionAddress: v.ExecutionAddress.String(), Balance: fmt.Sprintf("%d", v.Balance), DepositEpoch: fmt.Sprintf("%d", v.DepositEpoch), @@ -95,16 +120,7 @@ func (v *Builder) unpack(builderJSON *builderJSON) error { copy(v.PublicKey[:], publicKey) - if builderJSON.Version == "" { - return errors.New("version missing") - } - - version, err := strconv.ParseUint(builderJSON.Version, 10, 8) - if err != nil { - return errors.Wrap(err, "invalid value for version") - } - - v.Version = uint8(version) + v.Version = uint8(builderJSON.Version) if builderJSON.ExecutionAddress == "" { return errors.New("execution address missing") @@ -167,9 +183,9 @@ func (v *Builder) MarshalYAML() ([]byte, error) { PublicKey: fmt.Sprintf("%#x", v.PublicKey), Version: v.Version, ExecutionAddress: v.ExecutionAddress.String(), - Balance: uint64(v.Balance), - DepositEpoch: uint64(v.DepositEpoch), - WithdrawableEpoch: uint64(v.WithdrawableEpoch), + 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 diff --git a/spec/gloas/builder_test.go b/spec/gloas/builder_test.go index 03122348..cce3af1a 100644 --- a/spec/gloas/builder_test.go +++ b/spec/gloas/builder_test.go @@ -15,70 +15,77 @@ package gloas_test import ( "encoding/json" + "strings" "testing" + "github.com/ethpandaops/go-eth2-client/spec/bellatrix" "github.com/ethpandaops/go-eth2-client/spec/gloas" - "github.com/stretchr/testify/require" + "github.com/ethpandaops/go-eth2-client/spec/phase0" + require "github.com/stretchr/testify/require" ) -func TestBuilderJSON(t *testing.T) { - // Beacon API JSON encodes all integers as strings. - input := `{"pubkey":"0xa99a76ed7796f7be22d5b7e85deeb7c5677e88e511e0b337618f8c4eb61349b4bf2d153f649f7b53359fe8b94a38e44c","version":"1","execution_address":"0x000102030405060708090a0b0c0d0e0f10111213","balance":"32000000000","deposit_epoch":"12","withdrawable_epoch":"18446744073709551615"}` +// 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, + } +} - var builder gloas.Builder - require.NoError(t, json.Unmarshal([]byte(input), &builder)) - require.Equal(t, uint8(1), builder.Version) +// 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() - // Round-trip. - output, err := json.Marshal(&builder) - require.NoError(t, err) - require.JSONEq(t, input, string(output)) + var decoded gloas.Builder + require.NoError(t, json.Unmarshal([]byte(mustMarshal(t, builder)), &decoded)) + require.Equal(t, builder, &decoded) } -func TestBuilderJSONInvalidVersion(t *testing.T) { - tests := []struct { - name string - input string - errorMsg string - }{ - { - name: "missing", - input: `{"pubkey":"0xa99a76ed7796f7be22d5b7e85deeb7c5677e88e511e0b337618f8c4eb61349b4bf2d153f649f7b53359fe8b94a38e44c","execution_address":"0x000102030405060708090a0b0c0d0e0f10111213","balance":"32000000000","deposit_epoch":"12","withdrawable_epoch":"13"}`, - errorMsg: "version missing", - }, - { - name: "not a number", - input: `{"pubkey":"0xa99a76ed7796f7be22d5b7e85deeb7c5677e88e511e0b337618f8c4eb61349b4bf2d153f649f7b53359fe8b94a38e44c","version":"banana","execution_address":"0x000102030405060708090a0b0c0d0e0f10111213","balance":"32000000000","deposit_epoch":"12","withdrawable_epoch":"13"}`, - errorMsg: "invalid value for version", - }, - { - name: "out of range for uint8", - input: `{"pubkey":"0xa99a76ed7796f7be22d5b7e85deeb7c5677e88e511e0b337618f8c4eb61349b4bf2d153f649f7b53359fe8b94a38e44c","version":"256","execution_address":"0x000102030405060708090a0b0c0d0e0f10111213","balance":"32000000000","deposit_epoch":"12","withdrawable_epoch":"13"}`, - errorMsg: "invalid value for version", - }, - } +// 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() - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - var builder gloas.Builder - err := json.Unmarshal([]byte(test.input), &builder) - require.ErrorContains(t, err, test.errorMsg) - }) - } + 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) } -func TestBuilderYAML(t *testing.T) { - input := `{"pubkey":"0xa99a76ed7796f7be22d5b7e85deeb7c5677e88e511e0b337618f8c4eb61349b4bf2d153f649f7b53359fe8b94a38e44c","version":"1","execution_address":"0x000102030405060708090a0b0c0d0e0f10111213","balance":"32000000000","deposit_epoch":"12","withdrawable_epoch":"13"}` +// 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() - var builder gloas.Builder - require.NoError(t, json.Unmarshal([]byte(input), &builder)) + quoted := strings.Replace(mustMarshal(t, builder), `"version":1`, `"version":"1"`, 1) + require.Contains(t, quoted, `"version":"1"`) - yamlBytes, err := builder.MarshalYAML() - require.NoError(t, err) - // The consensus spec vectors write version as a bare integer in YAML. - require.Contains(t, string(yamlBytes), "version: 1") + var decoded gloas.Builder + require.NoError(t, json.Unmarshal([]byte(quoted), &decoded)) + require.Equal(t, builder, &decoded) - var roundTripped gloas.Builder - require.NoError(t, roundTripped.UnmarshalYAML(yamlBytes)) - require.Equal(t, builder, roundTripped) + require.Error(t, json.Unmarshal([]byte(`{"pubkey":"0x00","version":"x"}`), &decoded)) } diff --git a/spec/gloas/builderconfig.go b/spec/gloas/builderconfig.go new file mode 100644 index 00000000..4f0ec10c --- /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/ethpandaops/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..cb20c8b3 --- /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/ethpandaops/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..49ff27ce --- /dev/null +++ b/spec/gloas/builderconfig_ssz.go @@ -0,0 +1,214 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 5855899fc55f4c94c0896e3fe38ee3a1b79ad1b89396c9fd3fad45dae78f2c84 +// Version: v1.4.0-pre.3 (https://github.com/pk910/dynamic-ssz) + +package gloas + +import ( + "encoding/binary" + "math" + + "github.com/ethpandaops/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' + if off := uint64(len(dst)) - uint64(dstlen); off > math.MaxUint32 { + return nil, sszutils.ErrOffsetOverflowFn(off) + } else { + binary.LittleEndian.PutUint32(dst[dstlen+16:], uint32(off)) + } + 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 { + if off := uint64(len(dst)) - uint64(dstlen); off > math.MaxUint32 { + return nil, sszutils.ErrOffsetOverflowFn(off) + } else { + binary.LittleEndian.PutUint32(dst[dstlen+(idx1*4):], uint32(off)) + } + 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 := uint64(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 := 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") + } + firstOffset := binary.LittleEndian.Uint32(buf[0:4]) + if firstOffset%4 != 0 || uint64(firstOffset) > uint64(len(buf)) || firstOffset == 0 { + return sszutils.ErrorWithPath(sszutils.ErrInvalidListStartOffsetFn(firstOffset, len(buf)), "Builders") + } + startOffset = int(firstOffset) + } + itemCount := startOffset / 4 + if itemCount > 64 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(itemCount, 64), "Builders") + } + if itemCount > (len(buf)-startOffset)/(36) { + return sszutils.ErrorWithPath(sszutils.ErrListRegionTooSmallFn(itemCount, 36, len(buf)-startOffset), "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() int { + var size int64 + 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' + t2 := t.Builders + vlen := len(t2) + size += int64(vlen) * 4 // Offsets + for i1 := range vlen { + s1 := int64(t2[i1].SizeSSZ()) + if s1 < 0 { + return -1 + } + size += s1 + } + } + if uint64(size) > sszutils.MaxSszSize { + return -1 + } + return int(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 len(t) { + 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..e5022488 --- /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/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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/builderentry_ssz.go b/spec/gloas/builderentry_ssz.go new file mode 100644 index 00000000..ee5baaa4 --- /dev/null +++ b/spec/gloas/builderentry_ssz.go @@ -0,0 +1,251 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: b15e6f7ac4ab787e6cf7cdb52f3c0b9923b1f12c6e60670e87e25de98715b3b6 +// Version: v1.4.0-pre.3 (https://github.com/pk910/dynamic-ssz) + +package gloas + +import ( + "encoding/binary" + "math" + + "github.com/ethpandaops/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' + if off := uint64(len(dst)) - uint64(dstlen); off > math.MaxUint32 { + return nil, sszutils.ErrOffsetOverflowFn(off) + } else { + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(off)) + } + 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' + if off := uint64(len(dst)) - uint64(dstlen); off > math.MaxUint32 { + return nil, sszutils.ErrOffsetOverflowFn(off) + } else { + binary.LittleEndian.PutUint32(dst[dstlen+4:], uint32(off)) + } + 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' + if off := uint64(len(dst)) - uint64(dstlen); off > math.MaxUint32 { + return nil, sszutils.ErrOffsetOverflowFn(off) + } else { + binary.LittleEndian.PutUint32(dst[dstlen+8:], uint32(off)) + } + 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 := uint64(len(buf)) + if buflen < 36 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 36) + } + maxOffset := uint32(sszutils.Min(buflen, math.MaxUint32)) + // Field #0 'URL' (offset) + offset0 := binary.LittleEndian.Uint32(buf[0:4]) + if offset0 != 36 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 36), "URL:o") + } + // Field #1 'Auth' (offset) + offset1 := binary.LittleEndian.Uint32(buf[4:8]) + if offset1 < offset0 || offset1 > maxOffset { + return sszutils.ErrorWithPath(sszutils.ErrOffsetOutOfRangeFn(offset1, offset0, buflen), "Auth:o") + } + // Field #2 'BuilderPubkeys' (offset) + offset2 := binary.LittleEndian.Uint32(buf[8:12]) + if offset2 < offset1 || offset2 > maxOffset { + 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() int { + var size int64 + 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 += int64(len(t.URL)) + } + { // Dynamic field #1 'Auth' + s1 := int64(t.Auth.SizeSSZ()) + if s1 < 0 { + return -1 + } + size += s1 + } + { // Dynamic field #2 'BuilderPubkeys' + size += int64(len(t.BuilderPubkeys)) * 48 + } + if uint64(size) > sszutils.MaxSszSize { + return -1 + } + return int(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 | sszutils.TreeTypePacked) + 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 len(t) { + 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/builderindex.go b/spec/gloas/builderindex.go index ada3a004..b540a24c 100644 --- a/spec/gloas/builderindex.go +++ b/spec/gloas/builderindex.go @@ -1,4 +1,4 @@ -// Copyright © 2022, 2023 Attestant Limited. +// 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 @@ -56,3 +56,6 @@ func (v *BuilderIndex) UnmarshalJSON(input []byte) error { 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 index a294b2c6..81d7ad86 100644 --- a/spec/gloas/builderpendingpayment.go +++ b/spec/gloas/builderpendingpayment.go @@ -1,4 +1,4 @@ -// Copyright © 2025 Attestant Limited. +// 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 @@ -22,9 +22,9 @@ import ( // BuilderPendingPayment represents a builder pending payment. type BuilderPendingPayment struct { - Weight phase0.Gwei - Withdrawal *BuilderPendingWithdrawal - ProposerIndex phase0.ValidatorIndex + 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. diff --git a/spec/gloas/builderpendingpayment_json.go b/spec/gloas/builderpendingpayment_json.go deleted file mode 100644 index b5691842..00000000 --- a/spec/gloas/builderpendingpayment_json.go +++ /dev/null @@ -1,79 +0,0 @@ -// 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/ethpandaops/go-eth2-client/spec/phase0" - "github.com/pkg/errors" -) - -// builderPendingPaymentJSON is the spec representation of the struct. -type builderPendingPaymentJSON struct { - Weight string `json:"weight"` - Withdrawal *BuilderPendingWithdrawal `json:"withdrawal"` - ProposerIndex string `json:"proposer_index"` -} - -// builderPendingPaymentYAML is the spec representation of the struct. -type builderPendingPaymentYAML struct { - Weight uint64 `yaml:"weight"` - Withdrawal *BuilderPendingWithdrawal `yaml:"withdrawal"` - ProposerIndex uint64 `yaml:"proposer_index"` -} - -// MarshalJSON implements json.Marshaler. -func (p *BuilderPendingPayment) MarshalJSON() ([]byte, error) { - return json.Marshal(&builderPendingPaymentJSON{ - Weight: fmt.Sprintf("%d", p.Weight), - Withdrawal: p.Withdrawal, - ProposerIndex: fmt.Sprintf("%d", p.ProposerIndex), - }) -} - -// UnmarshalJSON implements json.Unmarshaler. -func (p *BuilderPendingPayment) UnmarshalJSON(input []byte) error { - var data builderPendingPaymentJSON - if err := json.Unmarshal(input, &data); err != nil { - return errors.Wrap(err, "invalid JSON") - } - - if data.Weight == "" { - return errors.New("weight missing") - } - weight, err := strconv.ParseUint(data.Weight, 10, 64) - if err != nil { - return errors.Wrap(err, "invalid weight") - } - p.Weight = phase0.Gwei(weight) - - if data.Withdrawal == nil { - return errors.New("withdrawal missing") - } - p.Withdrawal = data.Withdrawal - - if data.ProposerIndex == "" { - return errors.New("proposer index missing") - } - proposerIndex, err := strconv.ParseUint(data.ProposerIndex, 10, 64) - if err != nil { - return errors.Wrap(err, "invalid proposer index") - } - p.ProposerIndex = phase0.ValidatorIndex(proposerIndex) - - return nil -} diff --git a/spec/gloas/builderpendingpayment_test.go b/spec/gloas/builderpendingpayment_test.go new file mode 100644 index 00000000..12604b24 --- /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/ethpandaops/go-eth2-client/spec/bellatrix" + "github.com/ethpandaops/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/builderpendingpayment_yaml.go b/spec/gloas/builderpendingpayment_yaml.go deleted file mode 100644 index 26fa33c8..00000000 --- a/spec/gloas/builderpendingpayment_yaml.go +++ /dev/null @@ -1,50 +0,0 @@ -// 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 (p *BuilderPendingPayment) MarshalYAML() ([]byte, error) { - yamlBytes, err := yaml.MarshalWithOptions(&builderPendingPaymentYAML{ - Weight: uint64(p.Weight), - Withdrawal: p.Withdrawal, - ProposerIndex: uint64(p.ProposerIndex), - }, yaml.Flow(true)) - if err != nil { - return nil, err - } - - return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil -} - -// UnmarshalYAML implements yaml.Unmarshaler. -func (p *BuilderPendingPayment) UnmarshalYAML(input []byte) error { - var data builderPendingPaymentJSON - 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/builderpendingwithdrawal.go b/spec/gloas/builderpendingwithdrawal.go index eee153e0..8949f5c8 100644 --- a/spec/gloas/builderpendingwithdrawal.go +++ b/spec/gloas/builderpendingwithdrawal.go @@ -1,4 +1,4 @@ -// Copyright © 2025 Attestant Limited. +// 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 @@ -23,9 +23,9 @@ import ( // BuilderPendingWithdrawal represents a builder pending withdrawal. type BuilderPendingWithdrawal struct { - FeeRecipient bellatrix.ExecutionAddress `ssz-size:"20"` - Amount phase0.Gwei - BuilderIndex BuilderIndex + 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. diff --git a/spec/gloas/builderpendingwithdrawal_json.go b/spec/gloas/builderpendingwithdrawal_json.go deleted file mode 100644 index f8b62f26..00000000 --- a/spec/gloas/builderpendingwithdrawal_json.go +++ /dev/null @@ -1,89 +0,0 @@ -// 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/ethpandaops/go-eth2-client/spec/bellatrix" - "github.com/ethpandaops/go-eth2-client/spec/phase0" - "github.com/pkg/errors" -) - -// builderPendingWithdrawalJSON is the spec representation of the struct. -type builderPendingWithdrawalJSON struct { - FeeRecipient string `json:"fee_recipient"` - Amount string `json:"amount"` - BuilderIndex string `json:"builder_index"` -} - -// builderPendingWithdrawalYAML is the spec representation of the struct. -type builderPendingWithdrawalYAML struct { - FeeRecipient string `yaml:"fee_recipient"` - Amount uint64 `yaml:"amount"` - BuilderIndex uint64 `yaml:"builder_index"` -} - -// MarshalJSON implements json.Marshaler. -func (p *BuilderPendingWithdrawal) MarshalJSON() ([]byte, error) { - return json.Marshal(&builderPendingWithdrawalJSON{ - FeeRecipient: p.FeeRecipient.String(), - Amount: fmt.Sprintf("%d", p.Amount), - BuilderIndex: fmt.Sprintf("%d", p.BuilderIndex), - }) -} - -// UnmarshalJSON implements json.Unmarshaler. -func (p *BuilderPendingWithdrawal) UnmarshalJSON(input []byte) error { - var data builderPendingWithdrawalJSON - if err := json.Unmarshal(input, &data); err != nil { - return errors.Wrap(err, "invalid JSON") - } - - 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.ExecutionAddressLength { - return fmt.Errorf("incorrect length %d for fee recipient", len(feeRecipient)) - } - copy(p.FeeRecipient[:], feeRecipient) - - if data.Amount == "" { - return errors.New("amount missing") - } - amount, err := strconv.ParseUint(data.Amount, 10, 64) - if err != nil { - return errors.Wrap(err, "invalid amount") - } - p.Amount = phase0.Gwei(amount) - - 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") - } - p.BuilderIndex = BuilderIndex(builderIndex) - - return nil -} diff --git a/spec/gloas/builderpendingwithdrawal_yaml.go b/spec/gloas/builderpendingwithdrawal_yaml.go deleted file mode 100644 index 51d7f012..00000000 --- a/spec/gloas/builderpendingwithdrawal_yaml.go +++ /dev/null @@ -1,50 +0,0 @@ -// 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 (p *BuilderPendingWithdrawal) MarshalYAML() ([]byte, error) { - yamlBytes, err := yaml.MarshalWithOptions(&builderPendingWithdrawalYAML{ - FeeRecipient: p.FeeRecipient.String(), - Amount: uint64(p.Amount), - BuilderIndex: uint64(p.BuilderIndex), - }, yaml.Flow(true)) - if err != nil { - return nil, err - } - - return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil -} - -// UnmarshalYAML implements yaml.Unmarshaler. -func (p *BuilderPendingWithdrawal) UnmarshalYAML(input []byte) error { - var data builderPendingWithdrawalJSON - 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/builderrequestauth_ssz.go b/spec/gloas/builderrequestauth_ssz.go new file mode 100644 index 00000000..0e0bab48 --- /dev/null +++ b/spec/gloas/builderrequestauth_ssz.go @@ -0,0 +1,129 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 9944d1b4704792322704ce947419a273092381e8114151d8b9c76b2738d3477d +// Version: v1.4.0-pre.3 (https://github.com/pk910/dynamic-ssz) + +package gloas + +import ( + "encoding/binary" + "math" + + "github.com/ethpandaops/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' + if off := uint64(len(dst)) - uint64(dstlen); off > math.MaxUint32 { + return nil, sszutils.ErrOffsetOverflowFn(off) + } else { + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(off)) + } + 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 := uint64(len(buf)) + if buflen < 12 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 12) + } + // Field #0 'Data' (offset) + offset0 := 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() int { + var size int64 + 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 += int64(len(t.Data)) + } + if uint64(size) > sszutils.MaxSszSize { + return -1 + } + return int(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 | sszutils.TreeTypePacked) + 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 index da20acbe..00967f43 100644 --- a/spec/gloas/consensusspec_test.go +++ b/spec/gloas/consensusspec_test.go @@ -30,6 +30,7 @@ import ( 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. @@ -39,8 +40,9 @@ func TestConsensusSpec(t *testing.T) { } tests := []struct { - name string - s any + name string + s any + yamlValue any }{ { name: "AggregateAndProof", @@ -243,8 +245,9 @@ func TestConsensusSpec(t *testing.T) { s: &altair.SyncAggregate{}, }, { - name: "SyncAggregatorSelectionData", - s: &altair.SyncAggregatorSelectionData{}, + name: "SyncAggregatorSelectionData", + s: &altair.SyncAggregatorSelectionData{}, + yamlValue: &syncAggregatorSelectionDataVector{}, }, { name: "SyncCommittee", @@ -287,10 +290,19 @@ func TestConsensusSpec(t *testing.T) { require.NoError(t, err) if info.IsDir() { t.Run(fmt.Sprintf("%s/%s", test.name, info.Name()), func(t *testing.T) { - s1 := clone.Clone(test.s) + 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) @@ -328,11 +340,11 @@ func TestConsensusSpec(t *testing.T) { func testYAMLFormat(input []byte) string { val := make(map[string]any) - if err := yaml.UnmarshalWithOptions(input, &val, yaml.UseOrderedMap()); err != nil { + if err := yaml.Unmarshal(input, &val); err != nil { panic(err) } - res, err := yaml.MarshalWithOptions(val, yaml.Flow(true)) + res, err := yaml.MarshalWithOptions(normalizeVectorNumbers(val), yaml.Flow(true)) if err != nil { panic(err) } @@ -348,3 +360,35 @@ func testYAMLFormat(input []byte) string { 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 index 87d512ba..75613a49 100644 --- a/spec/gloas/executionpayload.go +++ b/spec/gloas/executionpayload.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 @@ -38,14 +38,14 @@ type ExecutionPayload struct { GasLimit uint64 `ssz-index:"7"` GasUsed uint64 `ssz-index:"8"` Timestamp uint64 `ssz-index:"9"` - ExtraData []byte `ssz-index:"10" dynssz-max:"MAX_EXTRA_DATA_BYTES" ssz-max:"32"` - BaseFeePerGas *uint256.Int `ssz-index:"11" ssz-type:"uint256"` + 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"` + 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"` + BlockAccessList BlockAccessList `ssz-index:"17" ssz-type:"progressive-list"` SlotNumber uint64 `ssz-index:"18"` } diff --git a/spec/gloas/executionpayload_json.go b/spec/gloas/executionpayload_json.go index 820cb9ee..1d97fa76 100644 --- a/spec/gloas/executionpayload_json.go +++ b/spec/gloas/executionpayload_json.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 @@ -55,8 +55,10 @@ type executionPayloadJSON struct { func (e *ExecutionPayload) MarshalJSON() ([]byte, error) { transactions := make([]string, len(e.Transactions)) for i := range e.Transactions { - // %#x renders an empty transaction as "", while the spec writes "0x". - transactions[i] = "0x" + hex.EncodeToString(e.Transactions[i]) + transactions[i] = "0x" + if len(e.Transactions[i]) > 0 { + transactions[i] = fmt.Sprintf("%#x", e.Transactions[i]) + } } extraData := "0x" @@ -69,6 +71,13 @@ func (e *ExecutionPayload) MarshalJSON() ([]byte, error) { 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, @@ -81,7 +90,7 @@ func (e *ExecutionPayload) MarshalJSON() ([]byte, error) { GasUsed: strconv.FormatUint(e.GasUsed, 10), Timestamp: strconv.FormatUint(e.Timestamp, 10), ExtraData: extraData, - BaseFeePerGas: e.BaseFeePerGas.Dec(), + BaseFeePerGas: baseFeePerGas.Dec(), BlockHash: e.BlockHash, Transactions: transactions, Withdrawals: e.Withdrawals, @@ -240,17 +249,10 @@ func (e *ExecutionPayload) UnmarshalJSON(input []byte) error { e.Transactions = make([]bellatrix.Transaction, len(transactions)) for i := range transactions { - if len(transactions[i]) == 0 { - return fmt.Errorf("transaction %d: missing", i) - } - - // Since Gloas a transaction is a ProgressiveByteList, which the spec - // writes as "0x" when it is empty. - if bytes.Equal(transactions[i], []byte{'"', '"'}) || - bytes.Equal(transactions[i], []byte{'"', '0', 'x', '"'}) { - e.Transactions[i] = bellatrix.Transaction{} - - continue + // "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) diff --git a/spec/gloas/executionpayload_test.go b/spec/gloas/executionpayload_test.go new file mode 100644 index 00000000..e1b8519c --- /dev/null +++ b/spec/gloas/executionpayload_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 gloas_test + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/ethpandaops/go-eth2-client/spec/bellatrix" + "github.com/ethpandaops/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"]`) + + // go-yaml may or may not quote "0x", so check the YAML round-trips instead of its quoting. + yamlData, err := yaml.Marshal(payload) + require.NoError(t, err) + require.Regexp(t, `transactions: \['?0x'?\]`, string(yamlData)) + + var decoded gloas.ExecutionPayload + require.NoError(t, yaml.Unmarshal(yamlData, &decoded)) + require.Equal(t, payload.Transactions, decoded.Transactions) +} + +// 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 index 680fe1e5..7508b9f0 100644 --- a/spec/gloas/executionpayload_yaml.go +++ b/spec/gloas/executionpayload_yaml.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 @@ -15,12 +15,12 @@ package gloas import ( "bytes" - "encoding/hex" "encoding/json" "fmt" "github.com/ethpandaops/go-eth2-client/spec/capella" "github.com/goccy/go-yaml" + "github.com/holiman/uint256" "github.com/pkg/errors" ) @@ -51,8 +51,10 @@ type executionPayloadYAML struct { func (e *ExecutionPayload) MarshalYAML() ([]byte, error) { transactions := make([]string, len(e.Transactions)) for i := range e.Transactions { - // %#x renders an empty transaction as "", while the spec writes "0x". - transactions[i] = "0x" + hex.EncodeToString(e.Transactions[i]) + transactions[i] = "0x" + if len(e.Transactions[i]) > 0 { + transactions[i] = fmt.Sprintf("%#x", e.Transactions[i]) + } } extraData := "0x" @@ -65,6 +67,13 @@ func (e *ExecutionPayload) MarshalYAML() ([]byte, error) { 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(), @@ -77,7 +86,7 @@ func (e *ExecutionPayload) MarshalYAML() ([]byte, error) { GasUsed: e.GasUsed, Timestamp: e.Timestamp, ExtraData: extraData, - BaseFeePerGas: e.BaseFeePerGas.Dec(), + BaseFeePerGas: baseFeePerGas.Dec(), BlockHash: fmt.Sprintf("%#x", e.BlockHash), Transactions: transactions, Withdrawals: e.Withdrawals, diff --git a/spec/gloas/executionpayloadbid.go b/spec/gloas/executionpayloadbid.go index 3d03c47d..46d2ec9a 100644 --- a/spec/gloas/executionpayloadbid.go +++ b/spec/gloas/executionpayloadbid.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 @@ -24,11 +24,11 @@ import ( // ExecutionPayloadBid represents an execution payload bid for EIP-7732. type ExecutionPayloadBid struct { - ParentBlockHash phase0.Hash32 `ssz-index:"0"` - ParentBlockRoot phase0.Root `ssz-index:"1"` - BlockHash phase0.Hash32 `ssz-index:"2"` - PrevRandao phase0.Root `ssz-index:"3"` - FeeRecipient bellatrix.ExecutionAddress `ssz-index:"4"` + 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"` diff --git a/spec/gloas/executionpayloadbid_json.go b/spec/gloas/executionpayloadbid_json.go index 85a0cdf9..31836520 100644 --- a/spec/gloas/executionpayloadbid_json.go +++ b/spec/gloas/executionpayloadbid_json.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 @@ -20,6 +20,7 @@ import ( "strconv" "strings" + "github.com/ethpandaops/go-eth2-client/spec/bellatrix" "github.com/ethpandaops/go-eth2-client/spec/deneb" "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/pkg/errors" @@ -65,6 +66,8 @@ func (e *ExecutionPayloadBid) MarshalJSON() ([]byte, error) { } // UnmarshalJSON implements json.Unmarshaler. +// +//nolint:gocyclo func (e *ExecutionPayloadBid) UnmarshalJSON(input []byte) error { var data executionPayloadBidJSON if err := json.Unmarshal(input, &data); err != nil { @@ -79,6 +82,9 @@ func (e *ExecutionPayloadBid) UnmarshalJSON(input []byte) error { 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 @@ -89,6 +95,9 @@ func (e *ExecutionPayloadBid) UnmarshalJSON(input []byte) error { 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 @@ -99,6 +108,9 @@ func (e *ExecutionPayloadBid) UnmarshalJSON(input []byte) error { 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 @@ -109,6 +121,9 @@ func (e *ExecutionPayloadBid) UnmarshalJSON(input []byte) error { 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 @@ -119,6 +134,9 @@ func (e *ExecutionPayloadBid) UnmarshalJSON(input []byte) error { 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 @@ -195,6 +213,9 @@ func (e *ExecutionPayloadBid) UnmarshalJSON(input []byte) error { 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_yaml.go b/spec/gloas/executionpayloadbid_yaml.go index 3ae2f51b..e3a97238 100644 --- a/spec/gloas/executionpayloadbid_yaml.go +++ b/spec/gloas/executionpayloadbid_yaml.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 @@ -22,22 +22,6 @@ import ( "github.com/pkg/errors" ) -// executionPayloadBidYAML is the spec representation of the struct. -type executionPayloadBidYAML struct { - ParentBlockHash string `yaml:"parent_block_hash"` - ParentBlockRoot string `yaml:"parent_block_root"` - BlockHash string `yaml:"block_hash"` - PrevRandao string `yaml:"prev_randao"` - FeeRecipient string `yaml:"fee_recipient"` - GasLimit uint64 `yaml:"gas_limit"` - BuilderIndex uint64 `yaml:"builder_index"` - Slot uint64 `yaml:"slot"` - Value uint64 `yaml:"value"` - ExecutionPayment uint64 `yaml:"execution_payment"` - BlobKZGCommitments []string `yaml:"blob_kzg_commitments"` - ExecutionRequestsRoot string `yaml:"execution_requests_root"` -} - // MarshalYAML implements yaml.Marshaler. func (e *ExecutionPayloadBid) MarshalYAML() ([]byte, error) { blobKZGCommitments := make([]string, len(e.BlobKZGCommitments)) @@ -45,17 +29,17 @@ func (e *ExecutionPayloadBid) MarshalYAML() ([]byte, error) { blobKZGCommitments[i] = fmt.Sprintf("%#x", e.BlobKZGCommitments[i]) } - yamlBytes, err := yaml.MarshalWithOptions(&executionPayloadBidYAML{ + 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: e.GasLimit, - BuilderIndex: uint64(e.BuilderIndex), - Slot: uint64(e.Slot), - Value: uint64(e.Value), - ExecutionPayment: uint64(e.ExecutionPayment), + 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)) diff --git a/spec/gloas/executionpayloadenvelope.go b/spec/gloas/executionpayloadenvelope.go index 440169be..c63d7261 100644 --- a/spec/gloas/executionpayloadenvelope.go +++ b/spec/gloas/executionpayloadenvelope.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 @@ -25,8 +25,8 @@ type ExecutionPayloadEnvelope struct { Payload *ExecutionPayload `ssz-index:"0"` ExecutionRequests *ExecutionRequests `ssz-index:"1"` BuilderIndex BuilderIndex `ssz-index:"2"` - BeaconBlockRoot phase0.Root `ssz-index:"3"` - ParentBeaconBlockRoot phase0.Root `ssz-index:"4"` + 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. diff --git a/spec/gloas/executionpayloadenvelope_json.go b/spec/gloas/executionpayloadenvelope_json.go index 1485b771..c89548e9 100644 --- a/spec/gloas/executionpayloadenvelope_json.go +++ b/spec/gloas/executionpayloadenvelope_json.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 @@ -20,6 +20,7 @@ import ( "strconv" "strings" + "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/pkg/errors" ) @@ -76,6 +77,9 @@ func (e *ExecutionPayloadEnvelope) UnmarshalJSON(input []byte) error { 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 == "" { @@ -85,6 +89,9 @@ func (e *ExecutionPayloadEnvelope) UnmarshalJSON(input []byte) error { 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_test.go b/spec/gloas/executionpayloadenvelope_test.go new file mode 100644 index 00000000..507b5078 --- /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/ethpandaops/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 index 0a0219e2..11b857dd 100644 --- a/spec/gloas/executionpayloadenvelope_yaml.go +++ b/spec/gloas/executionpayloadenvelope_yaml.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 @@ -22,21 +22,12 @@ import ( "github.com/pkg/errors" ) -// executionPayloadEnvelopeYAML is the spec representation of the struct. -type executionPayloadEnvelopeYAML struct { - Payload *ExecutionPayload `yaml:"payload"` - ExecutionRequests *ExecutionRequests `yaml:"execution_requests"` - BuilderIndex uint64 `yaml:"builder_index"` - BeaconBlockRoot string `yaml:"beacon_block_root"` - ParentBeaconBlockRoot string `yaml:"parent_beacon_block_root"` -} - // MarshalYAML implements yaml.Marshaler. func (e *ExecutionPayloadEnvelope) MarshalYAML() ([]byte, error) { - yamlBytes, err := yaml.MarshalWithOptions(&executionPayloadEnvelopeYAML{ + yamlBytes, err := yaml.MarshalWithOptions(&executionPayloadEnvelopeJSON{ Payload: e.Payload, ExecutionRequests: e.ExecutionRequests, - BuilderIndex: uint64(e.BuilderIndex), + BuilderIndex: fmt.Sprintf("%d", e.BuilderIndex), BeaconBlockRoot: fmt.Sprintf("%#x", e.BeaconBlockRoot), ParentBeaconBlockRoot: fmt.Sprintf("%#x", e.ParentBeaconBlockRoot), }, yaml.Flow(true)) diff --git a/spec/gloas/generate.yaml b/spec/gloas/generate.yaml index 9336ed08..eabbfa22 100644 --- a/spec/gloas/generate.yaml +++ b/spec/gloas/generate.yaml @@ -17,6 +17,14 @@ types: 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 diff --git a/spec/gloas/indexedattestation.go b/spec/gloas/indexedattestation.go index 898a94f5..899e24bd 100644 --- a/spec/gloas/indexedattestation.go +++ b/spec/gloas/indexedattestation.go @@ -1,4 +1,4 @@ -// Copyright © 2024 Attestant Limited. +// 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 @@ -109,13 +109,6 @@ func (i *IndexedAttestation) String() string { func (i *IndexedAttestation) unpack(indexedAttestationJSON *indexedAttestationJSON) error { var err error - // Spec tests contain indexed attestations with empty attesting indices. - // if indexedAttestationJSON.AttestingIndices == nil { - // return errors.New("attesting indices missing") - // } - // if len(indexedAttestationJSON.AttestingIndices) == 0 { - // return errors.New("attesting indices missing") - // } 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 { diff --git a/spec/gloas/indexedpayloadattestation.go b/spec/gloas/indexedpayloadattestation.go index fe7a65be..f8418898 100644 --- a/spec/gloas/indexedpayloadattestation.go +++ b/spec/gloas/indexedpayloadattestation.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 @@ -22,9 +22,9 @@ import ( // IndexedPayloadAttestation represents an indexed payload attestation. type IndexedPayloadAttestation struct { - AttestingIndices []phase0.ValidatorIndex `ssz-index:"0" dynssz-max:"PTC_SIZE" ssz-max:"512"` + AttestingIndices []phase0.ValidatorIndex `dynssz-max:"PTC_SIZE" ssz-index:"0" ssz-max:"512"` Data *PayloadAttestationData `ssz-index:"1"` - Signature phase0.BLSSignature `ssz-index:"2"` + Signature phase0.BLSSignature `ssz-index:"2" ssz-size:"96"` } // String returns a string version of the structure. diff --git a/spec/gloas/indexedpayloadattestation_json.go b/spec/gloas/indexedpayloadattestation_json.go index 4543b28a..24e9f4f8 100644 --- a/spec/gloas/indexedpayloadattestation_json.go +++ b/spec/gloas/indexedpayloadattestation_json.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 @@ -73,6 +73,9 @@ func (i *IndexedPayloadAttestation) UnmarshalJSON(input []byte) error { 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_yaml.go b/spec/gloas/indexedpayloadattestation_yaml.go index 26d8705b..a5065680 100644 --- a/spec/gloas/indexedpayloadattestation_yaml.go +++ b/spec/gloas/indexedpayloadattestation_yaml.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 @@ -22,20 +22,13 @@ import ( "github.com/pkg/errors" ) -// indexedPayloadAttestationYAML is the spec representation of the struct. -type indexedPayloadAttestationYAML struct { - AttestingIndices []uint64 `yaml:"attesting_indices"` - Data *PayloadAttestationData `yaml:"data"` - Signature string `yaml:"signature"` -} - // MarshalYAML implements yaml.Marshaler. func (i *IndexedPayloadAttestation) MarshalYAML() ([]byte, error) { - attestingIndices := make([]uint64, len(i.AttestingIndices)) + attestingIndices := make([]string, len(i.AttestingIndices)) for index := range i.AttestingIndices { - attestingIndices[index] = uint64(i.AttestingIndices[index]) + attestingIndices[index] = fmt.Sprintf("%d", i.AttestingIndices[index]) } - yamlBytes, err := yaml.MarshalWithOptions(&indexedPayloadAttestationYAML{ + yamlBytes, err := yaml.MarshalWithOptions(&indexedPayloadAttestationJSON{ AttestingIndices: attestingIndices, Data: i.Data, Signature: fmt.Sprintf("%#x", i.Signature), diff --git a/spec/gloas/jsonlength_test.go b/spec/gloas/jsonlength_test.go new file mode 100644 index 00000000..56572c5b --- /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/ethpandaops/go-eth2-client/spec/altair" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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 index 33513e0e..7e124909 100644 --- a/spec/gloas/payloadattestation.go +++ b/spec/gloas/payloadattestation.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 @@ -23,9 +23,83 @@ import ( // PayloadAttestation represents a payload attestation. type PayloadAttestation struct { - AggregationBits bitfield.Bitvector512 `ssz-index:"0" dynssz-size:"PTC_SIZE/8" ssz-size:"64"` + // 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"` + 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. diff --git a/spec/gloas/payloadattestation_json.go b/spec/gloas/payloadattestation_json.go index e94c67d7..66cf9d17 100644 --- a/spec/gloas/payloadattestation_json.go +++ b/spec/gloas/payloadattestation_json.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 @@ -19,6 +19,7 @@ import ( "fmt" "strings" + "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/pkg/errors" ) @@ -66,6 +67,9 @@ func (p *PayloadAttestation) UnmarshalJSON(input []byte) error { 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_test.go b/spec/gloas/payloadattestation_test.go new file mode 100644 index 00000000..a0002011 --- /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/ethpandaops/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 index 3056bbb1..fafd7aed 100644 --- a/spec/gloas/payloadattestation_yaml.go +++ b/spec/gloas/payloadattestation_yaml.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 diff --git a/spec/gloas/payloadattestationdata.go b/spec/gloas/payloadattestationdata.go index 3623c756..7280da4d 100644 --- a/spec/gloas/payloadattestationdata.go +++ b/spec/gloas/payloadattestationdata.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 diff --git a/spec/gloas/payloadattestationdata_json.go b/spec/gloas/payloadattestationdata_json.go index 2f2771e0..ec71f28b 100644 --- a/spec/gloas/payloadattestationdata_json.go +++ b/spec/gloas/payloadattestationdata_json.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 @@ -56,6 +56,9 @@ func (p *PayloadAttestationData) UnmarshalJSON(input []byte) error { 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) diff --git a/spec/gloas/payloadattestationdata_yaml.go b/spec/gloas/payloadattestationdata_yaml.go index 1984d4e9..25250aa9 100644 --- a/spec/gloas/payloadattestationdata_yaml.go +++ b/spec/gloas/payloadattestationdata_yaml.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 @@ -22,19 +22,11 @@ import ( "github.com/pkg/errors" ) -// payloadAttestationDataYAML is the spec representation of the struct. -type payloadAttestationDataYAML struct { - BeaconBlockRoot string `yaml:"beacon_block_root"` - Slot uint64 `yaml:"slot"` - PayloadPresent bool `yaml:"payload_present"` - BlobDataAvailable bool `yaml:"blob_data_available"` -} - // MarshalYAML implements yaml.Marshaler. func (p *PayloadAttestationData) MarshalYAML() ([]byte, error) { - yamlBytes, err := yaml.MarshalWithOptions(&payloadAttestationDataYAML{ + yamlBytes, err := yaml.MarshalWithOptions(&payloadAttestationDataJSON{ BeaconBlockRoot: fmt.Sprintf("%#x", p.BeaconBlockRoot), - Slot: uint64(p.Slot), + Slot: fmt.Sprintf("%d", p.Slot), PayloadPresent: p.PayloadPresent, BlobDataAvailable: p.BlobDataAvailable, }, yaml.Flow(true)) diff --git a/spec/gloas/payloadattestationmessage.go b/spec/gloas/payloadattestationmessage.go index b12d1e73..6612b5bc 100644 --- a/spec/gloas/payloadattestationmessage.go +++ b/spec/gloas/payloadattestationmessage.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 diff --git a/spec/gloas/payloadattestationmessage_json.go b/spec/gloas/payloadattestationmessage_json.go index 62de71e9..eb2e4e79 100644 --- a/spec/gloas/payloadattestationmessage_json.go +++ b/spec/gloas/payloadattestationmessage_json.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 @@ -68,6 +68,9 @@ func (p *PayloadAttestationMessage) UnmarshalJSON(input []byte) error { 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_yaml.go b/spec/gloas/payloadattestationmessage_yaml.go index 47fe6335..88dfe178 100644 --- a/spec/gloas/payloadattestationmessage_yaml.go +++ b/spec/gloas/payloadattestationmessage_yaml.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 @@ -22,17 +22,10 @@ import ( "github.com/pkg/errors" ) -// payloadAttestationMessageYAML is the spec representation of the struct. -type payloadAttestationMessageYAML struct { - ValidatorIndex uint64 `yaml:"validator_index"` - Data *PayloadAttestationData `yaml:"data"` - Signature string `yaml:"signature"` -} - // MarshalYAML implements yaml.Marshaler. func (p *PayloadAttestationMessage) MarshalYAML() ([]byte, error) { - yamlBytes, err := yaml.MarshalWithOptions(&payloadAttestationMessageYAML{ - ValidatorIndex: uint64(p.ValidatorIndex), + yamlBytes, err := yaml.MarshalWithOptions(&payloadAttestationMessageJSON{ + ValidatorIndex: fmt.Sprintf("%d", p.ValidatorIndex), Data: p.Data, Signature: fmt.Sprintf("%#x", p.Signature), }, yaml.Flow(true)) diff --git a/spec/gloas/proposerpreferences.go b/spec/gloas/proposerpreferences.go index ca5c144d..f16944f2 100644 --- a/spec/gloas/proposerpreferences.go +++ b/spec/gloas/proposerpreferences.go @@ -1,4 +1,4 @@ -// Copyright © 2025 Attestant Limited. +// 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 diff --git a/spec/gloas/proposerpreferences_json.go b/spec/gloas/proposerpreferences_json.go index ba8a9d35..49596b17 100644 --- a/spec/gloas/proposerpreferences_json.go +++ b/spec/gloas/proposerpreferences_json.go @@ -1,4 +1,4 @@ -// Copyright © 2025 Attestant Limited. +// 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 @@ -20,6 +20,7 @@ import ( "strconv" "strings" + "github.com/ethpandaops/go-eth2-client/spec/bellatrix" "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/pkg/errors" ) @@ -92,6 +93,9 @@ func (p *ProposerPreferences) UnmarshalJSON(input []byte) error { 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. diff --git a/spec/gloas/proposerpreferences_yaml.go b/spec/gloas/proposerpreferences_yaml.go index a79268a9..775f588a 100644 --- a/spec/gloas/proposerpreferences_yaml.go +++ b/spec/gloas/proposerpreferences_yaml.go @@ -1,4 +1,4 @@ -// Copyright © 2025 Attestant Limited. +// 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 @@ -22,23 +22,14 @@ import ( "github.com/pkg/errors" ) -// proposerPreferencesYAML is the spec representation of the struct. -type proposerPreferencesYAML struct { - DependentRoot string `yaml:"dependent_root"` - ProposalSlot uint64 `yaml:"proposal_slot"` - ValidatorIndex uint64 `yaml:"validator_index"` - FeeRecipient string `yaml:"fee_recipient"` - TargetGasLimit uint64 `yaml:"target_gas_limit"` -} - // MarshalYAML implements yaml.Marshaler. func (p *ProposerPreferences) MarshalYAML() ([]byte, error) { - yamlBytes, err := yaml.MarshalWithOptions(&proposerPreferencesYAML{ + yamlBytes, err := yaml.MarshalWithOptions(&proposerPreferencesJSON{ DependentRoot: fmt.Sprintf("%#x", p.DependentRoot), - ProposalSlot: uint64(p.ProposalSlot), - ValidatorIndex: uint64(p.ValidatorIndex), + ProposalSlot: fmt.Sprintf("%d", p.ProposalSlot), + ValidatorIndex: fmt.Sprintf("%d", p.ValidatorIndex), FeeRecipient: fmt.Sprintf("%#x", p.FeeRecipient), - TargetGasLimit: p.TargetGasLimit, + TargetGasLimit: fmt.Sprintf("%d", p.TargetGasLimit), }, yaml.Flow(true)) if err != nil { return nil, err diff --git a/spec/gloas/signedaggregateandproof.go b/spec/gloas/signedaggregateandproof.go index 8127c19e..e379dd68 100644 --- a/spec/gloas/signedaggregateandproof.go +++ b/spec/gloas/signedaggregateandproof.go @@ -1,4 +1,4 @@ -// Copyright © 2024 Attestant Limited. +// 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 diff --git a/spec/gloas/signedbeaconblock.go b/spec/gloas/signedbeaconblock.go index 8055e2db..2f6f0f8d 100644 --- a/spec/gloas/signedbeaconblock.go +++ b/spec/gloas/signedbeaconblock.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 diff --git a/spec/gloas/signedbeaconblock_json.go b/spec/gloas/signedbeaconblock_json.go index dd4ce7d0..6c3330f9 100644 --- a/spec/gloas/signedbeaconblock_json.go +++ b/spec/gloas/signedbeaconblock_json.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 @@ -19,6 +19,7 @@ import ( "fmt" "strings" + "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/pkg/errors" ) @@ -58,6 +59,9 @@ func (s *SignedBeaconBlock) UnmarshalJSON(input []byte) error { 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_yaml.go b/spec/gloas/signedbeaconblock_yaml.go index 3fbcba08..555699ef 100644 --- a/spec/gloas/signedbeaconblock_yaml.go +++ b/spec/gloas/signedbeaconblock_yaml.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 @@ -16,32 +16,25 @@ package gloas import ( "bytes" "encoding/json" - "fmt" "github.com/goccy/go-yaml" "github.com/pkg/errors" ) -// signedBeaconBlockYAML is the spec representation of the struct. -type signedBeaconBlockYAML struct { - Message *BeaconBlock `yaml:"message"` - Signature string `yaml:"signature"` -} - // MarshalYAML implements yaml.Marshaler. func (s *SignedBeaconBlock) MarshalYAML() ([]byte, error) { if s == nil { return nil, nil } - // Going through the JSON encoding would render every number as a string, - // which is the JSON representation but not the spec's YAML one. - yamlBytes, err := yaml.MarshalWithOptions(&signedBeaconBlockYAML{ - Message: s.Message, - Signature: fmt.Sprintf("%#x", s.Signature), - }, yaml.Flow(true)) + 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, err + return nil, errors.Wrap(err, "failed to convert JSON to YAML") } return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil diff --git a/spec/gloas/signedbuilderrequestauth_ssz.go b/spec/gloas/signedbuilderrequestauth_ssz.go new file mode 100644 index 00000000..616e7f98 --- /dev/null +++ b/spec/gloas/signedbuilderrequestauth_ssz.go @@ -0,0 +1,135 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 679d7be850c41edcf4c8923c2e0c67ca7bff0de435614c7ec6b7d29c0f1446fb +// Version: v1.4.0-pre.3 (https://github.com/pk910/dynamic-ssz) + +package gloas + +import ( + "encoding/binary" + "math" + + 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' + if off := uint64(len(dst)) - uint64(dstlen); off > math.MaxUint32 { + return nil, sszutils.ErrOffsetOverflowFn(off) + } else { + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(off)) + } + 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 := uint64(len(buf)) + if buflen < 100 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 100) + } + // Field #0 'Message' (offset) + offset0 := 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() int { + var size int64 + if t == nil { + t = new(SignedBuilderRequestAuth) + } + // Field #0 'Message' offset (4 bytes) + // Field #1 'Signature' static (96 bytes) + size += 100 + { // Dynamic field #0 'Message' + s1 := int64(t.Message.SizeSSZ()) + if s1 < 0 { + return -1 + } + size += s1 + } + if uint64(size) > sszutils.MaxSszSize { + return -1 + } + return int(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 index 7c2788d5..a5620fdf 100644 --- a/spec/gloas/signedexecutionpayloadbid.go +++ b/spec/gloas/signedexecutionpayloadbid.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 diff --git a/spec/gloas/signedexecutionpayloadbid_json.go b/spec/gloas/signedexecutionpayloadbid_json.go index 4e0a2674..cdd0d583 100644 --- a/spec/gloas/signedexecutionpayloadbid_json.go +++ b/spec/gloas/signedexecutionpayloadbid_json.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 @@ -19,6 +19,7 @@ import ( "fmt" "strings" + "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/pkg/errors" ) @@ -55,6 +56,9 @@ func (s *SignedExecutionPayloadBid) UnmarshalJSON(input []byte) error { 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_yaml.go b/spec/gloas/signedexecutionpayloadbid_yaml.go index d5adfada..e0d5a7bb 100644 --- a/spec/gloas/signedexecutionpayloadbid_yaml.go +++ b/spec/gloas/signedexecutionpayloadbid_yaml.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 diff --git a/spec/gloas/signedexecutionpayloadenvelope.go b/spec/gloas/signedexecutionpayloadenvelope.go index 2f9b256d..e273b010 100644 --- a/spec/gloas/signedexecutionpayloadenvelope.go +++ b/spec/gloas/signedexecutionpayloadenvelope.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 diff --git a/spec/gloas/signedexecutionpayloadenvelope_json.go b/spec/gloas/signedexecutionpayloadenvelope_json.go index 9eec83eb..48666651 100644 --- a/spec/gloas/signedexecutionpayloadenvelope_json.go +++ b/spec/gloas/signedexecutionpayloadenvelope_json.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 @@ -19,6 +19,7 @@ import ( "fmt" "strings" + "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/pkg/errors" ) @@ -55,6 +56,9 @@ func (s *SignedExecutionPayloadEnvelope) UnmarshalJSON(input []byte) error { 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_yaml.go b/spec/gloas/signedexecutionpayloadenvelope_yaml.go index 7839dc55..c843bb08 100644 --- a/spec/gloas/signedexecutionpayloadenvelope_yaml.go +++ b/spec/gloas/signedexecutionpayloadenvelope_yaml.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Attestant Limited. +// 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 diff --git a/spec/gloas/signedproposerpreferences.go b/spec/gloas/signedproposerpreferences.go index cf986af8..94db0b4c 100644 --- a/spec/gloas/signedproposerpreferences.go +++ b/spec/gloas/signedproposerpreferences.go @@ -1,4 +1,4 @@ -// Copyright © 2025 Attestant Limited. +// 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 diff --git a/spec/gloas/signedproposerpreferences_json.go b/spec/gloas/signedproposerpreferences_json.go index fdc193ff..6d659558 100644 --- a/spec/gloas/signedproposerpreferences_json.go +++ b/spec/gloas/signedproposerpreferences_json.go @@ -1,4 +1,4 @@ -// Copyright © 2025 Attestant Limited. +// 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 @@ -19,6 +19,7 @@ import ( "fmt" "strings" + "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/pkg/errors" ) @@ -55,6 +56,9 @@ func (s *SignedProposerPreferences) UnmarshalJSON(input []byte) error { 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_yaml.go b/spec/gloas/signedproposerpreferences_yaml.go index 3f1c8ea3..dbd1848a 100644 --- a/spec/gloas/signedproposerpreferences_yaml.go +++ b/spec/gloas/signedproposerpreferences_yaml.go @@ -1,4 +1,4 @@ -// Copyright © 2025 Attestant Limited. +// 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 diff --git a/spec/gloas/sszannotations_test.go b/spec/gloas/sszannotations_test.go new file mode 100644 index 00000000..1d6886f6 --- /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/ethpandaops/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..25c2fae4 --- /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/ethpandaops/go-eth2-client/spec/bellatrix" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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/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. diff --git a/spec/version.go b/spec/version.go index 409d8b4a..270c5dbb 100644 --- a/spec/version.go +++ b/spec/version.go @@ -36,3 +36,9 @@ var ( BuilderVersionV1 = version.BuilderVersionV1 ) + +// DataVersionFromString returns the DataVersion for the given fork name. +var DataVersionFromString = version.DataVersionFromString + +// AddDataVersionAlias registers an additional fork name that maps to an existing DataVersion. +var AddDataVersionAlias = version.AddDataVersionAlias diff --git a/spec/version/dataversion.go b/spec/version/dataversion.go index caf762e8..44ed2a73 100644 --- a/spec/version/dataversion.go +++ b/spec/version/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 @@ -70,8 +70,10 @@ var dataVersionMap = map[string]DataVersion{ } // 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. @@ -90,7 +92,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/version_compatibility_test.go b/spec/version_compatibility_test.go new file mode 100644 index 00000000..e7d74936 --- /dev/null +++ b/spec/version_compatibility_test.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 spec_test + +import ( + "reflect" + "testing" + + "github.com/ethpandaops/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) { + // This fork defines the version types in spec/version and re-exports them from spec as + // aliases, so the aliases must keep resolving to that package. + require.Equal(t, "github.com/ethpandaops/go-eth2-client/spec/version", reflect.TypeOf(test.type_).PkgPath()) + }) + } +} diff --git a/spec/versionedaggregateandproof.go b/spec/versionedaggregateandproof.go index 4ddc39ff..76393c0d 100644 --- a/spec/versionedaggregateandproof.go +++ b/spec/versionedaggregateandproof.go @@ -17,6 +17,7 @@ import ( "errors" "github.com/ethpandaops/go-eth2-client/spec/electra" + "github.com/ethpandaops/go-eth2-client/spec/gloas" "github.com/ethpandaops/go-eth2-client/spec/phase0" ) @@ -30,8 +31,11 @@ type VersionedAggregateAndProof struct { Deneb *phase0.AggregateAndProof Electra *electra.AggregateAndProof Fulu *electra.AggregateAndProof - Gloas *electra.AggregateAndProof - Heze *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 + Heze *gloas.AggregateAndProof } // AggregatorIndex returns the aggregator index of the aggregate. @@ -160,8 +164,8 @@ 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.Gloas == nil && v.Heze == nil + return v.Phase0 == nil && v.Altair == nil && v.Bellatrix == nil && + v.Capella == nil && v.Deneb == nil && v.Electra == nil && v.Fulu == nil && v.Gloas == nil && v.Heze == nil } // String returns a string version of the structure. diff --git a/spec/versionedaggregateandproof_test.go b/spec/versionedaggregateandproof_test.go new file mode 100644 index 00000000..c881cbb0 --- /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/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/electra" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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 e6e1bac6..e2be1afe 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 @@ -40,8 +40,8 @@ type VersionedAttestation struct { // 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.Gloas == nil && v.Heze == nil + return v.Phase0 == nil && v.Altair == nil && v.Bellatrix == nil && + v.Capella == nil && v.Deneb == nil && v.Electra == nil && v.Fulu == nil && v.Gloas == nil && v.Heze == nil } // AggregationBits returns the aggregation bits of the attestation. diff --git a/spec/versionedattesterslashing.go b/spec/versionedattesterslashing.go index 2a260c6a..07c8d4e7 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 @@ -37,8 +37,8 @@ type VersionedAttesterSlashing struct { // 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.Gloas == nil && v.Heze == nil + return v.Phase0 == nil && v.Altair == nil && v.Bellatrix == nil && + v.Capella == nil && v.Deneb == nil && v.Electra == nil && v.Fulu == nil && v.Gloas == nil && v.Heze == nil } // Attestation1 returns the first indexed attestation. diff --git a/spec/versionedbeaconblock.go b/spec/versionedbeaconblock.go index 9057fb7d..6e0b00be 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 @@ -42,8 +42,8 @@ type VersionedBeaconBlock struct { // 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.Gloas == nil && v.Heze == nil + return v.Phase0 == nil && v.Altair == nil && v.Bellatrix == nil && v.Capella == nil && + v.Deneb == nil && v.Electra == nil && v.Fulu == nil && v.Gloas == nil && v.Heze == nil } // Slot returns the slot of the beacon block. @@ -645,6 +645,8 @@ func (v *VersionedBeaconBlock) StateRoot() (phase0.Root, error) { } // Attestations returns the attestations of the beacon block. +// +//nolint:gocyclo func (v *VersionedBeaconBlock) Attestations() ([]VersionedAttestation, error) { switch v.Version { case DataVersionPhase0: @@ -779,6 +781,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: diff --git a/spec/versionedbeaconblockbody.go b/spec/versionedbeaconblockbody.go index eec2b01d..5e538ef9 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 diff --git a/spec/versionedbeaconstate.go b/spec/versionedbeaconstate.go index 29cf9cf0..ea265f9c 100644 --- a/spec/versionedbeaconstate.go +++ b/spec/versionedbeaconstate.go @@ -46,8 +46,8 @@ type VersionedBeaconState struct { // 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.Gloas == nil && v.Heze == nil + return v.Phase0 == nil && v.Altair == nil && v.Bellatrix == nil && + v.Capella == nil && v.Deneb == nil && v.Electra == nil && v.Fulu == nil && v.Gloas == nil && v.Heze == nil } // Slot returns the slot of the state. diff --git a/spec/versionedexecutionpayload.go b/spec/versionedexecutionpayload.go index ace8fca6..5177ddc8 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 @@ -38,9 +38,8 @@ type VersionedExecutionPayload struct { // 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.Gloas == nil && v.Heze == nil) + return v.Version < DataVersionBellatrix || (v.Bellatrix == nil && + v.Capella == nil && v.Deneb == nil && v.Electra == nil && v.Fulu == nil && v.Gloas == nil && v.Heze == nil) } // ParentHash returns the parent hash of the execution payload. diff --git a/spec/versionedexecutionpayloadenvelope_test.go b/spec/versionedexecutionpayloadenvelope_test.go new file mode 100644 index 00000000..92a582c8 --- /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/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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_test.go b/spec/versionedexecutionrequests_test.go new file mode 100644 index 00000000..e6f952f9 --- /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/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/electra" + "github.com/ethpandaops/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 ed07a197..e2bda810 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 @@ -37,8 +37,8 @@ type VersionedIndexedAttestation struct { // 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.Gloas == nil && v.Heze == nil + return v.Phase0 == nil && v.Altair == nil && v.Bellatrix == nil && + v.Capella == nil && v.Deneb == nil && v.Electra == nil && v.Fulu == nil && v.Gloas == nil && v.Heze == nil } // AttestingIndices returns the attesting indices of the indexed attestation. diff --git a/spec/versionedpayloadattestation.go b/spec/versionedpayloadattestation.go new file mode 100644 index 00000000..55e0b461 --- /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/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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..229e98ed --- /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/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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..b3afc448 --- /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/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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..260e4eb6 --- /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/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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..02fc8637 --- /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/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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..a8dd309b --- /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/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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 7a779f81..894ced56 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/ethpandaops/go-eth2-client/spec/electra" + "github.com/ethpandaops/go-eth2-client/spec/gloas" "github.com/ethpandaops/go-eth2-client/spec/phase0" ) @@ -30,8 +31,11 @@ type VersionedSignedAggregateAndProof struct { Deneb *phase0.SignedAggregateAndProof Electra *electra.SignedAggregateAndProof Fulu *electra.SignedAggregateAndProof - Gloas *electra.SignedAggregateAndProof - Heze *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 + Heze *gloas.SignedAggregateAndProof } // AggregatorIndex returns the aggregator index of the aggregate. @@ -81,13 +85,13 @@ 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 0, errors.New("no gloas signed aggregate and proof") } return v.Gloas.Message.AggregatorIndex, nil case DataVersionHeze: if v.Heze == nil { - return 0, errors.New("no Heze signed aggregate and proof") + return 0, errors.New("no heze signed aggregate and proof") } return v.Heze.Message.AggregatorIndex, nil diff --git a/spec/versionedsignedaggregateandproof_test.go b/spec/versionedsignedaggregateandproof_test.go new file mode 100644 index 00000000..af929b01 --- /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/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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 6f64cd76..7264b535 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 @@ -165,6 +165,8 @@ func (v *VersionedSignedBeaconBlock) ProposerIndex() (phase0.ValidatorIndex, err } // ExecutionBlockHash returns the block hash of the beacon block. +// +//nolint:gocyclo func (v *VersionedSignedBeaconBlock) ExecutionBlockHash() (phase0.Hash32, error) { switch v.Version { case DataVersionBellatrix: @@ -338,6 +340,8 @@ func (v *VersionedSignedBeaconBlock) ExecutionTransactions() ([]bellatrix.Transa } // Graffiti returns the graffiti for the block. +// +//nolint:gocyclo func (v *VersionedSignedBeaconBlock) Graffiti() ([32]byte, error) { switch v.Version { case DataVersionPhase0: @@ -598,6 +602,8 @@ func (v *VersionedSignedBeaconBlock) Root() (phase0.Root, error) { } // BodyRoot returns the body root of the beacon block. +// +//nolint:gocyclo func (v *VersionedSignedBeaconBlock) BodyRoot() (phase0.Root, error) { switch v.Version { case DataVersionPhase0: @@ -784,6 +790,8 @@ func (v *VersionedSignedBeaconBlock) StateRoot() (phase0.Root, error) { } // RandaoReveal returns the randao reveal of the beacon block. +// +//nolint:gocyclo func (v *VersionedSignedBeaconBlock) RandaoReveal() (phase0.BLSSignature, error) { switch v.Version { case DataVersionPhase0: @@ -846,6 +854,8 @@ func (v *VersionedSignedBeaconBlock) RandaoReveal() (phase0.BLSSignature, error) } // ETH1Data returns the eth1 data of the beacon block. +// +//nolint:gocyclo func (v *VersionedSignedBeaconBlock) ETH1Data() (*phase0.ETH1Data, error) { switch v.Version { case DataVersionPhase0: @@ -908,6 +918,8 @@ func (v *VersionedSignedBeaconBlock) ETH1Data() (*phase0.ETH1Data, error) { } // Deposits returns the deposits of the beacon block. +// +//nolint:gocyclo func (v *VersionedSignedBeaconBlock) Deposits() ([]*phase0.Deposit, error) { switch v.Version { case DataVersionPhase0: @@ -970,6 +982,8 @@ func (v *VersionedSignedBeaconBlock) Deposits() ([]*phase0.Deposit, error) { } // VoluntaryExits returns the voluntary exits of the beacon block. +// +//nolint:gocyclo func (v *VersionedSignedBeaconBlock) VoluntaryExits() ([]*phase0.SignedVoluntaryExit, error) { switch v.Version { case DataVersionPhase0: @@ -1168,6 +1182,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: @@ -1436,7 +1452,7 @@ func (v *VersionedSignedBeaconBlock) BlobKZGCommitments() ([]deneb.KZGCommitment } // ExecutionRequests returs the execution requests for the block. -func (v *VersionedSignedBeaconBlock) ExecutionRequests() (*VersionedExecutionRequests, error) { +func (v *VersionedSignedBeaconBlock) ExecutionRequests() (*electra.ExecutionRequests, error) { switch v.Version { case DataVersionPhase0: return nil, errors.New("phase0 block does not have execution requests") @@ -1455,63 +1471,17 @@ func (v *VersionedSignedBeaconBlock) ExecutionRequests() (*VersionedExecutionReq return nil, errors.New("no electra block") } - return &VersionedExecutionRequests{ - Version: DataVersionElectra, - Electra: v.Electra.Message.Body.ExecutionRequests, - }, nil + return v.Electra.Message.Body.ExecutionRequests, nil case DataVersionFulu: if v.Fulu == nil || v.Fulu.Message == nil || v.Fulu.Message.Body == nil { return nil, errors.New("no fulu block") } - return &VersionedExecutionRequests{ - Version: DataVersionFulu, - Fulu: v.Fulu.Message.Body.ExecutionRequests, - }, nil + return v.Fulu.Message.Body.ExecutionRequests, nil case DataVersionGloas: - return nil, errors.New("no execution requests for gloas block") - case DataVersionHeze: - return nil, errors.New("no execution requests for heze block") - default: - return nil, errors.New("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 + return nil, errors.New("gloas block does not have execution requests") case DataVersionHeze: - if v.Heze == nil || v.Heze.Message == nil || v.Heze.Message.Body == nil { - return nil, errors.New("no heze block") - } - - return &VersionedSignedExecutionPayloadBid{ - Version: DataVersionHeze, - Heze: v.Heze.Message.Body.SignedExecutionPayloadBid, - }, nil + return nil, errors.New("heze block does not have execution requests") default: return nil, errors.New("unknown version") } @@ -1634,5 +1604,45 @@ func (v *VersionedSignedBeaconBlock) String() string { // IsEmpty returns true if the block is empty. func (v *VersionedSignedBeaconBlock) 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.Gloas == nil + v.Electra == nil && v.Fulu == nil && v.Gloas == nil && v.Heze == nil +} + +// 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 + case DataVersionHeze: + if v.Heze == nil || v.Heze.Message == nil || v.Heze.Message.Body == nil { + return nil, errors.New("no heze block") + } + + return &VersionedSignedExecutionPayloadBid{ + Version: DataVersionHeze, + Heze: v.Heze.Message.Body.SignedExecutionPayloadBid, + }, nil + default: + return nil, errors.New("unknown version") + } } diff --git a/spec/versionedsignedexecutionpayloadbid.go b/spec/versionedsignedexecutionpayloadbid.go index bafde615..a0a111b9 100644 --- a/spec/versionedsignedexecutionpayloadbid.go +++ b/spec/versionedsignedexecutionpayloadbid.go @@ -1,4 +1,4 @@ -// Copyright © 2021 - 2024 Attestant Limited. +// 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 @@ -49,7 +49,6 @@ func (v *VersionedSignedExecutionPayloadBid) String() string { return v.Heze.String() default: - return "unknown version" } } diff --git a/spec/versionedsignedexecutionpayloadbid_test.go b/spec/versionedsignedexecutionpayloadbid_test.go new file mode 100644 index 00000000..f9d02098 --- /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/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/bellatrix" + "github.com/ethpandaops/go-eth2-client/spec/deneb" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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_test.go b/spec/versionedsignedexecutionpayloadenvelope_test.go new file mode 100644 index 00000000..80ead2f9 --- /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/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/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 e5f1be89..5caf4da0 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 @@ -730,6 +730,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, @@ -1149,3 +1168,209 @@ 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) +} + +// AgnosticSignedExecutionPayloadEnvelope provides a fork-agnostic signed execution payload envelope. +func (s *Erroring) AgnosticSignedExecutionPayloadEnvelope(ctx context.Context, + opts *api.SignedExecutionPayloadEnvelopeOpts, +) ( + *api.Response[*all.SignedExecutionPayloadEnvelope], + 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.AgnosticSignedExecutionPayloadEnvelope(ctx, opts) +} + +// SubmitAgnosticExecutionPayloadBid submits a fork-agnostic execution payload bid. +func (s *Erroring) SubmitAgnosticExecutionPayloadBid(ctx context.Context, + opts *api.SubmitAgnosticExecutionPayloadBidOpts, +) 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.SubmitAgnosticExecutionPayloadBid(ctx, opts) +} + +// SubmitAgnosticExecutionPayloadEnvelope submits a fork-agnostic execution payload envelope. +func (s *Erroring) SubmitAgnosticExecutionPayloadEnvelope(ctx context.Context, + opts *api.SubmitAgnosticExecutionPayloadEnvelopeOpts, +) 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.SubmitAgnosticExecutionPayloadEnvelope(ctx, opts) +} diff --git a/testclients/erroring_test.go b/testclients/erroring_test.go index 339aa44a..759cf161 100644 --- a/testclients/erroring_test.go +++ b/testclients/erroring_test.go @@ -19,12 +19,35 @@ import ( consensusclient "github.com/ethpandaops/go-eth2-client" "github.com/ethpandaops/go-eth2-client/api" + apiv1 "github.com/ethpandaops/go-eth2-client/api/v1" "github.com/ethpandaops/go-eth2-client/mock" "github.com/ethpandaops/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..12a0cec0 --- /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/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" + "github.com/ethpandaops/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..0a80dde7 --- /dev/null +++ b/testclients/forkutil_test.go @@ -0,0 +1,78 @@ +package testclients + +import ( + "context" + "testing" + + "github.com/ethpandaops/go-eth2-client/api" + "github.com/ethpandaops/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 5898902b..d23cbf30 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 @@ -575,6 +575,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, @@ -862,3 +879,200 @@ 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) +} + +// AgnosticSignedBeaconBlock provides a fork-agnostic signed beacon block. +func (s *Sleepy) AgnosticSignedBeaconBlock(ctx context.Context, + opts *api.SignedBeaconBlockOpts, +) ( + *api.Response[*all.SignedBeaconBlock], + error, +) { + s.sleep(ctx) + + next, isNext := s.next.(consensusclient.SignedBeaconBlockProvider) + if !isNext { + return nil, errors.New("next does not support this call") + } + + return next.AgnosticSignedBeaconBlock(ctx, opts) +} + +// AgnosticSignedExecutionPayloadEnvelope provides a fork-agnostic signed execution payload envelope. +func (s *Sleepy) AgnosticSignedExecutionPayloadEnvelope(ctx context.Context, + opts *api.SignedExecutionPayloadEnvelopeOpts, +) ( + *api.Response[*all.SignedExecutionPayloadEnvelope], + error, +) { + s.sleep(ctx) + + next, isNext := s.next.(consensusclient.ExecutionPayloadProvider) + if !isNext { + return nil, errors.New("next does not support this call") + } + + return next.AgnosticSignedExecutionPayloadEnvelope(ctx, opts) +} + +// SubmitAgnosticExecutionPayloadBid submits a fork-agnostic execution payload bid. +func (s *Sleepy) SubmitAgnosticExecutionPayloadBid(ctx context.Context, + opts *api.SubmitAgnosticExecutionPayloadBidOpts, +) error { + s.sleep(ctx) + + next, isNext := s.next.(consensusclient.ExecutionPayloadBidSubmitter) + if !isNext { + return errors.New("next does not support this call") + } + + return next.SubmitAgnosticExecutionPayloadBid(ctx, opts) +} + +// SubmitAgnosticExecutionPayloadEnvelope submits a fork-agnostic execution payload envelope. +func (s *Sleepy) SubmitAgnosticExecutionPayloadEnvelope(ctx context.Context, + opts *api.SubmitAgnosticExecutionPayloadEnvelopeOpts, +) error { + s.sleep(ctx) + + next, isNext := s.next.(consensusclient.ExecutionPayloadEnvelopeSubmitter) + if !isNext { + return errors.New("next does not support this call") + } + + return next.SubmitAgnosticExecutionPayloadEnvelope(ctx, opts) +} diff --git a/testclients/sleepy_test.go b/testclients/sleepy_test.go index 05ae2538..7249a7a7 100644 --- a/testclients/sleepy_test.go +++ b/testclients/sleepy_test.go @@ -20,12 +20,35 @@ import ( consensusclient "github.com/ethpandaops/go-eth2-client" "github.com/ethpandaops/go-eth2-client/api" + apiv1 "github.com/ethpandaops/go-eth2-client/api/v1" "github.com/ethpandaops/go-eth2-client/mock" "github.com/ethpandaops/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()